diff --git a/src/__tests__/token-registry-functions/fixtures.ts b/src/__tests__/token-registry-functions/fixtures.ts index 767d804..280d26b 100644 --- a/src/__tests__/token-registry-functions/fixtures.ts +++ b/src/__tests__/token-registry-functions/fixtures.ts @@ -3,8 +3,9 @@ import { ethers as ethersV5 } from 'ethers'; import { JsonRpcProvider as JsonRpcProviderV6 } from 'ethersV6'; export const MOCK_V5_ADDRESS = '0xV5TokenRegistryContract'; export const MOCK_V4_ADDRESS = '0xV4TokenRegistryContract'; +export const MOCK_OWNER_ADDRESS = '0xowner'; -vi.mock('src/core', () => ({ +vi.mock('../../core', () => ({ encrypt: vi.fn(() => 'encrypted_remarks'), getTitleEscrowAddress: vi.fn(), isTitleEscrowVersion: vi.fn(() => Promise.resolve(true)), @@ -16,7 +17,7 @@ vi.mock('src/core', () => ({ }, })); -vi.mock('src/token-registry-v5', () => { +vi.mock('../../token-registry-v5', () => { return { v5Contracts: { TitleEscrow__factory: { @@ -34,11 +35,12 @@ vi.mock('src/token-registry-v5', () => { TradeTrustTokenMintable: '0xTradeTrustTokenMintableIdV5', TradeTrustTokenRestorable: '0xTradeTrustTokenRestorableIdV5', TradeTrustTokenBurnable: '0xTradeTrustTokenBurnableIdV5', + SBT: '0xSBTIdV5', }, }; }); -vi.mock('src/token-registry-v4', () => { +vi.mock('../../token-registry-v4', () => { return { v4Contracts: { TitleEscrow__factory: { @@ -56,6 +58,7 @@ vi.mock('src/token-registry-v4', () => { TradeTrustTokenMintable: '0xTradeTrustTokenMintableIdV4', TradeTrustTokenRestorable: '0xTradeTrustTokenRestorableIdV4', TradeTrustTokenBurnable: '0xTradeTrustTokenBurnableIdV4', + SBT: '0xSBTIdV4', }, }; }); @@ -78,6 +81,7 @@ export const mockV5TradeTrustTokenContract = { burn: vi.fn(() => Promise.resolve('v5_burn_tx_hash')), restore: vi.fn(() => Promise.resolve('v5_restore_tx_hash')), mint: vi.fn(() => Promise.resolve('v5_mint_tx_hash')), + ownerOf: vi.fn(() => Promise.resolve(MOCK_OWNER_ADDRESS)), }; export const mockV5TitleEscrowContract = { @@ -139,6 +143,7 @@ export const mockV4TradeTrustTokenContract = { burn: vi.fn(() => Promise.resolve('v4_burn_tx_hash')), restore: vi.fn(() => Promise.resolve('v4_restore_tx_hash')), mint: vi.fn(() => Promise.resolve('v4_mint_tx_hash')), + ownerOf: vi.fn(() => Promise.resolve(MOCK_OWNER_ADDRESS)), }; export const PRIVATE_KEY = '0x59c6995e998f97a5a004497e5f1ebce0c16828d44b3f8d0bfa3a89d271d5b6b9'; // random local key diff --git a/src/__tests__/token-registry-functions/mint.test.ts b/src/__tests__/token-registry-functions/mint.test.ts index b07e651..bee8c48 100644 --- a/src/__tests__/token-registry-functions/mint.test.ts +++ b/src/__tests__/token-registry-functions/mint.test.ts @@ -2,12 +2,12 @@ import './fixtures.js'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ethers as ethersV5, Wallet as WalletV5 } from 'ethers'; import { Wallet as WalletV6, Network, ethers as ethersV6 } from 'ethersV6'; -import * as coreModule from 'src/core'; +import * as coreModule from '../../core'; import { CHAIN_ID } from '@tradetrust-tt/tradetrust-utils'; -import { v5Contracts } from 'src/token-registry-v5'; -import { v4Contracts } from 'src/token-registry-v4'; -import { mint } from 'src/token-registry-functions/mint'; +import { v5Contracts } from '../../token-registry-v5'; +import { v4Contracts } from '../../token-registry-v4'; +import { mint } from '../../token-registry-functions'; import { MOCK_V4_ADDRESS, MOCK_V5_ADDRESS, @@ -17,7 +17,7 @@ import { providerV5, providerV6, } from './fixtures.js'; -import { ProviderInfo } from 'src/token-registry-functions/types.js'; +import { ProviderInfo } from '../../token-registry-functions/types'; const providers: ProviderInfo[] = [ { @@ -55,11 +55,11 @@ describe('Mint Token', () => { let wallet: ethersV5.Wallet | ethersV6.Wallet; if (ethersVersion === 'v5') { wallet = new WalletV5(PRIVATE_KEY, Provider as any) as ethersV5.Wallet; - vi.spyOn(wallet, 'getChainId').mockResolvedValue(CHAIN_ID.local as unknown as number); + vi.spyOn(wallet, 'getChainId').mockResolvedValue(mockChainId as unknown as number); } else { wallet = new WalletV6(PRIVATE_KEY, Provider as any); vi.spyOn(Provider, 'getNetwork').mockResolvedValue({ - chainId: CHAIN_ID.local, + chainId: mockChainId, } as unknown as Network); } const mockTokenRegistryAddress = isV5TT ? MOCK_V5_ADDRESS : MOCK_V4_ADDRESS; diff --git a/src/__tests__/token-registry-functions/ownerOf.test.ts b/src/__tests__/token-registry-functions/ownerOf.test.ts new file mode 100644 index 0000000..898b2ef --- /dev/null +++ b/src/__tests__/token-registry-functions/ownerOf.test.ts @@ -0,0 +1,146 @@ +import './fixtures.js'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ethers as ethersV5, Wallet as WalletV5 } from 'ethers'; +import { Wallet as WalletV6, Network, ethers as ethersV6 } from 'ethersV6'; +import * as coreModule from '../../core'; +import { CHAIN_ID } from '@tradetrust-tt/tradetrust-utils'; +import { ownerOf } from '../../token-registry-functions'; +import { v5Contracts } from '../../token-registry-v5'; +import { v4Contracts } from '../../token-registry-v4'; +import { + MOCK_OWNER_ADDRESS, + MOCK_V4_ADDRESS, + MOCK_V5_ADDRESS, + PRIVATE_KEY, + providerV5, + providerV6, +} from './fixtures'; +import { ProviderInfo } from '../../token-registry-functions/types'; + +const providers: ProviderInfo[] = [ + { + Provider: providerV5, + ethersVersion: 'v5', + titleEscrowVersion: 'v5', + }, + { + Provider: providerV5, + ethersVersion: 'v5', + titleEscrowVersion: 'v4', + }, + { + Provider: providerV6, + ethersVersion: 'v6', + titleEscrowVersion: 'v5', + }, + { + Provider: providerV6, + ethersVersion: 'v6', + titleEscrowVersion: 'v4', + }, +]; + +describe.each(providers)( + 'ownerOf function for ethers version $ethersVersion and TR version $titleEscrowVersion', + ({ Provider, ethersVersion, titleEscrowVersion }) => { + const mockTokenId = '0xTokenId'; + const mockChainId = CHAIN_ID.local; + const isV5TT = titleEscrowVersion === 'v5'; + // let mockContract = isV5TT ? mockV5TradeTrustTokenContract : mockV4TradeTrustTokenContract; + + let wallet: ethersV5.Wallet | ethersV6.Wallet; + if (ethersVersion === 'v5') { + wallet = new WalletV5(PRIVATE_KEY, Provider as any) as ethersV5.Wallet; + vi.spyOn(wallet, 'getChainId').mockResolvedValue(mockChainId as unknown as number); + } else { + wallet = new WalletV6(PRIVATE_KEY, Provider as any); + vi.spyOn(Provider, 'getNetwork').mockResolvedValue({ + chainId: mockChainId, + } as unknown as Network); + } + const mockTokenRegistryAddress = isV5TT ? MOCK_V5_ADDRESS : MOCK_V4_ADDRESS; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(coreModule, 'checkSupportsInterface').mockImplementation( + async (address, interfaceId) => { + return interfaceId === (isV5TT ? '0xSBTIdV5' : '0xSBTIdV4'); + }, + ); + }); + + // afterEach(() => { + // vi.restoreAllMocks(); + // }); + + describe('Successful Calls', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should return owner for V5/v4 contract (auto-detected)', async () => { + const result = await ownerOf( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { tokenId: mockTokenId }, + {}, + ); + + expect(result).toBe(MOCK_OWNER_ADDRESS); + expect( + (isV5TT ? v5Contracts : v4Contracts).TradeTrustToken__factory.connect, + ).toHaveBeenCalled(); + }); + + it('should return owner for V5/v4 contract (explicit version)', async () => { + const result = await ownerOf( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { tokenId: mockTokenId }, + { titleEscrowVersion }, + ); + + expect(result).toBe(MOCK_OWNER_ADDRESS); + expect(coreModule.checkSupportsInterface).not.toHaveBeenCalled(); + }); + }); + + describe('Error Handling', () => { + it('should throw when token registry address is missing', async () => { + await expect( + ownerOf( + { tokenRegistryAddress: '' }, + wallet, + { tokenId: mockTokenId }, + { chainId: mockChainId }, + ), + ).rejects.toThrow('Token registry address is required'); + }); + + it('should throw when provider is missing', async () => { + const signerWithoutProvider = new WalletV5('0x'.padEnd(66, '1')); + + await expect( + ownerOf( + { tokenRegistryAddress: mockTokenRegistryAddress }, + signerWithoutProvider, + { tokenId: mockTokenId }, + { chainId: mockChainId }, + ), + ).rejects.toThrow('Provider is required'); + }); + + it('should throw when version is unsupported', async () => { + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + + await expect( + ownerOf( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { tokenId: mockTokenId }, + { chainId: mockChainId }, + ), + ).rejects.toThrow('Only Token Registry V4/V5 is supported'); + }); + }); + }, +); diff --git a/src/__tests__/token-registry-functions/rejectTransfers.test.ts b/src/__tests__/token-registry-functions/rejectTransfers.test.ts index 86e3c16..d4fde98 100644 --- a/src/__tests__/token-registry-functions/rejectTransfers.test.ts +++ b/src/__tests__/token-registry-functions/rejectTransfers.test.ts @@ -2,15 +2,15 @@ import './fixtures.js'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ethers as ethersV5, Wallet as WalletV5 } from 'ethers'; import { ethers as ethersV6, Network, Wallet as WalletV6 } from 'ethersV6'; -import * as coreModule from 'src/core'; +import * as coreModule from '../../core'; import { CHAIN_ID } from '@tradetrust-tt/tradetrust-utils'; import { rejectTransferBeneficiary, rejectTransferHolder, rejectTransferOwners, -} from 'src/token-registry-functions/rejectTransfers'; +} from '../../token-registry-functions/rejectTransfers'; import { mockV5TitleEscrowContract, PRIVATE_KEY, providerV5, providerV6 } from './fixtures'; -import { ProviderInfo } from 'src/token-registry-functions/types.js'; +import { ProviderInfo } from '../../token-registry-functions/types.js'; const providers: ProviderInfo[] = [ { diff --git a/src/__tests__/token-registry-functions/returnToken.test.ts b/src/__tests__/token-registry-functions/returnToken.test.ts index 9fd461c..56ca104 100644 --- a/src/__tests__/token-registry-functions/returnToken.test.ts +++ b/src/__tests__/token-registry-functions/returnToken.test.ts @@ -2,16 +2,16 @@ import './fixtures.js'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ethers as ethersV5, Wallet as WalletV5 } from 'ethers'; import { Wallet as WalletV6, Network, ethers as ethersV6 } from 'ethersV6'; -import * as coreModule from 'src/core'; +import * as coreModule from '../../core'; import { CHAIN_ID } from '@tradetrust-tt/tradetrust-utils'; -import { v5Contracts } from 'src/token-registry-v5'; -import { v4Contracts } from 'src/token-registry-v4'; +import { v5Contracts } from '../../token-registry-v5'; +import { v4Contracts } from '../../token-registry-v4'; import { acceptReturned, rejectReturned, returnToIssuer, -} from 'src/token-registry-functions/returnToken'; +} from '../../token-registry-functions/returnToken'; import { MOCK_V4_ADDRESS, MOCK_V5_ADDRESS, @@ -23,7 +23,7 @@ import { providerV5, providerV6, } from './fixtures.js'; -import { ProviderInfo } from 'src/token-registry-functions/types.js'; +import { ProviderInfo } from '../../token-registry-functions/types.js'; const providers: ProviderInfo[] = [ { diff --git a/src/__tests__/token-registry-functions/transfers.test.ts b/src/__tests__/token-registry-functions/transfers.test.ts index 5f1d6f4..65c8b82 100644 --- a/src/__tests__/token-registry-functions/transfers.test.ts +++ b/src/__tests__/token-registry-functions/transfers.test.ts @@ -2,16 +2,16 @@ import './fixtures.js'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ethers as ethersV5, Wallet as WalletV5 } from 'ethers'; import { ethers as ethersV6, Network, Wallet as WalletV6 } from 'ethersV6'; -import * as coreModule from 'src/core'; -import { encrypt } from 'src/core'; +import * as coreModule from '../../core'; +import { encrypt } from '../../core'; import { CHAIN_ID } from '@tradetrust-tt/tradetrust-utils'; import { transferBeneficiary, transferHolder, transferOwners, nominate, -} from 'src/token-registry-functions'; -import { ProviderInfo } from 'src/token-registry-functions/types'; +} from '../../token-registry-functions'; +import { ProviderInfo } from '../../token-registry-functions/types'; import { mockV4TitleEscrowContract, mockV5TitleEscrowContract, diff --git a/src/core/endorsement-chain/useEndorsementChain.ts b/src/core/endorsement-chain/useEndorsementChain.ts index 14104c7..d5f389c 100644 --- a/src/core/endorsement-chain/useEndorsementChain.ts +++ b/src/core/endorsement-chain/useEndorsementChain.ts @@ -146,18 +146,16 @@ export const getDocumentOwner = async ( // Check Title Escrow Interface Support export const checkSupportsInterface = async ( - titleEscrowAddress: string, + contractAddress: string, interfaceId: string, provider: Provider | ethersV6.Provider, ): Promise => { try { const Contract = getEthersContractFromProvider(provider); - const titleEscrowAbi = [ - 'function supportsInterface(bytes4 interfaceId) external view returns (bool)', - ]; + const abi = ['function supportsInterface(bytes4 interfaceId) external view returns (bool)']; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const titleEscrowContract = new Contract(titleEscrowAddress, titleEscrowAbi, provider as any); - return await titleEscrowContract.supportsInterface(interfaceId); + const contract = new Contract(contractAddress, abi, provider as any); + return await contract.supportsInterface(interfaceId); } catch { return false; } diff --git a/src/token-registry-functions/index.ts b/src/token-registry-functions/index.ts index fa484d0..607e167 100644 --- a/src/token-registry-functions/index.ts +++ b/src/token-registry-functions/index.ts @@ -2,3 +2,4 @@ export * from './transfer'; export * from './rejectTransfers'; export * from './returnToken'; export * from './mint'; +export * from './ownerOf'; diff --git a/src/token-registry-functions/mint.ts b/src/token-registry-functions/mint.ts index 0594464..00fea5f 100644 --- a/src/token-registry-functions/mint.ts +++ b/src/token-registry-functions/mint.ts @@ -1,6 +1,6 @@ -import { checkSupportsInterface, encrypt } from 'src/core'; -import { v5Contracts, v5SupportInterfaceIds } from 'src/token-registry-v5'; -import { v4Contracts, v4SupportInterfaceIds } from 'src/token-registry-v4'; +import { checkSupportsInterface, encrypt } from '../core'; +import { v5Contracts, v5SupportInterfaceIds } from '../token-registry-v5'; +import { v4Contracts, v4SupportInterfaceIds } from '../token-registry-v4'; import { Signer as SignerV6 } from 'ethersV6'; import { ContractTransaction, Signer } from 'ethers'; import { getTxOptions } from './utils'; diff --git a/src/token-registry-functions/ownerOf.ts b/src/token-registry-functions/ownerOf.ts new file mode 100644 index 0000000..9cd2c32 --- /dev/null +++ b/src/token-registry-functions/ownerOf.ts @@ -0,0 +1,68 @@ +import { checkSupportsInterface } from '../core'; +import { v5Contracts, v5SupportInterfaceIds } from '../token-registry-v5'; +import { v4Contracts, v4SupportInterfaceIds } from '../token-registry-v4'; +import { Signer as SignerV6 } from 'ethersV6'; +import { Signer } from 'ethers'; +import { OwnerOfTokenOptions, OwnerOfTokenParams, TransactionOptions } from './types'; + +/** + * Retrieves the owner of a given token from the TradeTrustToken contract. + * Supports both Token Registry V4 and V5 implementations. + * @param {OwnerOfTokenOptions} contractOptions - Options containing the token registry address. + * @param {Signer | SignerV6} signer - Signer instance (v5 or v6) used to query the blockchain. + * @param {OwnerOfTokenParams} params - Contains the `tokenId` of the token to query ownership for. + * @param {TransactionOptions} options - Includes the `titleEscrowVersion` and other optional metadata for interface detection. + * @returns {Promise} A promise that resolves to the owner address of the specified token. + * @throws {Error} If token registry address or signer provider is not provided. + * @throws {Error} If the token registry does not support V4 or V5 interfaces. + */ +const ownerOf = async ( + contractOptions: OwnerOfTokenOptions, + signer: Signer | SignerV6, + params: OwnerOfTokenParams, + options: TransactionOptions, +): Promise => { + const { tokenRegistryAddress } = contractOptions; + const { titleEscrowVersion } = options; + const { tokenId } = params; + + if (!tokenRegistryAddress) throw new Error('Token registry address is required'); + if (!signer.provider) throw new Error('Provider is required'); + + // Detect version if not explicitly provided checkSupportsInterface + let isV5TT = titleEscrowVersion === 'v5'; + let isV4TT = titleEscrowVersion === 'v4'; + + if (titleEscrowVersion === undefined) { + [isV4TT, isV5TT] = await Promise.all([ + checkSupportsInterface(tokenRegistryAddress, v4SupportInterfaceIds.SBT, signer.provider), + checkSupportsInterface(tokenRegistryAddress, v5SupportInterfaceIds.SBT, signer.provider), + ]); + } + + if (!isV4TT && !isV5TT) { + throw new Error('Only Token Registry V4/V5 is supported'); + } + // Connect V5 contract by default + let tradeTrustTokenContract: v5Contracts.TradeTrustToken | v4Contracts.TradeTrustToken; + if (isV5TT) { + tradeTrustTokenContract = v5Contracts.TradeTrustToken__factory.connect( + tokenRegistryAddress, + signer, + ); + } else if (isV4TT) { + tradeTrustTokenContract = v4Contracts.TradeTrustToken__factory.connect( + tokenRegistryAddress, + signer as Signer, + ); + } + + // Send the actual transaction + + if (isV5TT) { + return await (tradeTrustTokenContract as v5Contracts.TradeTrustToken).ownerOf(tokenId); + } else if (isV4TT) { + return await (tradeTrustTokenContract as v4Contracts.TradeTrustToken).ownerOf(tokenId); + } +}; +export { ownerOf }; diff --git a/src/token-registry-functions/rejectTransfers.ts b/src/token-registry-functions/rejectTransfers.ts index 207480d..85023e8 100644 --- a/src/token-registry-functions/rejectTransfers.ts +++ b/src/token-registry-functions/rejectTransfers.ts @@ -3,8 +3,8 @@ import { getTitleEscrowAddress, isTitleEscrowVersion, TitleEscrowInterface, -} from 'src/core'; -import { v5Contracts } from 'src/token-registry-v5'; +} from '../core'; +import { v5Contracts } from '../token-registry-v5'; import { Signer as SignerV6 } from 'ethersV6'; import { ContractTransaction, Signer } from 'ethers'; import { getTxOptions } from './utils'; diff --git a/src/token-registry-functions/returnToken.ts b/src/token-registry-functions/returnToken.ts index a7b8d0e..3754437 100644 --- a/src/token-registry-functions/returnToken.ts +++ b/src/token-registry-functions/returnToken.ts @@ -4,9 +4,9 @@ import { getTitleEscrowAddress, isTitleEscrowVersion, TitleEscrowInterface, -} from 'src/core'; -import { v5Contracts, v5SupportInterfaceIds } from 'src/token-registry-v5'; -import { v4Contracts, v4SupportInterfaceIds } from 'src/token-registry-v4'; +} from '../core'; +import { v5Contracts, v5SupportInterfaceIds } from '../token-registry-v5'; +import { v4Contracts, v4SupportInterfaceIds } from '../token-registry-v4'; import { Signer as SignerV6 } from 'ethersV6'; import { ContractTransaction, Signer } from 'ethers'; import { getTxOptions } from './utils'; diff --git a/src/token-registry-functions/transfer.ts b/src/token-registry-functions/transfer.ts index a4fa004..4629d8f 100644 --- a/src/token-registry-functions/transfer.ts +++ b/src/token-registry-functions/transfer.ts @@ -3,9 +3,9 @@ import { getTitleEscrowAddress, isTitleEscrowVersion, TitleEscrowInterface, -} from 'src/core'; -import { v4Contracts } from 'src/token-registry-v4'; -import { v5Contracts } from 'src/token-registry-v5'; +} from '../core'; +import { v4Contracts } from '../token-registry-v4'; +import { v5Contracts } from '../token-registry-v5'; import { Signer as SignerV6 } from 'ethersV6'; import { ContractTransaction, Signer } from 'ethers'; import { diff --git a/src/token-registry-functions/types.ts b/src/token-registry-functions/types.ts index 82397f4..3af157f 100644 --- a/src/token-registry-functions/types.ts +++ b/src/token-registry-functions/types.ts @@ -26,6 +26,9 @@ export interface MintTokenParams { tokenId: string | number; remarks?: string; } +export interface OwnerOfTokenParams { + tokenId: string | number; +} export interface TransactionOptions { chainId?: CHAIN_ID; @@ -57,6 +60,9 @@ export type RejectReturnedOptions = { export type MintTokenOptions = { tokenRegistryAddress: string; }; +export type OwnerOfTokenOptions = { + tokenRegistryAddress: string; +}; export interface TransferHolderParams { holderAddress: string; diff --git a/src/token-registry-functions/utils.ts b/src/token-registry-functions/utils.ts index df031e0..63065ac 100644 --- a/src/token-registry-functions/utils.ts +++ b/src/token-registry-functions/utils.ts @@ -1,4 +1,4 @@ -import { isV6EthersProvider } from 'src/utils/ethers'; +import { isV6EthersProvider } from '../utils/ethers'; import { GasValue } from './types'; import { CHAIN_ID, SUPPORTED_CHAINS } from '@tradetrust-tt/tradetrust-utils'; import { Signer } from 'ethers';