diff --git a/src/__tests__/token-registry-functions/fixtures.ts b/src/__tests__/token-registry-functions/fixtures.ts new file mode 100644 index 0000000..767d804 --- /dev/null +++ b/src/__tests__/token-registry-functions/fixtures.ts @@ -0,0 +1,147 @@ +import { vi } from 'vitest'; +import { ethers as ethersV5 } from 'ethers'; +import { JsonRpcProvider as JsonRpcProviderV6 } from 'ethersV6'; +export const MOCK_V5_ADDRESS = '0xV5TokenRegistryContract'; +export const MOCK_V4_ADDRESS = '0xV4TokenRegistryContract'; + +vi.mock('src/core', () => ({ + encrypt: vi.fn(() => 'encrypted_remarks'), + getTitleEscrowAddress: vi.fn(), + isTitleEscrowVersion: vi.fn(() => Promise.resolve(true)), + checkSupportsInterface: vi.fn(), + + TitleEscrowInterface: { + V4: '0xTitleEscrowIdV4', + V5: '0xTitleEscrowIdV5', + }, +})); + +vi.mock('src/token-registry-v5', () => { + return { + v5Contracts: { + TitleEscrow__factory: { + connect: vi.fn(() => mockV5TitleEscrowContract), + }, + TradeTrustToken__factory: { + connect: vi.fn(() => mockV5TradeTrustTokenContract), + }, + TitleEscrowFactory__factory: { + connect: vi.fn(() => mockV5TitleEscrowFactoryContract), + }, + }, + v5SupportInterfaceIds: { + TitleEscrow: '0xTitleEscrowIdV5', + TradeTrustTokenMintable: '0xTradeTrustTokenMintableIdV5', + TradeTrustTokenRestorable: '0xTradeTrustTokenRestorableIdV5', + TradeTrustTokenBurnable: '0xTradeTrustTokenBurnableIdV5', + }, + }; +}); + +vi.mock('src/token-registry-v4', () => { + return { + v4Contracts: { + TitleEscrow__factory: { + connect: vi.fn(() => mockV4TitleEscrowContract), + }, + TradeTrustToken__factory: { + connect: vi.fn(() => mockV4TradeTrustTokenContract), + }, + TitleEscrowFactory__factory: { + connect: vi.fn(() => mockV4TitleEscrowFactoryContract), + }, + }, + v4SupportInterfaceIds: { + TitleEscrow: '0xTitleEscrowIdV4', + TradeTrustTokenMintable: '0xTradeTrustTokenMintableIdV4', + TradeTrustTokenRestorable: '0xTradeTrustTokenRestorableIdV4', + TradeTrustTokenBurnable: '0xTradeTrustTokenBurnableIdV4', + }, + }; +}); + +export const mockV5TitleEscrowFactoryContract = { + callStatic: { + getEscrowAddress: vi.fn(), + }, + getEscrowAddress: vi.fn(() => Promise.resolve('0xV5titleescrow')), +}; + +export const mockV5TradeTrustTokenContract = { + callStatic: { + burn: vi.fn(), + restore: vi.fn(), + mint: vi.fn(), + }, + supportsInterface: vi.fn(), + titleEscrowFactory: vi.fn(() => Promise.resolve('0xV5titleescrowfactory')), + 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')), +}; + +export const mockV5TitleEscrowContract = { + supportsInterface: vi.fn(), + callStatic: { + transferHolder: vi.fn(), + transferBeneficiary: vi.fn(), + transferOwners: vi.fn(), + nominate: vi.fn(), + rejectTransferHolder: vi.fn(), + rejectTransferBeneficiary: vi.fn(), + rejectTransferOwners: vi.fn(), + returnToIssuer: vi.fn(), + }, + transferHolder: vi.fn(() => Promise.resolve('v5_transfer_holder_tx_hash')), + transferBeneficiary: vi.fn(() => Promise.resolve('v5_transfer_beneficiary_tx_hash')), + transferOwners: vi.fn(() => Promise.resolve('v5_transfer_owners_tx_hash')), + nominate: vi.fn(() => Promise.resolve('v5_nominate_tx_hash')), + holder: vi.fn(() => Promise.resolve('0xcurrent_holder')), + beneficiary: vi.fn(() => Promise.resolve('0xcurrent_beneficiary')), + rejectTransferHolder: vi.fn(() => Promise.resolve('v5_reject_transfer_holder_tx_hash')), + rejectTransferBeneficiary: vi.fn(() => Promise.resolve('v5_reject_transfer_beneficiary_tx_hash')), + rejectTransferOwners: vi.fn(() => Promise.resolve('v5_reject_transfer_owners_tx_hash')), + returnToIssuer: vi.fn(() => Promise.resolve('v5_return_to_issuer_tx_hash')), +}; + +export const mockV4TitleEscrowContract = { + supportsInterface: vi.fn(), + callStatic: { + transferHolder: vi.fn(), + transferBeneficiary: vi.fn(), + transferOwners: vi.fn(), + nominate: vi.fn(), + surrender: vi.fn(), + }, + transferHolder: vi.fn(() => Promise.resolve('v4_transfer_holder_tx_hash')), + transferBeneficiary: vi.fn(() => Promise.resolve('v4_transfer_beneficiary_tx_hash')), + transferOwners: vi.fn(() => Promise.resolve('v4_transfer_owners_tx_hash')), + nominate: vi.fn(() => Promise.resolve('v4_nominate_tx_hash')), + holder: vi.fn(() => Promise.resolve('0xcurrent_holder')), + beneficiary: vi.fn(() => Promise.resolve('0xcurrent_beneficiary')), + surrender: vi.fn(() => Promise.resolve('v4_surrender_tx_hash')), +}; +export const mockV4TitleEscrowFactoryContract = { + callStatic: { + getEscrowAddress: vi.fn(), + }, + getEscrowAddress: vi.fn(() => Promise.resolve('0xV4titleescrow')), +}; + +export const mockV4TradeTrustTokenContract = { + callStatic: { + burn: vi.fn(), + restore: vi.fn(), + mint: vi.fn(), + }, + titleEscrowFactory: vi.fn(() => Promise.resolve('0xV4titleescrowfactory')), + supportsInterface: vi.fn(), + 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')), +}; + +export const PRIVATE_KEY = '0x59c6995e998f97a5a004497e5f1ebce0c16828d44b3f8d0bfa3a89d271d5b6b9'; // random local key + +export const providerV5 = new ethersV5.providers.JsonRpcProvider(); +export const providerV6 = new JsonRpcProviderV6(); diff --git a/src/__tests__/token-registry-functions/mint.test.ts b/src/__tests__/token-registry-functions/mint.test.ts new file mode 100644 index 0000000..b07e651 --- /dev/null +++ b/src/__tests__/token-registry-functions/mint.test.ts @@ -0,0 +1,216 @@ +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 { 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 { + MOCK_V4_ADDRESS, + MOCK_V5_ADDRESS, + mockV4TradeTrustTokenContract, + mockV5TradeTrustTokenContract, + PRIVATE_KEY, + providerV5, + providerV6, +} from './fixtures.js'; +import { ProviderInfo } from 'src/token-registry-functions/types.js'; + +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('Mint Token', () => { + const mockTokenId = '0xTokenId'; + const mockRemarks = 'Mint remarks'; + const mockChainId = CHAIN_ID.local; + describe.each(providers)( + 'Mint Token with TR version $titleEscrowVersion and ethers version $ethersVersion', + async ({ Provider, ethersVersion, titleEscrowVersion }) => { + const isV5TT = titleEscrowVersion === 'v5'; + // let mockContract = isV5TT ? mockV5TradeTrustTokenContract : mockV4TradeTrustTokenContract; + const mockTxResponse = titleEscrowVersion === 'v5' ? 'v5_mint_tx_hash' : 'v4_mint_tx_hash'; + + 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); + } else { + wallet = new WalletV6(PRIVATE_KEY, Provider as any); + vi.spyOn(Provider, 'getNetwork').mockResolvedValue({ + chainId: CHAIN_ID.local, + } as unknown as Network); + } + const mockTokenRegistryAddress = isV5TT ? MOCK_V5_ADDRESS : MOCK_V4_ADDRESS; + const mockBeneficiaryAddress = '0xBeneficiaryAddress'; + const mockHolderAddress = '0xHolderAddress'; + // const titleEscrowAddress = isV5TT ? '0xv5contract' : '0xv4contract'; + beforeEach(() => { + vi.clearAllMocks(); + // vi.spyOn(coreModule, 'encrypt').mockReturnValue(mockEncryptedRemarks.slice(2)); + vi.spyOn(coreModule, 'checkSupportsInterface').mockImplementation( + async (address, interfaceId) => { + return ( + interfaceId === + (isV5TT ? '0xTradeTrustTokenMintableIdV5' : '0xTradeTrustTokenMintableIdV4') + ); + }, + ); + mockV5TradeTrustTokenContract.callStatic.mint.mockResolvedValue(true); + mockV4TradeTrustTokenContract.callStatic.mint.mockResolvedValue(true); + }); + + it('should Mint token with remarks', async () => { + const result = await mint( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { + beneficiaryAddress: mockBeneficiaryAddress, + holderAddress: mockHolderAddress, + tokenId: mockTokenId, + remarks: mockRemarks, + }, + { chainId: mockChainId, id: 'encryption-id' }, + ); + + expect(result).toEqual(mockTxResponse); + if (isV5TT) expect(coreModule.encrypt).toHaveBeenCalledWith(mockRemarks, 'encryption-id'); + expect( + (isV5TT ? v5Contracts : v4Contracts).TradeTrustToken__factory.connect, + ).toHaveBeenCalled(); + }); + + it('should mint token without remarks', async () => { + const result = await mint( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { + beneficiaryAddress: mockBeneficiaryAddress, + holderAddress: mockHolderAddress, + tokenId: mockTokenId, + }, + { chainId: mockChainId, titleEscrowVersion }, + ); + + expect(result).toEqual(mockTxResponse); + expect(coreModule.encrypt).not.toHaveBeenCalled(); + }); + + it('should throw when callStatic fails', async () => { + const mockError = new Error('callStatic error'); + if (isV5TT) { + mockV5TradeTrustTokenContract.callStatic.mint.mockRejectedValue(mockError); + } else { + mockV4TradeTrustTokenContract.callStatic.mint.mockRejectedValue(mockError); + } + await expect( + mint( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { + beneficiaryAddress: mockBeneficiaryAddress, + holderAddress: mockHolderAddress, + tokenId: mockTokenId, + remarks: mockRemarks, + }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Pre-check (callStatic) for mint failed'); + if (isV5TT) { + mockV5TradeTrustTokenContract.callStatic.mint = vi.fn(); + } else { + mockV4TradeTrustTokenContract.callStatic.mint = vi.fn(); + } + }); + + it('should throw when token registry address is missing', async () => { + await expect( + mint( + { tokenRegistryAddress: '' }, + wallet, + { + beneficiaryAddress: mockBeneficiaryAddress, + holderAddress: mockHolderAddress, + 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( + mint( + { tokenRegistryAddress: mockTokenRegistryAddress }, + signerWithoutProvider, + { + beneficiaryAddress: mockBeneficiaryAddress, + holderAddress: mockHolderAddress, + tokenId: mockTokenId, + remarks: mockRemarks, + }, + { chainId: mockChainId }, + ), + ).rejects.toThrow('Provider is required'); + }); + + it('should throw when version is unsupported', async () => { + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + + await expect( + mint( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { + beneficiaryAddress: mockBeneficiaryAddress, + holderAddress: mockHolderAddress, + tokenId: mockTokenId, + remarks: mockRemarks, + }, + { chainId: mockChainId }, + ), + ).rejects.toThrow('Only Token Registry V4/V5 is supported'); + }); + + it('should work with explicit V5/V4 version', async () => { + const result = await mint( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { + beneficiaryAddress: mockBeneficiaryAddress, + holderAddress: mockHolderAddress, + tokenId: mockTokenId, + remarks: mockRemarks, + }, + { chainId: mockChainId, id: 'encryption-id', titleEscrowVersion }, + ); + + expect(result).toEqual(mockTxResponse); + expect(coreModule.checkSupportsInterface).not.toHaveBeenCalled(); + }); + }, + ); +}); diff --git a/src/__tests__/token-registry-functions/rejectTransfers.test.ts b/src/__tests__/token-registry-functions/rejectTransfers.test.ts new file mode 100644 index 0000000..86e3c16 --- /dev/null +++ b/src/__tests__/token-registry-functions/rejectTransfers.test.ts @@ -0,0 +1,438 @@ +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 { CHAIN_ID } from '@tradetrust-tt/tradetrust-utils'; +import { + rejectTransferBeneficiary, + rejectTransferHolder, + rejectTransferOwners, +} from 'src/token-registry-functions/rejectTransfers'; +import { mockV5TitleEscrowContract, PRIVATE_KEY, providerV5, providerV6 } from './fixtures'; +import { ProviderInfo } from 'src/token-registry-functions/types.js'; + +const providers: ProviderInfo[] = [ + { + Provider: providerV5, + ethersVersion: 'v5', + titleEscrowVersion: 'v5', + }, + { + Provider: providerV6, + ethersVersion: 'v6', + titleEscrowVersion: 'v5', + }, +]; + +describe.each(providers)( + 'Reject Transfers', + async ({ Provider, ethersVersion, titleEscrowVersion }) => { + const mockTokenRegistryAddress = '0xTokenRegistry'; + const mockTokenId = '0xTokenId'; + const mockTitleEscrowAddress = '0xTitleEscrow'; + const mockRemarks = 'Rejection remarks'; + const mockChainId = CHAIN_ID.local; + const mockEncryptedRemarks = '0xencryptedRemarks'; + + let wallet: ethersV5.Wallet | ethersV6.Wallet; + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + + if (ethersVersion === 'v5') { + wallet = new WalletV5(PRIVATE_KEY, Provider as any) as ethersV5.Wallet; + // wallet = { + // ...wallet, + // address: '0xcurrent_holder', + // getChainId: vi.fn().mockResolvedValue(CHAIN_ID.mainnet as unknown as number), + // } as any; + vi.spyOn(wallet, 'getChainId').mockResolvedValue(CHAIN_ID.mainnet as unknown as number); + } else { + wallet = new WalletV6(PRIVATE_KEY, Provider as any); + vi.spyOn(Provider, 'getNetwork').mockResolvedValue({ + chainId: CHAIN_ID.mainnet, + } as unknown as Network); + // vi.spyOn(wallet, 'getAddress').mockResolvedValue('0xcurrent_holder'); + } + + // Mock core functions + vi.spyOn(coreModule, 'getTitleEscrowAddress').mockResolvedValue(mockTitleEscrowAddress); + vi.spyOn(coreModule, 'isTitleEscrowVersion').mockResolvedValue(true); + vi.spyOn(coreModule, 'encrypt').mockReturnValue(mockEncryptedRemarks.slice(2)); + + // Mock contract calls + }); + describe(`Reject Transfers Holder with ethers version ${ethersVersion}`, () => { + it('should reject transfer holder with signer and all required parameters', async () => { + const result = await rejectTransferHolder( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ); + + expect(result).toEqual('v5_reject_transfer_holder_tx_hash'); + }); + + it('should reject transfer holder when titleEscrowAddress is provided', async () => { + const result = await rejectTransferHolder( + { + titleEscrowAddress: mockTitleEscrowAddress, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ); + + expect(result).toEqual('v5_reject_transfer_holder_tx_hash'); + expect(coreModule.getTitleEscrowAddress).not.toHaveBeenCalled(); + }); + + it('should reject transfer holder without remarks', async () => { + const result = await rejectTransferHolder( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + {}, + { chainId: mockChainId }, + ); + + expect(result).toEqual('v5_reject_transfer_holder_tx_hash'); + expect(coreModule.encrypt).not.toHaveBeenCalled(); + }); + + it('should throw error when tokenRegistryAddress is missing', async () => { + vi.mocked(coreModule.getTitleEscrowAddress).mockResolvedValue(undefined); + await expect( + rejectTransferHolder( + { + tokenId: mockTokenId, + } as any, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Token registry address is required'); + }); + + it('should throw error when provider is missing', async () => { + const signerWithoutProvider = new WalletV5('0x'.padEnd(66, '1')); + + await expect( + rejectTransferHolder( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + signerWithoutProvider, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Provider is required'); + }); + + it('should throw error when title escrow is not V5', async () => { + vi.spyOn(coreModule, 'isTitleEscrowVersion').mockResolvedValue(false); + + await expect( + rejectTransferHolder( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Only Token Registry V5 is supported'); + }); + + it('should throw error when callStatic fails', async () => { + mockV5TitleEscrowContract.callStatic.rejectTransferHolder.mockRejectedValue( + new Error('Simulated failure'), + ); + + await expect( + rejectTransferHolder( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Pre-check (callStatic) for rejectTransferHolder failed'); + mockV5TitleEscrowContract.callStatic.rejectTransferHolder = vi.fn(); + }); + + it('should use explicit titleEscrowVersion when provided', async () => { + await rejectTransferHolder( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id', titleEscrowVersion }, + ); + + expect(coreModule.isTitleEscrowVersion).not.toHaveBeenCalled(); + }); + }); + + describe(`Reject Transfers Beneficiary with ethers version ${ethersVersion}`, () => { + it('should reject transfer beneficiary with signer and all required parameters', async () => { + const result = await rejectTransferBeneficiary( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ); + + expect(result).toEqual('v5_reject_transfer_beneficiary_tx_hash'); + }); + + it('should reject transfer beneficiary when titleEscrowAddress is provided', async () => { + const result = await rejectTransferBeneficiary( + { + titleEscrowAddress: mockTitleEscrowAddress, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ); + + expect(result).toEqual('v5_reject_transfer_beneficiary_tx_hash'); + expect(coreModule.getTitleEscrowAddress).not.toHaveBeenCalled(); + }); + + it('should reject transfer beneficiary without remarks', async () => { + const result = await rejectTransferBeneficiary( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + {}, + { chainId: mockChainId }, + ); + + expect(result).toEqual('v5_reject_transfer_beneficiary_tx_hash'); + expect(coreModule.encrypt).not.toHaveBeenCalled(); + }); + + it('should throw error when tokenRegistryAddress is missing', async () => { + vi.mocked(coreModule.getTitleEscrowAddress).mockResolvedValue(undefined); + await expect( + rejectTransferBeneficiary( + { + tokenId: mockTokenId, + } as any, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Token registry address is required'); + }); + + it('should throw error when provider is missing', async () => { + const signerWithoutProvider = new WalletV5('0x'.padEnd(66, '1')); + + await expect( + rejectTransferBeneficiary( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + signerWithoutProvider, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Provider is required'); + }); + + it('should throw error when title escrow is not V5', async () => { + vi.spyOn(coreModule, 'isTitleEscrowVersion').mockResolvedValue(false); + + await expect( + rejectTransferBeneficiary( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Only Token Registry V5 is supported'); + }); + + it('should throw error when callStatic fails', async () => { + mockV5TitleEscrowContract.callStatic.rejectTransferBeneficiary.mockRejectedValue( + new Error('Simulated failure'), + ); + + await expect( + rejectTransferBeneficiary( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Pre-check (callStatic) for rejectTransferBeneficiary failed'); + mockV5TitleEscrowContract.callStatic.rejectTransferBeneficiary = vi.fn(); + }); + + it('should use explicit titleEscrowVersion when provided', async () => { + await rejectTransferBeneficiary( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id', titleEscrowVersion }, + ); + + expect(coreModule.isTitleEscrowVersion).not.toHaveBeenCalled(); + }); + }); + + describe(`Reject Transfers Owners with ethers version ${ethersVersion}`, () => { + it('should reject transfer beneficiary with signer and all required parameters', async () => { + const result = await rejectTransferOwners( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ); + + expect(result).toEqual('v5_reject_transfer_owners_tx_hash'); + }); + + it('should reject transfer beneficiary when titleEscrowAddress is provided', async () => { + const result = await rejectTransferOwners( + { + titleEscrowAddress: mockTitleEscrowAddress, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ); + + expect(result).toEqual('v5_reject_transfer_owners_tx_hash'); + expect(coreModule.getTitleEscrowAddress).not.toHaveBeenCalled(); + }); + + it('should reject transfer beneficiary without remarks', async () => { + const result = await rejectTransferOwners( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + {}, + { chainId: mockChainId }, + ); + + expect(result).toEqual('v5_reject_transfer_owners_tx_hash'); + expect(coreModule.encrypt).not.toHaveBeenCalled(); + }); + + it('should throw error when tokenRegistryAddress is missing', async () => { + vi.mocked(coreModule.getTitleEscrowAddress).mockResolvedValue(undefined); + await expect( + rejectTransferOwners( + { + tokenId: mockTokenId, + } as any, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Token registry address is required'); + }); + + it('should throw error when provider is missing', async () => { + const signerWithoutProvider = new WalletV5('0x'.padEnd(66, '1')); + + await expect( + rejectTransferOwners( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + signerWithoutProvider, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Provider is required'); + }); + + it('should throw error when title escrow is not V5', async () => { + vi.spyOn(coreModule, 'isTitleEscrowVersion').mockResolvedValue(false); + + await expect( + rejectTransferOwners( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Only Token Registry V5 is supported'); + }); + + it('should throw error when callStatic fails', async () => { + mockV5TitleEscrowContract.callStatic.rejectTransferOwners.mockRejectedValue( + new Error('Simulated failure'), + ); + + await expect( + rejectTransferOwners( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Pre-check (callStatic) for rejectTransferOwners failed'); + mockV5TitleEscrowContract.callStatic.rejectTransferOwners = vi.fn(); + }); + + it('should use explicit titleEscrowVersion when provided', async () => { + await rejectTransferOwners( + { + tokenRegistryAddress: mockTokenRegistryAddress, + tokenId: mockTokenId, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id', titleEscrowVersion }, + ); + + expect(coreModule.isTitleEscrowVersion).not.toHaveBeenCalled(); + }); + }); + }, +); diff --git a/src/__tests__/token-registry-functions/returnToken.test.ts b/src/__tests__/token-registry-functions/returnToken.test.ts new file mode 100644 index 0000000..9fd461c --- /dev/null +++ b/src/__tests__/token-registry-functions/returnToken.test.ts @@ -0,0 +1,452 @@ +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 { CHAIN_ID } from '@tradetrust-tt/tradetrust-utils'; +import { v5Contracts } from 'src/token-registry-v5'; +import { v4Contracts } from 'src/token-registry-v4'; +import { + acceptReturned, + rejectReturned, + returnToIssuer, +} from 'src/token-registry-functions/returnToken'; +import { + MOCK_V4_ADDRESS, + MOCK_V5_ADDRESS, + mockV4TitleEscrowContract, + mockV4TradeTrustTokenContract, + mockV5TitleEscrowContract, + mockV5TradeTrustTokenContract, + PRIVATE_KEY, + providerV5, + providerV6, +} from './fixtures.js'; +import { ProviderInfo } from 'src/token-registry-functions/types.js'; + +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('Return Token', () => { + const mockTokenId = '0xTokenId'; + const mockRemarks = 'Return remarks'; + const mockChainId = CHAIN_ID.local; + const mockEncryptedRemarks = '0xencryptedRemarks'; + describe.each(providers)( + 'Return Token with TR version $titleEscrowVersion and ethers version $ethersVersion', + async ({ Provider, ethersVersion, titleEscrowVersion }) => { + const mockTokenRegistryAddress = '0xTokenRegistry'; + const mockTxResponse = + titleEscrowVersion === 'v5' ? 'v5_return_to_issuer_tx_hash' : 'v4_surrender_tx_hash'; + + 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); + } else { + wallet = new WalletV6(PRIVATE_KEY, Provider as any); + vi.spyOn(Provider, 'getNetwork').mockResolvedValue({ + chainId: CHAIN_ID.local, + } as unknown as Network); + } + const isV5TT = titleEscrowVersion === 'v5'; + const titleEscrowAddress = isV5TT ? '0xv5contract' : '0xv4contract'; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(coreModule, 'getTitleEscrowAddress').mockResolvedValue(titleEscrowAddress); + vi.spyOn(coreModule, 'encrypt').mockReturnValue(mockEncryptedRemarks.slice(2)); + vi.spyOn(coreModule, 'isTitleEscrowVersion').mockImplementation( + async ({ versionInterface }) => { + return versionInterface === (isV5TT ? '0xTitleEscrowIdV5' : '0xTitleEscrowIdV4'); + }, + ); + mockV5TitleEscrowContract.callStatic.returnToIssuer.mockResolvedValue(true); + mockV4TitleEscrowContract.callStatic.surrender.mockResolvedValue(true); + }); + + it('should return to issuer with signer and remarks', async () => { + const result = await returnToIssuer( + { + titleEscrowAddress, + }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ); + + expect(result).toEqual(mockTxResponse); + expect(coreModule.encrypt).toHaveBeenCalledWith(mockRemarks, 'encryption-id'); + expect( + (isV5TT ? v5Contracts : v4Contracts).TitleEscrow__factory.connect, + ).toHaveBeenCalled(); + }); + + it('should return to issuer without remarks', async () => { + const result = await returnToIssuer( + { titleEscrowAddress }, + wallet, + {}, + { chainId: mockChainId }, + ); + + expect(result).toEqual(mockTxResponse); + expect(coreModule.encrypt).not.toHaveBeenCalled(); + }); + + it('should throw when callStatic fails', async () => { + if (isV5TT) { + mockV5TitleEscrowContract.callStatic.returnToIssuer.mockRejectedValue( + new Error('Simulated failure'), + ); + } else { + mockV4TitleEscrowContract.callStatic.surrender.mockRejectedValue( + new Error('Simulated failure'), + ); + } + + await expect( + returnToIssuer( + { tokenRegistryAddress: mockTokenRegistryAddress, tokenId: mockTokenId }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Pre-check (callStatic) for returnToIssuer failed'); + if (isV5TT) { + mockV5TitleEscrowContract.callStatic.returnToIssuer = vi.fn(); + } else { + mockV4TitleEscrowContract.callStatic.surrender = vi.fn(); + } + }); + it('should throw error when provider is missing', async () => { + const signerWithoutProvider = isV5TT + ? new WalletV5('0x'.padEnd(66, '1')) + : new WalletV6('0x'.padEnd(66, '1')); + + await expect( + returnToIssuer( + { tokenRegistryAddress: mockTokenRegistryAddress, tokenId: mockTokenId }, + signerWithoutProvider, + {}, + { chainId: mockChainId }, + ), + ).rejects.toThrow('Provider is required'); + }); + + it('should throw when version is unsupported', async () => { + vi.spyOn(coreModule, 'isTitleEscrowVersion').mockResolvedValue(false); + + await expect( + returnToIssuer( + { tokenRegistryAddress: mockTokenRegistryAddress, tokenId: mockTokenId }, + wallet, + {}, + { chainId: mockChainId }, + ), + ).rejects.toThrow('Only Token Registry V4/V5 is supported'); + }); + + it('should work with explicit version', async () => { + const result = await returnToIssuer( + { tokenRegistryAddress: mockTokenRegistryAddress, tokenId: mockTokenId }, + wallet, + { remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id', titleEscrowVersion }, + ); + + expect(result).toEqual(mockTxResponse); + expect(coreModule.isTitleEscrowVersion).not.toHaveBeenCalled(); + }); + }, + ); + + describe.each(providers)( + 'Reject Return Token with TR version $titleEscrowVersion and ethers version $ethersVersion', + async ({ Provider, ethersVersion, titleEscrowVersion }) => { + const isV5TT = titleEscrowVersion === 'v5'; + // let mockContract = isV5TT ? mockV5TradeTrustTokenContract : mockV4TradeTrustTokenContract; + const mockTxResponse = + titleEscrowVersion === 'v5' ? 'v5_restore_tx_hash' : 'v4_restore_tx_hash'; + + 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); + } else { + wallet = new WalletV6(PRIVATE_KEY, Provider as any); + vi.spyOn(Provider, 'getNetwork').mockResolvedValue({ + chainId: CHAIN_ID.local, + } as unknown as Network); + } + const mockTokenRegistryAddress = isV5TT ? MOCK_V5_ADDRESS : MOCK_V4_ADDRESS; + // const titleEscrowAddress = isV5TT ? '0xv5contract' : '0xv4contract'; + beforeEach(() => { + vi.clearAllMocks(); + // vi.spyOn(coreModule, 'encrypt').mockReturnValue(mockEncryptedRemarks.slice(2)); + vi.spyOn(coreModule, 'checkSupportsInterface').mockImplementation( + async (address, interfaceId) => { + return ( + interfaceId === + (isV5TT ? '0xTradeTrustTokenRestorableIdV5' : '0xTradeTrustTokenRestorableIdV4') + ); + }, + ); + mockV5TradeTrustTokenContract.callStatic.restore.mockResolvedValue(true); + mockV4TradeTrustTokenContract.callStatic.restore.mockResolvedValue(true); + }); + + it('should reject returned token with remarks', async () => { + const result = await rejectReturned( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { tokenId: mockTokenId, remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ); + + expect(result).toEqual(mockTxResponse); + if (isV5TT) expect(coreModule.encrypt).toHaveBeenCalledWith(mockRemarks, 'encryption-id'); + expect( + (isV5TT ? v5Contracts : v4Contracts).TradeTrustToken__factory.connect, + ).toHaveBeenCalled(); + }); + + it('should reject returned token without remarks', async () => { + const result = await rejectReturned( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { tokenId: mockTokenId }, + { chainId: mockChainId, titleEscrowVersion }, + ); + + expect(result).toEqual(mockTxResponse); + expect(coreModule.encrypt).not.toHaveBeenCalled(); + }); + + it('should throw when callStatic fails', async () => { + const mockError = new Error('callStatic error'); + if (isV5TT) { + mockV5TradeTrustTokenContract.callStatic.restore.mockRejectedValue(mockError); + } else { + mockV4TradeTrustTokenContract.callStatic.restore.mockRejectedValue(mockError); + } + await expect( + rejectReturned( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { tokenId: mockTokenId, remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Pre-check (callStatic) for rejectReturned failed'); + if (isV5TT) { + mockV5TradeTrustTokenContract.callStatic.restore = vi.fn(); + } else { + mockV4TradeTrustTokenContract.callStatic.restore = vi.fn(); + } + }); + + it('should throw when token registry address is missing', async () => { + await expect( + rejectReturned( + { 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( + rejectReturned( + { 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( + rejectReturned( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { tokenId: mockTokenId }, + { chainId: mockChainId }, + ), + ).rejects.toThrow('Only Token Registry V4/V5 is supported'); + }); + + it('should work with explicit V5/V4 version', async () => { + const result = await rejectReturned( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { tokenId: mockTokenId, remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id', titleEscrowVersion }, + ); + + expect(result).toEqual(mockTxResponse); + expect(coreModule.checkSupportsInterface).not.toHaveBeenCalled(); + }); + }, + ); + + describe.each(providers)( + 'Accept Return Token with TR version $titleEscrowVersion and ethers version $ethersVersion', + async ({ Provider, ethersVersion, titleEscrowVersion }) => { + const isV5TT = titleEscrowVersion === 'v5'; + // let mockContract = isV5TT ? mockV5TradeTrustTokenContract : mockV4TradeTrustTokenContract; + const mockTxResponse = titleEscrowVersion === 'v5' ? 'v5_burn_tx_hash' : 'v4_burn_tx_hash'; + + 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); + } else { + wallet = new WalletV6(PRIVATE_KEY, Provider as any); + vi.spyOn(Provider, 'getNetwork').mockResolvedValue({ + chainId: CHAIN_ID.local, + } as unknown as Network); + } + const mockTokenRegistryAddress = isV5TT ? MOCK_V5_ADDRESS : MOCK_V4_ADDRESS; + // const titleEscrowAddress = isV5TT ? '0xv5contract' : '0xv4contract'; + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(coreModule, 'checkSupportsInterface').mockImplementation( + async (address, interfaceId) => { + return ( + interfaceId === + (isV5TT ? '0xTradeTrustTokenBurnableIdV5' : '0xTradeTrustTokenBurnableIdV4') + ); + }, + ); + mockV5TradeTrustTokenContract.callStatic.burn.mockResolvedValue(true); + mockV4TradeTrustTokenContract.callStatic.burn.mockResolvedValue(true); + }); + + it('should accept returned token with remarks', async () => { + const result = await acceptReturned( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { tokenId: mockTokenId, remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ); + + expect(result).toEqual(mockTxResponse); + if (isV5TT) expect(coreModule.encrypt).toHaveBeenCalledWith(mockRemarks, 'encryption-id'); + expect( + (isV5TT ? v5Contracts : v4Contracts).TradeTrustToken__factory.connect, + ).toHaveBeenCalled(); + }); + + it('should accept returned token without remarks', async () => { + const result = await acceptReturned( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { tokenId: mockTokenId }, + { chainId: mockChainId, titleEscrowVersion }, + ); + + expect(result).toEqual(mockTxResponse); + expect(coreModule.encrypt).not.toHaveBeenCalled(); + }); + + it('should throw when callStatic fails', async () => { + const mockError = new Error('callStatic error'); + if (isV5TT) { + mockV5TradeTrustTokenContract.callStatic.burn.mockRejectedValue(mockError); + } else { + mockV4TradeTrustTokenContract.callStatic.burn.mockRejectedValue(mockError); + } + await expect( + acceptReturned( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { tokenId: mockTokenId, remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id' }, + ), + ).rejects.toThrow('Pre-check (callStatic) for acceptReturned failed'); + if (isV5TT) { + mockV5TradeTrustTokenContract.callStatic.burn = vi.fn(); + } else { + mockV4TradeTrustTokenContract.callStatic.burn = vi.fn(); + } + }); + + it('should throw when token registry address is missing', async () => { + await expect( + acceptReturned( + { 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( + acceptReturned( + { 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( + acceptReturned( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { tokenId: mockTokenId }, + { chainId: mockChainId }, + ), + ).rejects.toThrow('Only Token Registry V4/V5 is supported'); + }); + + it('should work with explicit V5/V4 version', async () => { + const result = await acceptReturned( + { tokenRegistryAddress: mockTokenRegistryAddress }, + wallet, + { tokenId: mockTokenId, remarks: mockRemarks }, + { chainId: mockChainId, id: 'encryption-id', titleEscrowVersion }, + ); + + expect(result).toEqual(mockTxResponse); + expect(coreModule.checkSupportsInterface).not.toHaveBeenCalled(); + }); + }, + ); +}); diff --git a/src/__tests__/token-registry-functions/transfers.test.ts b/src/__tests__/token-registry-functions/transfers.test.ts index c87447c..5f1d6f4 100644 --- a/src/__tests__/token-registry-functions/transfers.test.ts +++ b/src/__tests__/token-registry-functions/transfers.test.ts @@ -1,11 +1,7 @@ +import './fixtures.js'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ethers as ethersV5, Wallet as WalletV5 } from 'ethers'; -import { - ethers as ethersV6, - JsonRpcProvider as JsonRpcProviderV6, - Network, - Wallet as WalletV6, -} from 'ethersV6'; +import { ethers as ethersV6, Network, Wallet as WalletV6 } from 'ethersV6'; import * as coreModule from 'src/core'; import { encrypt } from 'src/core'; import { CHAIN_ID } from '@tradetrust-tt/tradetrust-utils'; @@ -15,122 +11,14 @@ import { transferOwners, nominate, } from 'src/token-registry-functions'; - -// Mocks - -vi.mock('src/core', () => ({ - encrypt: vi.fn(() => 'encrypted_remarks'), - getTitleEscrowAddress: vi.fn(() => Promise.resolve('0xv5contract')), - isTitleEscrowVersion: vi.fn(() => Promise.resolve(true)), - TitleEscrowInterface: { - V4: '0xTitleEscrowIdV4', - V5: '0xTitleEscrowIdV5', - }, -})); - -vi.mock('src/token-registry-v5', () => { - return { - v5Contracts: { - TitleEscrow__factory: { - connect: vi.fn(() => mockV5TitleEscrowContract), - }, - TradeTrustToken__factory: { - connect: vi.fn(() => mockV5TradeTrustTokenContract), - }, - TitleEscrowFactory__factory: { - connect: vi.fn(() => mockV5TitleEscrowFactoryContract), - }, - }, - v5SupportInterfaceIds: { - TitleEscrow: '0xTitleEscrowIdV5', - TradeTrustTokenMintable: '0xTradeTrustTokenMintableIdV5', - }, - }; -}); - -vi.mock('src/token-registry-v4', () => { - return { - v4Contracts: { - TitleEscrow__factory: { - connect: vi.fn(() => mockV4TitleEscrowContract), - }, - TradeTrustToken__factory: { - connect: vi.fn(() => mockV4TradeTrustTokenContract), - }, - TitleEscrowFactory__factory: { - connect: vi.fn(() => mockV4TitleEscrowFactoryContract), - }, - v4SupportInterfaceIds: { - TitleEscrow: '0xTitleEscrowIdV4', - TradeTrustTokenMintable: '0xTradeTrustTokenMintableIdV4', - }, - }, - }; -}); - -const mockV5TitleEscrowFactoryContract = { - callStatic: { - getEscrowAddress: vi.fn(), - }, - getEscrowAddress: vi.fn(() => Promise.resolve('0xV5titleescrow')), -}; - -const mockV5TradeTrustTokenContract = { - supportsInterface: vi.fn(), - titleEscrowFactory: vi.fn(() => Promise.resolve('0xV5titleescrowfactory')), -}; - -const mockV5TitleEscrowContract = { - supportsInterface: vi.fn(), - callStatic: { - transferHolder: vi.fn(), - transferBeneficiary: vi.fn(), - transferOwners: vi.fn(), - nominate: vi.fn(), - }, - transferHolder: vi.fn(() => Promise.resolve('v5_transfer_holder_tx_hash')), - transferBeneficiary: vi.fn(() => Promise.resolve('v5_transfer_beneficiary_tx_hash')), - transferOwners: vi.fn(() => Promise.resolve('v5_transfer_owners_tx_hash')), - nominate: vi.fn(() => Promise.resolve('v5_nominate_tx_hash')), - holder: vi.fn(() => Promise.resolve('0xcurrent_holder')), - beneficiary: vi.fn(() => Promise.resolve('0xcurrent_beneficiary')), -}; - -const mockV4TitleEscrowContract = { - callStatic: { - transferHolder: vi.fn(), - transferBeneficiary: vi.fn(), - transferOwners: vi.fn(), - nominate: vi.fn(), - }, - transferHolder: vi.fn(() => Promise.resolve('v4_transfer_holder_tx_hash')), - transferBeneficiary: vi.fn(() => Promise.resolve('v4_transfer_beneficiary_tx_hash')), - transferOwners: vi.fn(() => Promise.resolve('v4_transfer_owners_tx_hash')), - nominate: vi.fn(() => Promise.resolve('v4_nominate_tx_hash')), - holder: vi.fn(() => Promise.resolve('0xcurrent_holder')), - beneficiary: vi.fn(() => Promise.resolve('0xcurrent_beneficiary')), -}; -const mockV4TitleEscrowFactoryContract = { - callStatic: { - getEscrowAddress: vi.fn(), - }, - getAddress: vi.fn(() => Promise.resolve('0xV4titleescrow')), -}; - -const mockV4TradeTrustTokenContract = { - titleEscrowFactory: vi.fn(() => Promise.resolve('0xV4titleescrowfactory')), -}; - -const PRIVATE_KEY = '0x59c6995e998f97a5a004497e5f1ebce0c16828d44b3f8d0bfa3a89d271d5b6b9'; // random local key - -const providerV5 = new ethersV5.providers.JsonRpcProvider(); -const providerV6 = new JsonRpcProviderV6(); - -interface ProviderInfo { - Provider: typeof providerV5 | typeof providerV6; - ethersVersion: 'v5' | 'v6'; - titleEscrowVersion: 'v4' | 'v5'; -} +import { ProviderInfo } from 'src/token-registry-functions/types'; +import { + mockV4TitleEscrowContract, + mockV5TitleEscrowContract, + PRIVATE_KEY, + providerV5, + providerV6, +} from './fixtures'; const providers: ProviderInfo[] = [ { @@ -189,7 +77,6 @@ describe.each(providers)('Transfers', async ({ Provider, ethersVersion, titleEsc holderAddress: '0xholder', tokenId: 1, }; - const txHash = isV5TT ? 'v5_transfer_holder_tx_hash' : 'v4_transfer_holder_tx_hash'; it('throws error if titleEscrowAddress is missing ', async () => { @@ -529,7 +416,6 @@ describe.each(providers)('Transfers', async ({ Provider, ethersVersion, titleEsc remarks: '0xencrypted_remarks', } : { newBeneficiaryAddress: '0xbeneficiary', newHolderAddress: '0xholder' }; - const txHash = isV5TT ? 'v5_transfer_owners_tx_hash' : 'v4_transfer_owners_tx_hash'; it('throws error if titleEscrowAddress is missing ', async () => { diff --git a/src/core/endorsement-chain/useEndorsementChain.ts b/src/core/endorsement-chain/useEndorsementChain.ts index a6b949c..14104c7 100644 --- a/src/core/endorsement-chain/useEndorsementChain.ts +++ b/src/core/endorsement-chain/useEndorsementChain.ts @@ -145,7 +145,7 @@ export const getDocumentOwner = async ( }; // Check Title Escrow Interface Support -const checkSupportsInterface = async ( +export const checkSupportsInterface = async ( titleEscrowAddress: string, interfaceId: string, provider: Provider | ethersV6.Provider, diff --git a/src/token-registry-functions/index.ts b/src/token-registry-functions/index.ts index 6de95ff..fa484d0 100644 --- a/src/token-registry-functions/index.ts +++ b/src/token-registry-functions/index.ts @@ -1 +1,4 @@ export * from './transfer'; +export * from './rejectTransfers'; +export * from './returnToken'; +export * from './mint'; diff --git a/src/token-registry-functions/mint.ts b/src/token-registry-functions/mint.ts new file mode 100644 index 0000000..0594464 --- /dev/null +++ b/src/token-registry-functions/mint.ts @@ -0,0 +1,113 @@ +import { checkSupportsInterface, encrypt } from 'src/core'; +import { v5Contracts, v5SupportInterfaceIds } from 'src/token-registry-v5'; +import { v4Contracts, v4SupportInterfaceIds } from 'src/token-registry-v4'; +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; +import { getTxOptions } from './utils'; +import { MintTokenOptions, MintTokenParams, TransactionOptions } from './types'; + +/** + * Mints a new token into the TradeTrustToken registry with the specified beneficiary and holder. + * Supports both Token Registry V4 and V5 contracts. + * @param {MintTokenOptions} contractOptions - Contains the `tokenRegistryAddress` for the minting contract. + * @param {Signer | SignerV6} signer - Signer instance (Ethers v5 or v6) that authorizes the mint transaction. + * @param {MintTokenParams} params - Parameters for minting, including `beneficiaryAddress`, `holderAddress`, `tokenId`, and optional `remarks`. + * @param {TransactionOptions} options - Transaction metadata including gas values, version detection, chain ID, and optional encryption ID. + * @returns {Promise} A promise resolving to the transaction result from the mint call. + * @throws {Error} If the token registry address or signer provider is not provided. + * @throws {Error} If neither V4 nor V5 interfaces are supported. + * @throws {Error} If the `callStatic.mint` fails as a pre-check. + */ +const mint = async ( + contractOptions: MintTokenOptions, + signer: Signer | SignerV6, + params: MintTokenParams, + options: TransactionOptions, +): Promise => { + const { tokenRegistryAddress } = contractOptions; + const { chainId, maxFeePerGas, maxPriorityFeePerGas, titleEscrowVersion } = options; + + if (!tokenRegistryAddress) throw new Error('Token registry address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const { beneficiaryAddress, holderAddress, tokenId, remarks } = params; + + // 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.TradeTrustTokenMintable, + signer.provider, + ), + checkSupportsInterface( + tokenRegistryAddress, + v5SupportInterfaceIds.TradeTrustTokenMintable, + 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, + ); + } + + const encryptedRemarks = remarks && isV5TT ? `0x${encrypt(remarks, options.id!)}` : '0x'; + // Check callStatic (dry run) + try { + if (isV5TT) { + await (tradeTrustTokenContract as v5Contracts.TradeTrustToken).callStatic.mint( + beneficiaryAddress, + holderAddress, + tokenId, + encryptedRemarks, + ); + } else if (isV4TT) { + await (tradeTrustTokenContract as v4Contracts.TradeTrustToken).callStatic.mint( + beneficiaryAddress, + holderAddress, + tokenId, + ); + } + } catch (e) { + console.error('callStatic failed:', e); + throw new Error('Pre-check (callStatic) for mint failed'); + } + + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + + // Send the actual transaction + + if (isV5TT) { + return await (tradeTrustTokenContract as v5Contracts.TradeTrustToken).mint( + beneficiaryAddress, + holderAddress, + tokenId, + encryptedRemarks, + txOptions, + ); + } else if (isV4TT) { + return await (tradeTrustTokenContract as v4Contracts.TradeTrustToken).mint( + beneficiaryAddress, + holderAddress, + tokenId, + txOptions, + ); + } +}; +export { mint }; diff --git a/src/token-registry-functions/rejectTransfers.ts b/src/token-registry-functions/rejectTransfers.ts new file mode 100644 index 0000000..207480d --- /dev/null +++ b/src/token-registry-functions/rejectTransfers.ts @@ -0,0 +1,217 @@ +import { + encrypt, + getTitleEscrowAddress, + isTitleEscrowVersion, + TitleEscrowInterface, +} from 'src/core'; +import { v5Contracts } from 'src/token-registry-v5'; +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; +import { getTxOptions } from './utils'; +import { ContractOptions, RejectTransferParams, TransactionOptions } from './types'; + +/** + * Rejects the transfer of holder for a title escrow contract. + * @param {ContractOptions} contractOptions - Contract-related options including the token registry address, and optionally, token ID and the title escrow address. + * @param {Signer | SignerV6} signer - Ethers signer (V5 or V6) used to sign and send the transaction. + * @param {RejectTransferParams} params - Contains the `remarks` field which is an optional string that will be encrypted and sent with the transaction. + * @param {TransactionOptions} options - Transfer options including optional `chainId`, `titleEscrowVersion`, `maxFeePerGas`, `maxPriorityFeePerGas`, and an `id` used for encryption. + * @throws error if the title escrow address or signer provider is missing. + * @throws if the version is not V5 compatible. + * @throws if the dry-run (`callStatic`) fails. + * @returns {Promise} The transaction response of the rejectTransferHolder call. + */ +const rejectTransferHolder = async ( + contractOptions: ContractOptions, + signer: Signer | SignerV6, + params: RejectTransferParams, + options: TransactionOptions, +): Promise => { + const { tokenRegistryAddress, tokenId } = contractOptions; + let { titleEscrowAddress } = contractOptions; + const { chainId, maxFeePerGas, maxPriorityFeePerGas, titleEscrowVersion } = options; + + if (!titleEscrowAddress) { + titleEscrowAddress = await getTitleEscrowAddress( + tokenRegistryAddress, + tokenId as string, + signer.provider, + {}, + ); + } + + if (!titleEscrowAddress) throw new Error('Token registry address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const { remarks } = params; + + // Connect V5 contract by default + const titleEscrowContract = v5Contracts.TitleEscrow__factory.connect(titleEscrowAddress, signer); + + const encryptedRemarks = remarks ? `0x${encrypt(remarks, options.id!)}` : '0x'; + + // Detect version if not explicitly provided + let isV5TT = titleEscrowVersion === 'v5'; + if (titleEscrowVersion === undefined) { + isV5TT = await isTitleEscrowVersion({ + titleEscrowAddress, + versionInterface: TitleEscrowInterface.V5, + provider: signer.provider, + }); + } + + if (!isV5TT) { + throw new Error('Only Token Registry V5 is supported'); + } + + // Check callStatic (dry run) + try { + await titleEscrowContract.callStatic.rejectTransferHolder(encryptedRemarks); + } catch (e) { + console.error('callStatic failed:', e); + throw new Error('Pre-check (callStatic) for rejectTransferHolder failed'); + } + + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + + // Send the actual transaction + + return await titleEscrowContract.rejectTransferHolder(encryptedRemarks, txOptions); +}; + +/** + * Rejects the transfer of beneficiary for a title escrow contract. + * @param {ContractOptions} contractOptions - Contract-related options including the token registry address, and optionally, token ID and the title escrow address. + * @param {Signer | SignerV6} signer - Ethers signer (V5 or V6) used to sign and send the transaction. + * @param {RejectTransferParams} params - Contains the `remarks` field which is an optional string that will be encrypted and sent with the transaction. + * @param {TransactionOptions} options - Transfer options including optional `chainId`, `titleEscrowVersion`, `maxFeePerGas`, `maxPriorityFeePerGas`, and an `id` used for encryption. + * @throws error if the title escrow address or signer provider is missing. + * @throws error if the version is not V5 compatible. + * @throws error if the dry-run (`callStatic`) fails. + * @returns {Promise} The transaction response of the rejectTransferBeneficiary call. + */ +const rejectTransferBeneficiary = async ( + contractOptions: ContractOptions, + signer: Signer | SignerV6, + params: RejectTransferParams, + options: TransactionOptions, +): Promise => { + const { tokenRegistryAddress, tokenId } = contractOptions; + let { titleEscrowAddress } = contractOptions; + const { chainId, maxFeePerGas, maxPriorityFeePerGas, titleEscrowVersion } = options; + + if (!titleEscrowAddress) { + titleEscrowAddress = await getTitleEscrowAddress( + tokenRegistryAddress, + tokenId as string, + signer.provider, + {}, + ); + } + + if (!titleEscrowAddress) throw new Error('Token registry address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const { remarks } = params; + + // Connect V5 contract by default + const titleEscrowContract = v5Contracts.TitleEscrow__factory.connect(titleEscrowAddress, signer); + + const encryptedRemarks = remarks ? `0x${encrypt(remarks, options.id!)}` : '0x'; + + // Detect version if not explicitly provided + let isV5TT = titleEscrowVersion === 'v5'; + if (titleEscrowVersion === undefined) { + isV5TT = await isTitleEscrowVersion({ + titleEscrowAddress, + versionInterface: TitleEscrowInterface.V5, + provider: signer.provider, + }); + } + + if (!isV5TT) { + throw new Error('Only Token Registry V5 is supported'); + } + + // Check callStatic (dry run) + try { + await titleEscrowContract.callStatic.rejectTransferBeneficiary(encryptedRemarks); + } catch (e) { + console.error('callStatic failed:', e); + throw new Error('Pre-check (callStatic) for rejectTransferBeneficiary failed'); + } + + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + + // Send the actual transaction + + return await titleEscrowContract.rejectTransferBeneficiary(encryptedRemarks, txOptions); +}; + +/** + * Rejects the transfer of ownership for a title escrow contract. + * @param {ContractOptions} contractOptions - Contract-related options including the token registry address, and optionally, token ID and the title escrow address. + * @param {Signer | SignerV6} signer - Ethers signer (V5 or V6) used to sign and send the transaction. + * @param {RejectTransferParams} params - Contains the `remarks` field which is an optional string that will be encrypted and sent with the transaction. + * @param {TransactionOptions} options - Transfer options including optional `chainId`, `titleEscrowVersion`, `maxFeePerGas`, `maxPriorityFeePerGas`, and an `id` used for encryption. + * @throws error if the title escrow address or signer provider is missing. + * @throws an error if the version is not V5 compatible. + * @throws an error if the dry-run (`callStatic`) fails. + * @returns {Promise} The transaction response of the rejectTransferOwners call. + */ +const rejectTransferOwners = async ( + contractOptions: ContractOptions, + signer: Signer | SignerV6, + params: RejectTransferParams, + options: TransactionOptions, +): Promise => { + const { tokenRegistryAddress, tokenId } = contractOptions; + let { titleEscrowAddress } = contractOptions; + const { chainId, maxFeePerGas, maxPriorityFeePerGas, titleEscrowVersion } = options; + + if (!titleEscrowAddress) { + titleEscrowAddress = await getTitleEscrowAddress( + tokenRegistryAddress, + tokenId as string, + signer.provider, + {}, + ); + } + + if (!titleEscrowAddress) throw new Error('Token registry address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const { remarks } = params; + + // Connect V5 contract by default + const titleEscrowContract = v5Contracts.TitleEscrow__factory.connect(titleEscrowAddress, signer); + + const encryptedRemarks = remarks ? `0x${encrypt(remarks, options.id!)}` : '0x'; + + // Detect version if not explicitly provided + let isV5TT = titleEscrowVersion === 'v5'; + if (titleEscrowVersion === undefined) { + isV5TT = await isTitleEscrowVersion({ + titleEscrowAddress, + versionInterface: TitleEscrowInterface.V5, + provider: signer.provider, + }); + } + + if (!isV5TT) { + throw new Error('Only Token Registry V5 is supported'); + } + + // Check callStatic (dry run) + try { + await titleEscrowContract.callStatic.rejectTransferOwners(encryptedRemarks); + } catch (e) { + console.error('callStatic failed:', e); + throw new Error('Pre-check (callStatic) for rejectTransferOwners failed'); + } + + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + + // Send the actual transaction + + return await titleEscrowContract.rejectTransferOwners(encryptedRemarks, txOptions); +}; + +export { rejectTransferHolder, rejectTransferBeneficiary, rejectTransferOwners }; diff --git a/src/token-registry-functions/returnToken.ts b/src/token-registry-functions/returnToken.ts new file mode 100644 index 0000000..a7b8d0e --- /dev/null +++ b/src/token-registry-functions/returnToken.ts @@ -0,0 +1,306 @@ +import { + checkSupportsInterface, + encrypt, + getTitleEscrowAddress, + isTitleEscrowVersion, + TitleEscrowInterface, +} from 'src/core'; +import { v5Contracts, v5SupportInterfaceIds } from 'src/token-registry-v5'; +import { v4Contracts, v4SupportInterfaceIds } from 'src/token-registry-v4'; +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; +import { getTxOptions } from './utils'; +import { + AcceptReturnedOptions, + AcceptReturnedParams, + ContractOptions, + RejectReturnedOptions, + RejectReturnedParams, + ReturnToIssuerParams, + TransactionOptions, +} from './types'; + +/** + * Returns the token to the original issuer from the Title Escrow contract. + * @param {ContractOptions} contractOptions - Options including token ID, registry address, and optionally title escrow address. + * @param {Signer | SignerV6} signer - Signer instance (Ethers v5 or v6) that will execute the transaction. + * @param {ReturnToIssuerParams} params - Contains optional remarks to be encrypted and attached to the transaction. + * @param {TransactionOptions} options - Transaction settings including gas fees, escrow version, chain ID, and optional encryption ID. + * @returns {Promise} Promise that resolves to the transaction response from the `returnToIssuer` function. + * @throws {Error} If title escrow address or provider is not provided or if version is unsupported. + * @throws {Error} If the `callStatic.returnToIssuer` fails as a pre-check. + */ +const returnToIssuer = async ( + contractOptions: ContractOptions, + signer: Signer | SignerV6, + params: ReturnToIssuerParams, + options: TransactionOptions, +): Promise => { + const { tokenRegistryAddress, tokenId } = contractOptions; + let { titleEscrowAddress } = contractOptions; + const { chainId, maxFeePerGas, maxPriorityFeePerGas, titleEscrowVersion } = options; + + if (!titleEscrowAddress) { + titleEscrowAddress = await getTitleEscrowAddress( + tokenRegistryAddress, + tokenId as string, + signer.provider, + {}, + ); + } + + if (!titleEscrowAddress) throw new Error('Title Escrow address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const { remarks } = params; + + // Connect V5 contract by default + let titleEscrowContract: v5Contracts.TitleEscrow | v4Contracts.TitleEscrow = + v5Contracts.TitleEscrow__factory.connect(titleEscrowAddress, signer); + + const encryptedRemarks = remarks && options.id ? `0x${encrypt(remarks, options.id)}` : '0x'; + + // Detect version if not explicitly provided + let isV5TT = titleEscrowVersion === 'v5'; + let isV4TT = titleEscrowVersion === 'v4'; + + if (titleEscrowVersion === undefined) { + [isV4TT, isV5TT] = await Promise.all([ + await isTitleEscrowVersion({ + titleEscrowAddress, + versionInterface: TitleEscrowInterface.V4, + provider: signer.provider, + }), + await isTitleEscrowVersion({ + titleEscrowAddress, + versionInterface: TitleEscrowInterface.V5, + provider: signer.provider, + }), + ]); + } + + if (!isV5TT && !isV4TT) { + throw new Error('Only Token Registry V4/V5 is supported'); + } + + if (isV4TT) { + titleEscrowContract = v4Contracts.TitleEscrow__factory.connect( + titleEscrowAddress, + signer as Signer, + ); + } + + // Check callStatic (dry run) + try { + if (isV5TT) { + await (titleEscrowContract as v5Contracts.TitleEscrow).callStatic.returnToIssuer( + encryptedRemarks, + ); + } else if (isV4TT) { + await (titleEscrowContract as v4Contracts.TitleEscrow).callStatic.surrender(); + } + } catch (e) { + console.error('callStatic failed:', e); + throw new Error('Pre-check (callStatic) for returnToIssuer failed'); + } + + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + + // Send the actual transaction + if (isV5TT) { + return await (titleEscrowContract as v5Contracts.TitleEscrow).returnToIssuer( + encryptedRemarks, + txOptions, + ); + } else if (isV4TT) { + return await (titleEscrowContract as v4Contracts.TitleEscrow).surrender(txOptions); + } +}; + +/** + * Rejects a previously returned token by restoring it back to the token registry. + * This is only supported on Token Registry V5 contracts with the `restore` functionality. + * @param {AcceptReturnedOptions} contractOptions - Contains the `tokenRegistryAddress` used to locate the TradeTrustToken contract. + * @param {Signer | SignerV6} signer - Signer instance (v5 or v6) used to authorize the transaction. + * @param {AcceptReturnedParams} params - Includes the `tokenId` to restore and optional `remarks` to encrypt. + * @param {TransactionOptions} options - Configuration for the transaction including version, gas fees, and optional `id` used for encryption. + * @returns {Promise} A promise that resolves to the transaction result of the `restore` call. + * @throws {Error} If the token registry address or provider is missing. + * @throws {Error} If the token registry version is unsupported. + * @throws {Error} If the callStatic pre-check fails. + */ +const rejectReturned = async ( + contractOptions: AcceptReturnedOptions, + signer: Signer | SignerV6, + params: RejectReturnedParams, + options: TransactionOptions, +): Promise => { + const { tokenRegistryAddress } = contractOptions; + const { chainId, maxFeePerGas, maxPriorityFeePerGas, titleEscrowVersion } = options; + + if (!tokenRegistryAddress) throw new Error('Token registry address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const { tokenId, remarks } = params; + + // 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.TradeTrustTokenRestorable, + signer.provider, + ), + checkSupportsInterface( + tokenRegistryAddress, + v5SupportInterfaceIds.TradeTrustTokenRestorable, + 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, + ); + } + + const encryptedRemarks = remarks && isV5TT ? `0x${encrypt(remarks, options.id!)}` : '0x'; + // Check callStatic (dry run) + try { + if (isV5TT) { + await (tradeTrustTokenContract as v5Contracts.TradeTrustToken).callStatic.restore( + tokenId, + encryptedRemarks, + ); + } else if (isV4TT) { + await (tradeTrustTokenContract as v4Contracts.TradeTrustToken).callStatic.restore(tokenId); + } + } catch (e) { + console.error('callStatic failed:', e); + throw new Error('Pre-check (callStatic) for rejectReturned failed'); + } + + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + + // Send the actual transaction + + if (isV5TT) { + return await (tradeTrustTokenContract as v5Contracts.TradeTrustToken).restore( + tokenId, + encryptedRemarks, + txOptions, + ); + } else if (isV4TT) { + return await (tradeTrustTokenContract as v4Contracts.TradeTrustToken).restore( + tokenId, + txOptions, + ); + } +}; +/** + * Accepts the returned token by burning it from the TradeTrustToken contract. + * Only supported on Token Registry V5 contracts that implement the burnable interface. + * @param {RejectReturnedOptions} contractOptions - Contains the `tokenRegistryAddress` from which the token will be burned. + * @param {Signer | SignerV6} signer - Signer instance (v5 or v6) used to authorize and send the burn transaction. + * @param {AcceptReturnedParams} params - Includes the `tokenId` to burn and optional `remarks` for audit trail. + * @param {TransactionOptions} options - Transaction settings including chain ID, gas fee values, escrow version, and encryption ID for remarks. + * @returns {Promise} A promise resolving to the transaction result of the burn call. + * @throws {Error} If token registry address or signer provider is not provided. + * @throws {Error} If the contract does not support Token Registry V5. + * @throws {Error} If `callStatic.burn` fails as a pre-check. + */ +const acceptReturned = async ( + contractOptions: RejectReturnedOptions, + signer: Signer | SignerV6, + params: AcceptReturnedParams, + options: TransactionOptions, +): Promise => { + const { tokenRegistryAddress } = contractOptions; + const { chainId, maxFeePerGas, maxPriorityFeePerGas, titleEscrowVersion } = options; + + if (!tokenRegistryAddress) throw new Error('Token registry address is required'); + if (!signer.provider) throw new Error('Provider is required'); + const { tokenId, remarks } = params; + + // 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.TradeTrustTokenBurnable, + signer.provider, + ), + checkSupportsInterface( + tokenRegistryAddress, + v5SupportInterfaceIds.TradeTrustTokenBurnable, + 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, + ); + } + + const encryptedRemarks = remarks && isV5TT ? `0x${encrypt(remarks, options.id!)}` : '0x'; + + // Check callStatic (dry run) + try { + if (isV5TT) { + await (tradeTrustTokenContract as v5Contracts.TradeTrustToken).callStatic.burn( + tokenId, + encryptedRemarks, + ); + } else if (isV4TT) { + await (tradeTrustTokenContract as v4Contracts.TradeTrustToken).callStatic.burn(tokenId); + } + } catch (e) { + console.error('callStatic failed:', e); + throw new Error('Pre-check (callStatic) for acceptReturned failed'); + } + + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + + // Send the actual transaction + + if (isV5TT) { + return await (tradeTrustTokenContract as v5Contracts.TradeTrustToken).burn( + tokenId, + encryptedRemarks, + txOptions, + ); + } else if (isV4TT) { + return await (tradeTrustTokenContract as v4Contracts.TradeTrustToken).burn(tokenId, txOptions); + } +}; + +export { returnToIssuer, acceptReturned, rejectReturned }; diff --git a/src/token-registry-functions/transfer.ts b/src/token-registry-functions/transfer.ts index 5247762..a4fa004 100644 --- a/src/token-registry-functions/transfer.ts +++ b/src/token-registry-functions/transfer.ts @@ -1,4 +1,3 @@ -import { CHAIN_ID, SUPPORTED_CHAINS } from '@tradetrust-tt/tradetrust-utils'; import { encrypt, getTitleEscrowAddress, @@ -7,74 +6,44 @@ import { } from 'src/core'; import { v4Contracts } from 'src/token-registry-v4'; import { v5Contracts } from 'src/token-registry-v5'; -import { BigNumberish, Signer as SignerV6 } from 'ethersV6'; -import { BigNumber, Signer } from 'ethers'; -import { isV6EthersProvider } from 'src/utils/ethers'; - -interface TransferHolderParams { - holderAddress: string; - remarks?: string; -} -interface TransferBeneficiaryParams { - newBeneficiaryAddress: string; - remarks?: string; -} -interface NominateParams { - newBeneficiaryAddress: string; - remarks?: string; -} -interface TransferOwnersParams { - newHolderAddress: string; - newBeneficiaryAddress: string; - remarks?: string; -} - -interface TransferOptions { - chainId?: CHAIN_ID; - titleEscrowVersion?: 'v4' | 'v5'; - maxFeePerGas?: BigNumberish | string | number | BigNumber; - maxPriorityFeePerGas?: BigNumberish | string | number | BigNumber; - id?: string; -} - -// 🔍 Handles both Ethers v5 and v6 signer types -const getChainIdSafe = async (signer: SignerV6 | Signer): Promise => { - if (isV6EthersProvider(signer.provider)) { - const network = await (signer as Signer).provider?.getNetwork(); - if (!network?.chainId) throw new Error('Cannot determine chainId: provider is missing'); - return network.chainId; - } - return await (signer as Signer).getChainId(); -}; -// const getSignerAddressSafe = async (signer: SignerV6 | Signer): Promise => { -// if (isV6EthersProvider(signer.provider)) { -// return await (signer as SignerV6).getAddress(); -// } -// return (signer as any).address; -// }; - -type ContractOptions = - | { - titleEscrowAddress: string; // Present — no restrictions on the rest - tokenId?: string | number; - tokenRegistryAddress?: string; - } - | { - titleEscrowAddress?: undefined; // Absent — must provide both below - tokenId: string | number; - tokenRegistryAddress: string; - }; - +import { Signer as SignerV6 } from 'ethersV6'; +import { ContractTransaction, Signer } from 'ethers'; +import { + ContractOptions, + NominateParams, + TransactionOptions, + TransferBeneficiaryParams, + TransferHolderParams, + TransferOwnersParams, +} from './types'; +import { getTxOptions } from './utils'; + +/** + * Transfers holder role of a Title Escrow contract to a new address. + * The caller of this function must be the current holder. + * @param {ContractOptions} contractOptions - Contains `tokenRegistryAddress` and optionally `tokenId` and `titleEscrowAddress`. + * @param {Signer | SignerV6} signer - The signer (ethers v5 or v6) who initiates the transaction. + * @param {TransferHolderParams} params - Object containing `holderAddress` (address to transfer to) and optional `remarks`. + * @param {TransactionOptions} options - Transaction options including: + * - `titleEscrowVersion` (optional): Either "v4" or "v5" + * - `chainId` (optional): Used for gas station lookup + * - `maxFeePerGas` (optional), `maxPriorityFeePerGas` (optional): EIP-1559 gas fee configuration + * - `id` (optional): ID used for encrypting remarks + * @throws If required fields like `titleEscrowAddress` or `signer.provider` are missing. + * @throws If the version is unsupported (neither v4 nor v5). + * @throws If the dry-run via `callStatic` fails. + * @returns {Promise} The transaction response for `transferHolder`. + */ const transferHolder = async ( contractOptions: ContractOptions, signer: Signer | SignerV6, params: TransferHolderParams, - options: TransferOptions, -) => { + options: TransactionOptions, +): Promise => { const { tokenRegistryAddress, tokenId } = contractOptions; const { titleEscrowVersion } = options; let { titleEscrowAddress } = contractOptions; - let { chainId, maxFeePerGas, maxPriorityFeePerGas } = options; + const { chainId, maxFeePerGas, maxPriorityFeePerGas } = options; let isV5TT = titleEscrowVersion === 'v5'; let isV4TT = titleEscrowVersion === 'v4'; @@ -153,18 +122,8 @@ const transferHolder = async ( } // If gas values are missing, query gas station if available - if (!maxFeePerGas || !maxPriorityFeePerGas) { - chainId = chainId ?? ((await getChainIdSafe(signer)) as unknown as CHAIN_ID); - const gasStation = SUPPORTED_CHAINS[chainId]?.gasStation; - - if (gasStation) { - const gasFees = await gasStation(); - maxFeePerGas = gasFees?.maxFeePerGas ?? 0; - maxPriorityFeePerGas = gasFees?.maxPriorityFeePerGas ?? 0; - } - } - const txOptions = - maxFeePerGas && maxPriorityFeePerGas ? { maxFeePerGas, maxPriorityFeePerGas } : undefined; + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + // Send the actual transaction if (isV5TT) { return await (titleEscrowContract as v5Contracts.TitleEscrow).transferHolder( @@ -173,20 +132,41 @@ const transferHolder = async ( txOptions, ); } else if (isV4TT) { - return await titleEscrowContract.transferHolder(holderAddress, txOptions); + return await (titleEscrowContract as v4Contracts.TitleEscrow).transferHolder( + holderAddress, + txOptions, + ); } }; +/** + * Transfers the beneficiary role of a Title Escrow contract to a new beneficiary address. + * The caller of this function must be the current holder. + * @param {ContractOptions} contractOptions - Contains `tokenRegistryAddress` and optionally `tokenId` and `titleEscrowAddress`. + * @param {Signer | SignerV6} signer - The signer (ethers v5 or v6) who initiates and signs the transaction. + * @param {TransferBeneficiaryParams} params - Object containing: + * - `newBeneficiaryAddress`: Address to which the beneficiary role is being transferred. + * - `remarks` (optional): Optional encrypted message attached with the transaction. + * @param {TransactionOptions} options - Transaction configuration options: + * - `titleEscrowVersion` (optional): Token registry version, either `'v4'` or `'v5'`. + * - `chainId` (optional): Used to query gas station info if gas fee values are missing. + * - `maxFeePerGas`(optional), `maxPriorityFeePerGas`(optional): EIP-1559 gas fee parameters. + * - `id`(optional): Used for encryption of remarks. + * @throws If required values like `titleEscrowAddress` or `signer.provider` are missing. + * @throws If the version is unsupported (neither v4 nor v5). + * @throws If the dry-run `callStatic` fails for pre-checking the transaction. + * @returns {Promise} The transaction response for the `transferBeneficiary` call. + */ const transferBeneficiary = async ( contractOptions: ContractOptions, signer: Signer | SignerV6, params: TransferBeneficiaryParams, - options: TransferOptions, -) => { + options: TransactionOptions, +): Promise => { const { tokenId, tokenRegistryAddress } = contractOptions; const { titleEscrowVersion } = options; let { titleEscrowAddress } = contractOptions; - let { chainId, maxFeePerGas, maxPriorityFeePerGas } = options; + const { chainId, maxFeePerGas, maxPriorityFeePerGas } = options; let isV5TT = titleEscrowVersion === 'v5'; let isV4TT = titleEscrowVersion === 'v4'; @@ -265,19 +245,8 @@ const transferBeneficiary = async ( } // If gas values are missing, query gas station if available - if (!maxFeePerGas || !maxPriorityFeePerGas) { - chainId = chainId ?? ((await getChainIdSafe(signer)) as unknown as CHAIN_ID); - const gasStation = SUPPORTED_CHAINS[chainId]?.gasStation; - - if (gasStation) { - const gasFees = await gasStation(); - maxFeePerGas = gasFees?.maxFeePerGas ?? 0; - maxPriorityFeePerGas = gasFees?.maxPriorityFeePerGas ?? 0; - } - } + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); - const txOptions = - maxFeePerGas && maxPriorityFeePerGas ? { maxFeePerGas, maxPriorityFeePerGas } : undefined; // Send the actual transaction if (isV5TT) { const tx = await (titleEscrowContract as v5Contracts.TitleEscrow).transferBeneficiary( @@ -294,16 +263,36 @@ const transferBeneficiary = async ( return tx; } }; + +/** + * Transfers both the holder and beneficiary roles of a Title Escrow contract to new addresses. + * The caller of this function must be the current holder and beneficiary both. + * @param {ContractOptions} contractOptions - Contains `tokenRegistryAddress` and optionally `tokenId` and `titleEscrowAddress`. + * @param {Signer | SignerV6} signer - The signer (ethers v5 or v6) who initiates and signs the transaction. + * @param {TransferOwnersParams} params - Object containing: + * - `newBeneficiaryAddress`: The new beneficiary address. + * - `newHolderAddress`: The new holder address. + * - `remarks` (optional): Optional remarks that will be encrypted and included with the transaction. + * @param {TransactionOptions} options - Transaction configuration options: + * - `titleEscrowVersion` (optional): Token registry version, either `'v4'` or `'v5'`. + * - `chainId` (optional): Used for gas station lookup if gas fee values are not provided. + * - `maxFeePerGas`(optional), `maxPriorityFeePerGas`(optional): EIP-1559 gas fee parameters. + * - `id`(optional): Used for encrypting remarks. + * @throws If required fields like `titleEscrowAddress` or `signer.provider` are missing. + * @throws If the title escrow version is unsupported. + * @throws If the pre-check `callStatic.transferOwners` fails. + * @returns {Promise} The transaction response from the `transferOwners` call. + */ const transferOwners = async ( contractOptions: ContractOptions, signer: Signer | SignerV6, params: TransferOwnersParams, - options: TransferOptions, -) => { + options: TransactionOptions, +): Promise => { const { tokenId, tokenRegistryAddress } = contractOptions; const { titleEscrowVersion } = options; let { titleEscrowAddress } = contractOptions; - let { chainId, maxFeePerGas, maxPriorityFeePerGas } = options; + const { chainId, maxFeePerGas, maxPriorityFeePerGas } = options; let isV5TT = titleEscrowVersion === 'v5'; let isV4TT = titleEscrowVersion === 'v4'; @@ -389,19 +378,8 @@ const transferOwners = async ( } // If gas values are missing, query gas station if available - if (!maxFeePerGas || !maxPriorityFeePerGas) { - chainId = chainId ?? ((await getChainIdSafe(signer)) as unknown as CHAIN_ID); - const gasStation = SUPPORTED_CHAINS[chainId]?.gasStation; - - if (gasStation) { - const gasFees = await gasStation(); - maxFeePerGas = gasFees?.maxFeePerGas ?? 0; - maxPriorityFeePerGas = gasFees?.maxPriorityFeePerGas ?? 0; - } - } + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); - const txOptions = - maxFeePerGas && maxPriorityFeePerGas ? { maxFeePerGas, maxPriorityFeePerGas } : undefined; // Send the actual transaction if (isV5TT) { @@ -420,16 +398,34 @@ const transferOwners = async ( } }; +/** + * Nominates a new beneficiary on the Title Escrow contract. + * The caller of this function must be the current beneficiary. + * @param {ContractOptions} contractOptions - Contains `tokenRegistryAddress` and optionally `tokenId` and `titleEscrowAddress`. + * @param {Signer | SignerV6} signer - The signer (ethers v5 or v6) who will sign and send the transaction. + * @param {NominateParams} params - Nomination parameters: + * - `newBeneficiaryAddress`: The Ethereum address to nominate as the new beneficiary. + * - `remarks` (optional): Remarks to include with the transaction (will be encrypted). + * @param {TransactionOptions} options - Transaction-level configuration: + * - `titleEscrowVersion` (optional): Specifies token registry version, either `'v4'` or `'v5'`. + * - `chainId` (optional): Chain ID used for querying gas stations if fees are not set. + * - `maxFeePerGas`(optional), `maxPriorityFeePerGas`(optional): EIP-1559-compatible gas fee settings. + * - `id`(optional): Used for encrypting the remarks string. + * @throws If required inputs like `titleEscrowAddress` or `signer.provider` are missing. + * @throws If token registry version is unsupported. + * @throws If the dry-run `callStatic.nominate()` fails. + * @returns {Promise} The transaction response from the `nominate` method. + */ const nominate = async ( contractOptions: ContractOptions, signer: Signer | SignerV6, params: NominateParams, - options: TransferOptions, -) => { + options: TransactionOptions, +): Promise => { const { tokenId, tokenRegistryAddress } = contractOptions; const { titleEscrowVersion } = options; let { titleEscrowAddress } = contractOptions; - let { chainId, maxFeePerGas, maxPriorityFeePerGas } = options; + const { chainId, maxFeePerGas, maxPriorityFeePerGas } = options; let isV5TT = titleEscrowVersion === 'v5'; let isV4TT = titleEscrowVersion === 'v4'; @@ -504,19 +500,8 @@ const nominate = async ( } // If gas values are missing, query gas station if available - if (!maxFeePerGas || !maxPriorityFeePerGas) { - chainId = chainId ?? ((await getChainIdSafe(signer)) as unknown as CHAIN_ID); - const gasStation = SUPPORTED_CHAINS[chainId]?.gasStation; - - if (gasStation) { - const gasFees = await gasStation(); - maxFeePerGas = gasFees?.maxFeePerGas ?? 0; - maxPriorityFeePerGas = gasFees?.maxPriorityFeePerGas ?? 0; - } - } + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); - const txOptions = - maxFeePerGas && maxPriorityFeePerGas ? { maxFeePerGas, maxPriorityFeePerGas } : undefined; // Send the actual transaction if (isV5TT) { diff --git a/src/token-registry-functions/types.ts b/src/token-registry-functions/types.ts new file mode 100644 index 0000000..82397f4 --- /dev/null +++ b/src/token-registry-functions/types.ts @@ -0,0 +1,83 @@ +import { CHAIN_ID } from '@tradetrust-tt/tradetrust-utils'; +import { BigNumber, providers as providersV5 } from 'ethers'; +import { BigNumberish, Provider as ProviderV6 } from 'ethersV6'; + +export type GasValue = BigNumber | BigNumberish | string | number; + +export interface RejectTransferParams { + remarks?: string; +} +export interface ReturnToIssuerParams { + remarks?: string; +} + +export interface AcceptReturnedParams { + tokenId: string | number; + remarks?: string; +} +export interface RejectReturnedParams { + tokenId: string | number; + remarks?: string; +} + +export interface MintTokenParams { + beneficiaryAddress: string; + holderAddress: string; + tokenId: string | number; + remarks?: string; +} + +export interface TransactionOptions { + chainId?: CHAIN_ID; + titleEscrowVersion?: 'v4' | 'v5'; + maxFeePerGas?: BigNumberish | string | number | BigNumber; + maxPriorityFeePerGas?: BigNumberish | string | number | BigNumber; + id?: string; +} + +export type ContractOptions = + | { + titleEscrowAddress: string; // Present — no restrictions on the rest + tokenId?: string | number; + tokenRegistryAddress?: string; + } + | { + titleEscrowAddress?: undefined; // Absent — must provide both below + tokenId: string | number; + tokenRegistryAddress: string; + }; + +export type AcceptReturnedOptions = { + tokenRegistryAddress: string; +}; +export type RejectReturnedOptions = { + tokenRegistryAddress: string; +}; + +export type MintTokenOptions = { + tokenRegistryAddress: string; +}; + +export interface TransferHolderParams { + holderAddress: string; + remarks?: string; +} +export interface TransferBeneficiaryParams { + newBeneficiaryAddress: string; + remarks?: string; +} +export interface NominateParams { + newBeneficiaryAddress: string; + remarks?: string; +} +export interface TransferOwnersParams { + newHolderAddress: string; + newBeneficiaryAddress: string; + remarks?: string; +} + +export interface ProviderInfo { + Provider: providersV5.Provider | ProviderV6; + ethersVersion: 'v5' | 'v6'; + titleEscrowVersion: 'v4' | 'v5'; +} diff --git a/src/token-registry-functions/utils.ts b/src/token-registry-functions/utils.ts new file mode 100644 index 0000000..df031e0 --- /dev/null +++ b/src/token-registry-functions/utils.ts @@ -0,0 +1,44 @@ +import { isV6EthersProvider } from 'src/utils/ethers'; +import { GasValue } from './types'; +import { CHAIN_ID, SUPPORTED_CHAINS } from '@tradetrust-tt/tradetrust-utils'; +import { Signer } from 'ethers'; +import { Signer as SignerV6 } from 'ethersV6'; + +const getTxOptions = async ( + signer: SignerV6 | Signer, + chainId: CHAIN_ID, + maxFeePerGas: GasValue, + maxPriorityFeePerGas: GasValue, +) => { + // If gas values are missing, query gas station if available + if (!maxFeePerGas || !maxPriorityFeePerGas) { + chainId = chainId ?? ((await getChainIdSafe(signer)) as unknown as CHAIN_ID); + const gasStation = SUPPORTED_CHAINS[chainId]?.gasStation; + + if (gasStation) { + const gasFees = await gasStation(); + maxFeePerGas = gasFees?.maxFeePerGas ?? 0; + maxPriorityFeePerGas = gasFees?.maxPriorityFeePerGas ?? 0; + } + } + return maxFeePerGas && maxPriorityFeePerGas ? { maxFeePerGas, maxPriorityFeePerGas } : undefined; +}; + +// 🔍 Handles both Ethers v5 and v6 signer types +const getChainIdSafe = async (signer: SignerV6 | Signer): Promise => { + if (isV6EthersProvider(signer.provider)) { + const network = await (signer as SignerV6).provider?.getNetwork(); + if (!network?.chainId) throw new Error('Cannot determine chainId: provider is missing'); + return network.chainId; + } + return await (signer as Signer).getChainId(); +}; + +const getSignerAddressSafe = async (signer: SignerV6 | Signer): Promise => { + if (isV6EthersProvider(signer.provider)) { + return await (signer as SignerV6).getAddress(); + } + return await (signer as unknown as Signer).getAddress(); +}; + +export { getChainIdSafe, getTxOptions, getSignerAddressSafe };