diff --git a/package-lock.json b/package-lock.json index 392e9b4..4dc87cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@trustvc/trustvc": "^2.5.0", "@types/yargs": "^17.0.32", "chalk": "^4.1.2", + "ethers": "^6.16.0", "inquirer": "^13.1.0", "node-fetch": "^3.3.2", "signale": "^1.4.0", @@ -1651,6 +1652,12 @@ "scrypt-js": "3.0.1" } }, + "node_modules/@ethersproject/json-wallets/node_modules/aes-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", + "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", + "license": "MIT" + }, "node_modules/@ethersproject/keccak256": { "version": "5.8.0", "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.8.0.tgz", @@ -3690,6 +3697,23 @@ "ethers": ">=5.0.8" } }, + "node_modules/@tradetrust-tt/token-registry-v4/node_modules/@typechain/ethers-v5": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@typechain/ethers-v5/-/ethers-v5-10.2.1.tgz", + "integrity": "sha512-n3tQmCZjRE6IU4h6lqUGiQ1j866n5MTCBJreNEHHVWXa2u9GJTaeYyU1/k+1qLutkyw+sS6VAN+AbeiTqsxd/A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15", + "ts-essentials": "^7.0.1" + }, + "peerDependencies": { + "@ethersproject/abi": "^5.0.0", + "@ethersproject/providers": "^5.0.0", + "ethers": "^5.1.3", + "typechain": "^8.1.1", + "typescript": ">=4.3.0" + } + }, "node_modules/@tradetrust-tt/token-registry-v5": { "name": "@tradetrust-tt/token-registry", "version": "5.5.1", @@ -3986,6 +4010,54 @@ "resolved": "https://registry.npmjs.org/did-resolver/-/did-resolver-4.1.0.tgz", "integrity": "sha512-S6fWHvCXkZg2IhS4RcVHxwuyVejPR7c+a4Go0xbQ9ps5kILa8viiYQgrM4gfTyeTjJ0ekgJH9gk/BawTpmkbZA==" }, + "node_modules/@tradetrust-tt/tradetrust/node_modules/ethers": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.8.0.tgz", + "integrity": "sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abi": "5.8.0", + "@ethersproject/abstract-provider": "5.8.0", + "@ethersproject/abstract-signer": "5.8.0", + "@ethersproject/address": "5.8.0", + "@ethersproject/base64": "5.8.0", + "@ethersproject/basex": "5.8.0", + "@ethersproject/bignumber": "5.8.0", + "@ethersproject/bytes": "5.8.0", + "@ethersproject/constants": "5.8.0", + "@ethersproject/contracts": "5.8.0", + "@ethersproject/hash": "5.8.0", + "@ethersproject/hdnode": "5.8.0", + "@ethersproject/json-wallets": "5.8.0", + "@ethersproject/keccak256": "5.8.0", + "@ethersproject/logger": "5.8.0", + "@ethersproject/networks": "5.8.0", + "@ethersproject/pbkdf2": "5.8.0", + "@ethersproject/properties": "5.8.0", + "@ethersproject/providers": "5.8.0", + "@ethersproject/random": "5.8.0", + "@ethersproject/rlp": "5.8.0", + "@ethersproject/sha2": "5.8.0", + "@ethersproject/signing-key": "5.8.0", + "@ethersproject/solidity": "5.8.0", + "@ethersproject/strings": "5.8.0", + "@ethersproject/transactions": "5.8.0", + "@ethersproject/units": "5.8.0", + "@ethersproject/wallet": "5.8.0", + "@ethersproject/web": "5.8.0", + "@ethersproject/wordlists": "5.8.0" + } + }, "node_modules/@tradetrust-tt/tradetrust/node_modules/jsonld-signatures": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/jsonld-signatures/-/jsonld-signatures-7.0.0.tgz", @@ -4095,6 +4167,54 @@ "ethers": "^5.7.2" } }, + "node_modules/@tradetrust-tt/tt-verify/node_modules/ethers": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.8.0.tgz", + "integrity": "sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abi": "5.8.0", + "@ethersproject/abstract-provider": "5.8.0", + "@ethersproject/abstract-signer": "5.8.0", + "@ethersproject/address": "5.8.0", + "@ethersproject/base64": "5.8.0", + "@ethersproject/basex": "5.8.0", + "@ethersproject/bignumber": "5.8.0", + "@ethersproject/bytes": "5.8.0", + "@ethersproject/constants": "5.8.0", + "@ethersproject/contracts": "5.8.0", + "@ethersproject/hash": "5.8.0", + "@ethersproject/hdnode": "5.8.0", + "@ethersproject/json-wallets": "5.8.0", + "@ethersproject/keccak256": "5.8.0", + "@ethersproject/logger": "5.8.0", + "@ethersproject/networks": "5.8.0", + "@ethersproject/pbkdf2": "5.8.0", + "@ethersproject/properties": "5.8.0", + "@ethersproject/providers": "5.8.0", + "@ethersproject/random": "5.8.0", + "@ethersproject/rlp": "5.8.0", + "@ethersproject/sha2": "5.8.0", + "@ethersproject/signing-key": "5.8.0", + "@ethersproject/solidity": "5.8.0", + "@ethersproject/strings": "5.8.0", + "@ethersproject/transactions": "5.8.0", + "@ethersproject/units": "5.8.0", + "@ethersproject/wallet": "5.8.0", + "@ethersproject/web": "5.8.0", + "@ethersproject/wordlists": "5.8.0" + } + }, "node_modules/@trustvc/trustvc": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@trustvc/trustvc/-/trustvc-2.5.0.tgz", @@ -4127,6 +4247,54 @@ "ethers": "^5.8.0" } }, + "node_modules/@trustvc/trustvc/node_modules/ethers": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.8.0.tgz", + "integrity": "sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abi": "5.8.0", + "@ethersproject/abstract-provider": "5.8.0", + "@ethersproject/abstract-signer": "5.8.0", + "@ethersproject/address": "5.8.0", + "@ethersproject/base64": "5.8.0", + "@ethersproject/basex": "5.8.0", + "@ethersproject/bignumber": "5.8.0", + "@ethersproject/bytes": "5.8.0", + "@ethersproject/constants": "5.8.0", + "@ethersproject/contracts": "5.8.0", + "@ethersproject/hash": "5.8.0", + "@ethersproject/hdnode": "5.8.0", + "@ethersproject/json-wallets": "5.8.0", + "@ethersproject/keccak256": "5.8.0", + "@ethersproject/logger": "5.8.0", + "@ethersproject/networks": "5.8.0", + "@ethersproject/pbkdf2": "5.8.0", + "@ethersproject/properties": "5.8.0", + "@ethersproject/providers": "5.8.0", + "@ethersproject/random": "5.8.0", + "@ethersproject/rlp": "5.8.0", + "@ethersproject/sha2": "5.8.0", + "@ethersproject/signing-key": "5.8.0", + "@ethersproject/solidity": "5.8.0", + "@ethersproject/strings": "5.8.0", + "@ethersproject/transactions": "5.8.0", + "@ethersproject/units": "5.8.0", + "@ethersproject/wallet": "5.8.0", + "@ethersproject/web": "5.8.0", + "@ethersproject/wordlists": "5.8.0" + } + }, "node_modules/@trustvc/trustvc/node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -4716,9 +4884,10 @@ } }, "node_modules/aes-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", - "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==" + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" }, "node_modules/agent-base": { "version": "6.0.2", @@ -6267,13 +6436,13 @@ } }, "node_modules/ethers": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.8.0.tgz", - "integrity": "sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", "funding": [ { "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + "url": "https://github.com/sponsors/ethers-io/" }, { "type": "individual", @@ -6281,36 +6450,82 @@ } ], "dependencies": { - "@ethersproject/abi": "5.8.0", - "@ethersproject/abstract-provider": "5.8.0", - "@ethersproject/abstract-signer": "5.8.0", - "@ethersproject/address": "5.8.0", - "@ethersproject/base64": "5.8.0", - "@ethersproject/basex": "5.8.0", - "@ethersproject/bignumber": "5.8.0", - "@ethersproject/bytes": "5.8.0", - "@ethersproject/constants": "5.8.0", - "@ethersproject/contracts": "5.8.0", - "@ethersproject/hash": "5.8.0", - "@ethersproject/hdnode": "5.8.0", - "@ethersproject/json-wallets": "5.8.0", - "@ethersproject/keccak256": "5.8.0", - "@ethersproject/logger": "5.8.0", - "@ethersproject/networks": "5.8.0", - "@ethersproject/pbkdf2": "5.8.0", - "@ethersproject/properties": "5.8.0", - "@ethersproject/providers": "5.8.0", - "@ethersproject/random": "5.8.0", - "@ethersproject/rlp": "5.8.0", - "@ethersproject/sha2": "5.8.0", - "@ethersproject/signing-key": "5.8.0", - "@ethersproject/solidity": "5.8.0", - "@ethersproject/strings": "5.8.0", - "@ethersproject/transactions": "5.8.0", - "@ethersproject/units": "5.8.0", - "@ethersproject/wallet": "5.8.0", - "@ethersproject/web": "5.8.0", - "@ethersproject/wordlists": "5.8.0" + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/ethers/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, "node_modules/ethersV6": { @@ -11096,6 +11311,8 @@ "version": "2.8.8", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "license": "MIT", + "peer": true, "bin": { "prettier": "bin-prettier.js" }, diff --git a/package.json b/package.json index 0286ee1..5bc3054 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@trustvc/trustvc": "^2.5.0", "@types/yargs": "^17.0.32", "chalk": "^4.1.2", + "ethers": "^6.16.0", "inquirer": "^13.1.0", "node-fetch": "^3.3.2", "signale": "^1.4.0", diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts new file mode 100644 index 0000000..9d210ae --- /dev/null +++ b/src/commands/helpers.ts @@ -0,0 +1,234 @@ +// External dependencies +import { BytesLike, Wallet, HDNodeWallet, ZeroAddress } from 'ethers'; +import signale from 'signale'; +import { v5Contracts } from '@trustvc/trustvc'; +import { encrypt } from '@trustvc/trustvc'; + +// Internal utilities +import { ConnectedSigner } from '../utils'; + +// Contract factories from TrustVC v5 +const { TitleEscrow__factory, TradeTrustToken__factory } = v5Contracts; +// Interface for connectToTitleEscrow function arguments +interface ConnectToTitleEscrowArgs { + tokenId: string; + address: string; + wallet: Wallet | HDNodeWallet | ConnectedSigner; +} + +/** + * Connects to a title escrow contract instance for a specific token. + * Retrieves the title escrow address from the token registry and establishes a connection. + * + * @param tokenId - The unique identifier of the token + * @param address - The address of the token registry contract + * @param wallet - The wallet or signer to use for the connection + * @returns Promise resolving to the connected TitleEscrow contract instance + * @throws Error if title escrow address is invalid or connection fails + */ +export const connectToTitleEscrow = async ({ + tokenId, + address, + wallet, +}: ConnectToTitleEscrowArgs): Promise> => { + try { + // Connect to the token registry contract + signale.info(`Connecting to token registry at: ${address}`); + const tokenRegistry = TradeTrustToken__factory.connect(address, wallet); + + // Fetch the title escrow address by getting the owner of the token + signale.info(`Fetching title escrow address for tokenId: ${tokenId}`); + const titleEscrowAddress = await tokenRegistry.ownerOf(tokenId); + signale.info(`Title escrow address: ${titleEscrowAddress}`); + + // Validate that the title escrow address is not zero/invalid + if (!titleEscrowAddress || titleEscrowAddress === ZeroAddress) { + const error = `Invalid title escrow address for tokenId: ${tokenId}. Address: ${titleEscrowAddress}`; + signale.error(error); + throw new Error(error); + } + + // Connect to the title escrow contract + signale.info(`Connecting to title escrow at: ${titleEscrowAddress}`); + const titleEscrow = TitleEscrow__factory.connect(titleEscrowAddress, wallet); + console.log(titleEscrow.tr); + + // Validate the connection was successful + if (!titleEscrow) { + const error = `Failed to connect to title escrow at address: ${titleEscrowAddress}`; + signale.error(error); + throw new Error(error); + } + + signale.success(`Successfully connected to title escrow`); + return titleEscrow; + } catch (error) { + signale.error( + `Error in connectToTitleEscrow: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } +}; + +// Interface for validateEndorseChangeOwner function arguments +interface validateEndorseChangeOwnerArgs { + newHolder: string; + newOwner: string; + titleEscrow: InstanceType; +} +/** + * Validates that the new owner and holder are different from the current ones. + * Prevents unnecessary transactions when attempting to transfer to the same addresses. + * + * @param newHolder - The proposed new holder address + * @param newOwner - The proposed new owner (beneficiary) address + * @param titleEscrow - The title escrow contract instance + * @throws Error if new addresses match current addresses + */ +export const validateEndorseChangeOwner = async ({ + newHolder, + newOwner, + titleEscrow, +}: validateEndorseChangeOwnerArgs): Promise => { + // Get current beneficiary and holder from the contract + const beneficiary = await titleEscrow.beneficiary(); + const holder = await titleEscrow.holder(); + + // Check if both new addresses match the current ones + if (newOwner === beneficiary && newHolder === holder) { + const error = + 'new owner and new holder addresses are the same as the current owner and holder addresses'; + signale.error(error); + throw new Error(error); + } +}; + +// Interface for validateNominateBeneficiary function arguments +interface validateNominateBeneficiaryArgs { + beneficiaryNominee: string; + titleEscrow: InstanceType; +} +/** + * Validates that the nominated beneficiary is different from the current beneficiary. + * Prevents nominating the same beneficiary that already exists. + * + * @param beneficiaryNominee - The proposed new beneficiary address + * @param titleEscrow - The title escrow contract instance + * @throws Error if the nominee is the same as the current beneficiary + */ +export const validateNominateBeneficiary = async ({ + beneficiaryNominee, + titleEscrow, +}: validateNominateBeneficiaryArgs): Promise => { + // Get current beneficiary from the contract + const beneficiary = await titleEscrow.beneficiary(); + + // Check if the nominee is the same as the current beneficiary + if (beneficiaryNominee === beneficiary) { + const error = 'new beneficiary address is the same as the current beneficiary address'; + signale.error(error); + throw new Error(error); + } +}; + +/** + * Validates that a previous beneficiary exists for rejection operations. + * A previous beneficiary must be set to perform a beneficiary transfer rejection. + * + * @param titleEscrow - The title escrow contract instance + * @throws Error if previous beneficiary is not set (zero address) + */ +export const validatePreviousBeneficiary = async ( + titleEscrow: InstanceType, +): Promise => { + // Get the previous beneficiary from the contract + const prevBeneficiary = await titleEscrow.prevBeneficiary(); + + // Check if previous beneficiary is set (not zero address) + if (prevBeneficiary === ZeroAddress) { + const error = 'invalid rejection as previous beneficiary is not set'; + signale.error(error); + throw new Error(error); + } +}; + +/** + * Validates that a previous holder exists for rejection operations. + * A previous holder must be set to perform a holder transfer rejection. + * + * @param titleEscrow - The title escrow contract instance + * @throws Error if previous holder is not set (zero address) + */ +export const validatePreviousHolder = async ( + titleEscrow: InstanceType, +): Promise => { + // Get the previous holder from the contract + const prevHolder = await titleEscrow.prevHolder(); + + // Check if previous holder is set (not zero address) + if (prevHolder === ZeroAddress) { + const error = 'invalid rejection as previous holder is not set'; + signale.error(error); + throw new Error(error); + } +}; + +// Interface for validateEndorseTransferOwner function arguments +interface validateEndorseTransferOwnerArgs { + approvedOwner: string | undefined; + approvedHolder: string | undefined; +} + +// Genesis address (zero address) constant +const GENESIS_ADDRESS = ZeroAddress; +/** + * Validates that approved owner and holder exist and are not the genesis address. + * Ensures that there are valid approved addresses before endorsing a transfer. + * + * @param approvedOwner - The approved owner address (may be undefined) + * @param approvedHolder - The approved holder address (may be undefined) + * @throws Error if approved addresses are missing or equal to genesis address + */ +export const validateEndorseTransferOwner = ({ + approvedOwner, + approvedHolder, +}: validateEndorseTransferOwnerArgs): void => { + // Check if approved addresses exist and are not the genesis address + if ( + !approvedOwner || + !approvedHolder || + approvedOwner === GENESIS_ADDRESS || + approvedHolder === GENESIS_ADDRESS + ) { + const error = `there is no approved owner or holder or the approved owner or holder is equal to the genesis address: ${GENESIS_ADDRESS}`; + signale.error(error); + throw new Error(error); + } +}; + +/** + * Validates and encrypts a remark string for blockchain transactions. + * Ensures the remark meets length requirements and encrypts it with the provided key. + * + * @param remark - Optional remark string to encrypt (max 120 characters) + * @param keyId - Optional encryption key ID + * @returns Encrypted remark as BytesLike (hex string), or '0x' if no remark provided + * @throws Error if remark exceeds 120 characters + */ +export const validateAndEncryptRemark = (remark?: string, keyId?: string): BytesLike => { + // Validate remark length (max 120 characters) + if (remark && remark.length > 120) { + const error = `Remark length is more than 120 characters`; + signale.error(error); + throw new Error(error); + } + + // Return empty hex string if no remark provided + if (!remark || remark?.length === 0) { + return '0x'; + } + + // Encrypt the remark and ensure it has '0x' prefix + const encrpyted = encrypt(remark, keyId ?? ''); + return encrpyted.startsWith('0x') ? encrpyted : `0x${encrpyted}`; +}; diff --git a/src/commands/title-escrow/change-holder.ts b/src/commands/title-escrow/change-holder.ts new file mode 100644 index 0000000..4529afc --- /dev/null +++ b/src/commands/title-escrow/change-holder.ts @@ -0,0 +1,251 @@ +import { input } from '@inquirer/prompts'; +import { error, info, success, warn } from 'signale'; +import signale from 'signale'; +import { TransactionReceipt } from 'ethers'; +import { CHAIN_ID, transferHolder as transferHolderImpl } from '@trustvc/trustvc'; +import { TitleEscrowTransferHolderCommand } from '../../types'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + promptRemarkAndEncryptionKey, + promptNetworkSelection, + promptWalletSelection, + TransactionReceiptFees, + getSupportedNetwork, + getWalletOrSigner, + dryRunMode, + canEstimateGasPrice, + getGasFees, +} from '../../utils'; +import { + connectToTitleEscrow, + validateAndEncryptRemark, +} from '../helpers'; + +export const command = 'change-holder'; + +export const describe = 'Changes the holder of the transferable record to another address'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + + await changeHolderHandler(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +// Prompt user for all required inputs +export const promptForInputs = async (): Promise => { + // Network selection + const network = await promptNetworkSelection(); + + // Token Registry Address + const tokenRegistry = await input({ + message: 'Enter the token registry contract address:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Token registry address is required'; + } + if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { + return 'Invalid Ethereum address format'; + } + return true; + }, + }); + + // Token ID (Document Hash) + const tokenId = await input({ + message: 'Enter the document hash (tokenId) of the transferable record:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Token ID is required'; + } + return true; + }, + }); + + // New Holder Address + const newHolder = await input({ + message: 'Enter the address of the new holder:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'New holder address is required'; + } + if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { + return 'Invalid Ethereum address format'; + } + return true; + }, + }); + + // Wallet selection + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + + // Optional: Remark and Encryption Key + const { remark, encryptionKey } = await promptRemarkAndEncryptionKey(); + + // Build the result object + const baseResult = { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + newHolder, + remark, + encryptionKey, + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + // Add wallet-specific properties + if (encryptedWalletPath) { + return { + ...baseResult, + encryptedWalletPath, + } as TitleEscrowTransferHolderCommand; + } else if (keyFile) { + return { + ...baseResult, + keyFile, + } as TitleEscrowTransferHolderCommand; + } else if (key) { + return { + ...baseResult, + key, + } as TitleEscrowTransferHolderCommand; + } + + // For environment variable case (when all wallet options are undefined) + return baseResult as TitleEscrowTransferHolderCommand; +}; + +// Change the holder with the provided inputs +export const changeHolderHandler = async (args: TitleEscrowTransferHolderCommand) => { + try { + info( + `Connecting to the registry ${args.tokenRegistryAddress} and attempting to change the holder of the transferable record ${args.tokenId} to ${args.newHolder}`, + ); + warn( + `Please note that only current holders can change the holder of the transferable record, otherwise this command will fail.`, + ); + + const transaction = await transferHolder(args); + const network = args.network as NetworkCmdName; + displayTransactionPrice(transaction as unknown as TransactionReceiptFees, network); + const { hash: transactionHash } = transaction; + + success( + `Transferable record with hash ${args.tokenId}'s holder has been successfully changed to holder with address: ${args.newHolder}`, + ); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transactionHash}`, + ); + + return args.tokenRegistryAddress; + } catch (e) { + error(getErrorMessage(e)); + } +}; + +/** + * Transfers the holder role to a new address. + * The holder has custody of the transferable record but not ownership. + * + * @param tokenRegistryAddress - The address of the token registry contract + * @param newHolder - The address of the new holder (aliased as 'to') + * @param remark - Optional remark/comment to attach to the transaction + * @param encryptionKey - Optional encryption key for encrypting the remark + * @param tokenId - The unique identifier of the token + * @param network - The blockchain network to execute the transaction on + * @param dryRun - If true, simulates the transaction without executing it + * @param rest - Additional parameters (e.g., wallet configuration, gas settings) + * @returns Promise resolving to the transaction receipt + * @throws Error if provider is required but not available, or if transaction receipt is null + */ +export const transferHolder = async ({ + tokenRegistryAddress, + newHolder: to, + remark, + encryptionKey, + tokenId, + network, + dryRun, + ...rest +}: TitleEscrowTransferHolderCommand): Promise => { + // Initialize wallet/signer for the transaction + const wallet = await getWalletOrSigner({ network, ...rest }); + + // Get the network ID for the specified network + const networkId = getSupportedNetwork(network).networkId; + + // Validate and encrypt the remark if encryption key is provided + const encryptedRemark = validateAndEncryptRemark(remark, encryptionKey); + // Dry run mode: estimate gas and exit without executing the transaction + if (dryRun) { + // Connect to the title escrow contract for gas estimation + const titleEscrow = await connectToTitleEscrow({ + tokenId, + address: tokenRegistryAddress, + wallet, + }); + + await dryRunMode({ + estimatedGas: await titleEscrow.estimateGas.transferHolder(to, encryptedRemark), + network, + }); + process.exit(0); + } + let transaction; + + // Execute transaction with appropriate gas settings based on network capabilities + if (canEstimateGasPrice(network)) { + // Ensure provider is available for gas estimation + if (!wallet.provider) { + throw new Error('Provider is required for gas estimation'); + } + + // Get current gas fees from the network + const gasFees = await getGasFees({ provider: wallet.provider, ...rest }); + + // Execute holder transfer with EIP-1559 gas parameters + transaction = await transferHolderImpl( + { tokenRegistryAddress, tokenId }, + wallet, + { remarks: remark, holderAddress: to }, + { + chainId: networkId as unknown as CHAIN_ID, + maxFeePerGas: gasFees.maxFeePerGas?.toString(), + maxPriorityFeePerGas: gasFees.maxPriorityFeePerGas?.toString(), + id: encryptionKey, + }, + ); + } else { + // Execute holder transfer without gas estimation (for networks that don't support it) + transaction = await transferHolderImpl( + { tokenRegistryAddress, tokenId }, + wallet, + { remarks: remark, holderAddress: to }, + { + chainId: networkId as unknown as CHAIN_ID, + id: encryptionKey, + }, + ); + } + // Wait for transaction to be mined + signale.await(`Waiting for transaction ${transaction.hash} to be mined`); + const receipt = await transaction.wait(); + + // Validate receipt exists + if (!receipt) { + throw new Error('Transaction receipt is null'); + } + + return receipt as unknown as TransactionReceipt; +}; diff --git a/src/commands/title-escrow/endorse-change-of-owner.ts b/src/commands/title-escrow/endorse-change-of-owner.ts new file mode 100644 index 0000000..9ee28b5 --- /dev/null +++ b/src/commands/title-escrow/endorse-change-of-owner.ts @@ -0,0 +1,281 @@ +import { input } from '@inquirer/prompts'; +import { error, info, success, warn } from 'signale'; +import signale from 'signale'; +import { TransactionReceipt } from 'ethers'; +import { CHAIN_ID, transferOwners as transferOwnersImpl } from '@trustvc/trustvc'; +import { TitleEscrowEndorseTransferOfOwnersCommand } from '../../types'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + promptRemarkAndEncryptionKey, + promptNetworkSelection, + promptWalletSelection, + TransactionReceiptFees, + getSupportedNetwork, + getWalletOrSigner, + dryRunMode, + canEstimateGasPrice, + getGasFees, +} from '../../utils'; +import { + connectToTitleEscrow, + validateAndEncryptRemark, + validateEndorseChangeOwner, +} from '../helpers'; + +export const command = 'endorse-change-owner'; + +export const describe = 'Endorses the change of owner of transferable record to another address'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + + await endorseChangeOwnerHandler(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +// Prompt user for all required inputs +export const promptForInputs = async (): Promise => { + // Network selection + const network = await promptNetworkSelection(); + + // Token Registry Address + const tokenRegistry = await input({ + message: 'Enter the token registry contract address:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Token registry address is required'; + } + if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { + return 'Invalid Ethereum address format'; + } + return true; + }, + }); + + // Token ID (Document Hash) + const tokenId = await input({ + message: 'Enter the document hash (tokenId) of the transferable record:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Token ID is required'; + } + return true; + }, + }); + + // New Owner Address + const newOwner = await input({ + message: 'Enter the address of the new owner (beneficiary):', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'New owner address is required'; + } + if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { + return 'Invalid Ethereum address format'; + } + return true; + }, + }); + + // New Holder Address + const newHolder = await input({ + message: 'Enter the address of the new holder:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'New holder address is required'; + } + if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { + return 'Invalid Ethereum address format'; + } + return true; + }, + }); + + // Wallet selection + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + + // Optional: Remark and Encryption Key + const { remark, encryptionKey } = await promptRemarkAndEncryptionKey(); + + // Build the result object + const baseResult = { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + newOwner, + newHolder, + remark, + encryptionKey, + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + // Add wallet-specific properties + if (encryptedWalletPath) { + return { + ...baseResult, + encryptedWalletPath, + } as TitleEscrowEndorseTransferOfOwnersCommand; + } else if (keyFile) { + return { + ...baseResult, + keyFile, + } as TitleEscrowEndorseTransferOfOwnersCommand; + } else if (key) { + return { + ...baseResult, + key, + } as TitleEscrowEndorseTransferOfOwnersCommand; + } + + // For environment variable case (when all wallet options are undefined) + return baseResult as TitleEscrowEndorseTransferOfOwnersCommand; +}; + +// Endorse the change of owner with the provided inputs +export const endorseChangeOwnerHandler = async ( + args: TitleEscrowEndorseTransferOfOwnersCommand, +) => { + try { + info( + `Connecting to the registry ${args.tokenRegistryAddress} and attempting to endorse the change of owner of the transferable record ${args.tokenId} to new owner at ${args.newOwner} and new holder at ${args.newHolder}`, + ); + warn( + `Please note that you have to be both the holder and owner of the transferable record, otherwise this command will fail.`, + ); + + const transaction = await transferOwners(args); + + const network = args.network as NetworkCmdName; + displayTransactionPrice(transaction as unknown as TransactionReceiptFees, network); + const { hash: transactionHash } = transaction; + + success( + `Transferable record with hash ${args.tokenId}'s holder has been successfully endorsed to new owner with address ${args.newOwner} and new holder with address: ${args.newHolder}`, + ); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transactionHash}`, + ); + + return args.tokenRegistryAddress; + } catch (e) { + error(getErrorMessage(e)); + } +}; + +/** + * Transfers both the beneficiary (owner) and holder roles to new addresses. + * This performs a complete transfer of ownership and custody in a single transaction. + * + * @param tokenRegistryAddress - The address of the token registry contract + * @param tokenId - The unique identifier of the token + * @param newHolder - The address of the new holder + * @param newOwner - The address of the new beneficiary (owner) + * @param remark - Optional remark/comment to attach to the transaction + * @param encryptionKey - Optional encryption key for encrypting the remark + * @param network - The blockchain network to execute the transaction on + * @param dryRun - If true, simulates the transaction without executing it + * @param rest - Additional parameters (e.g., wallet configuration, gas settings) + * @returns Promise resolving to the transaction receipt + * @throws Error if provider is required but not available, or if transaction receipt is null + */ +export const transferOwners = async ({ + tokenRegistryAddress, + tokenId, + newHolder, + newOwner, + remark, + encryptionKey, + network, + dryRun, + ...rest +}: TitleEscrowEndorseTransferOfOwnersCommand): Promise => { + // Initialize wallet/signer for the transaction + const wallet = await getWalletOrSigner({ network, ...rest }); + + // Get the network ID for the specified network + const networkId = getSupportedNetwork(network).networkId; + + // Connect to the title escrow contract for this token + const titleEscrow = await connectToTitleEscrow({ + tokenId, + address: tokenRegistryAddress, + wallet, + }); + + // Validate and encrypt the remark if encryption key is provided + const encryptedRemark = validateAndEncryptRemark(remark, encryptionKey); + + // Validate that the new owner and holder are different from current ones + await validateEndorseChangeOwner({ newHolder, newOwner, titleEscrow }); + // Dry run mode: estimate gas and exit without executing the transaction + if (dryRun) { + await dryRunMode({ + estimatedGas: await titleEscrow.estimateGas.transferOwners( + newOwner, + newHolder, + encryptedRemark, + ), + network, + }); + process.exit(0); + } + let transaction; + + // Execute transaction with appropriate gas settings based on network capabilities + if (canEstimateGasPrice(network)) { + // Ensure provider is available for gas estimation + if (!wallet.provider) { + throw new Error('Provider is required for gas estimation'); + } + + // Get current gas fees from the network + const gasFees = await getGasFees({ provider: wallet.provider, ...rest }); + + // Execute transfer owners with EIP-1559 gas parameters + transaction = await transferOwnersImpl( + { tokenRegistryAddress, tokenId }, + wallet, + { remarks: remark, newBeneficiaryAddress: newOwner, newHolderAddress: newHolder }, + { + chainId: networkId as unknown as CHAIN_ID, + maxFeePerGas: gasFees.maxFeePerGas?.toString(), + maxPriorityFeePerGas: gasFees.maxPriorityFeePerGas?.toString(), + id: encryptionKey, + }, + ); + } else { + // Execute transfer owners without gas estimation (for networks that don't support it) + transaction = await transferOwnersImpl( + { tokenRegistryAddress, tokenId }, + wallet, + { remarks: remark, newBeneficiaryAddress: newOwner, newHolderAddress: newHolder }, + { + chainId: networkId as unknown as CHAIN_ID, + id: encryptionKey, + }, + ); + } + + // Wait for transaction to be mined + signale.await(`Waiting for transaction ${transaction.hash} to be mined`); + const receipt = await transaction.wait(); + + // Validate receipt exists + if (!receipt) { + throw new Error('Transaction receipt is null'); + } + + return receipt as unknown as TransactionReceipt; +}; diff --git a/src/commands/title-escrow/endorse-transfer-of-owner.ts b/src/commands/title-escrow/endorse-transfer-of-owner.ts new file mode 100644 index 0000000..54aa458 --- /dev/null +++ b/src/commands/title-escrow/endorse-transfer-of-owner.ts @@ -0,0 +1,268 @@ +import { input } from '@inquirer/prompts'; +import { error, info, success, warn } from 'signale'; +import signale from 'signale'; +import { TransactionReceipt } from 'ethers'; +import { CHAIN_ID, transferBeneficiary as transferBeneficiaryImpl } from '@trustvc/trustvc'; +import { TitleEscrowNominateBeneficiaryCommand } from '../../types'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + promptRemarkAndEncryptionKey, + promptNetworkSelection, + promptWalletSelection, + TransactionReceiptFees, + getSupportedNetwork, + getWalletOrSigner, + dryRunMode, + canEstimateGasPrice, + getGasFees, +} from '../../utils'; +import { + connectToTitleEscrow, + validateAndEncryptRemark, + validateNominateBeneficiary, +} from '../helpers'; + +export const command = 'endorse-transfer-owner'; + +export const describe = + 'Endorses the transfer of owner of transferable record to an approved owner and approved holder address'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + + await endorseTransferOwnerHandler(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +// Prompt user for all required inputs +export const promptForInputs = async (): Promise => { + // Network selection + const network = await promptNetworkSelection(); + + // Token Registry Address + const tokenRegistry = await input({ + message: 'Enter the token registry contract address:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Token registry address is required'; + } + if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { + return 'Invalid Ethereum address format'; + } + return true; + }, + }); + + // Token ID (Document Hash) + const tokenId = await input({ + message: 'Enter the document hash (tokenId) of the transferable record:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Token ID is required'; + } + return true; + }, + }); + + // New Beneficiary Address + const newBeneficiary = await input({ + message: 'Enter the address of the new beneficiary (owner):', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'New beneficiary address is required'; + } + if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { + return 'Invalid Ethereum address format'; + } + return true; + }, + }); + + // Wallet selection + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + + // Optional: Remark and Encryption Key + const { remark, encryptionKey } = await promptRemarkAndEncryptionKey(); + + // Build the result object + const baseResult = { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + newBeneficiary, + remark, + encryptionKey, + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + // Add wallet-specific properties + if (encryptedWalletPath) { + return { + ...baseResult, + encryptedWalletPath, + } as TitleEscrowNominateBeneficiaryCommand; + } else if (keyFile) { + return { + ...baseResult, + keyFile, + } as TitleEscrowNominateBeneficiaryCommand; + } else if (key) { + return { + ...baseResult, + key, + } as TitleEscrowNominateBeneficiaryCommand; + } + + // For environment variable case (when all wallet options are undefined) + return baseResult as TitleEscrowNominateBeneficiaryCommand; +}; + +// Endorse the transfer of owner with the provided inputs +export const endorseTransferOwnerHandler = async (args: TitleEscrowNominateBeneficiaryCommand) => { + try { + info( + `Connecting to the registry ${args.tokenRegistryAddress} and attempting to endorse the change of owner of the transferable record ${args.tokenId} to approved owner and approved holder`, + ); + warn( + `Please note that if you do not have the correct privileges to the transferable record, then this command will fail.`, + ); + + const { transactionReceipt } = await endorseNominatedBeneficiary(args); + const network = args.network as NetworkCmdName; + displayTransactionPrice(transactionReceipt as unknown as TransactionReceiptFees, network); + const { hash: transactionHash } = transactionReceipt; + + success( + `Transferable record with hash ${args.tokenId}'s holder has been successfully endorsed to approved beneficiary at ${args.newBeneficiary}`, + ); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transactionHash}`, + ); + + return args.tokenRegistryAddress; + } catch (e) { + error(getErrorMessage(e)); + } +}; + +/** + * Endorses a nominated beneficiary by transferring the beneficiary role to the new address. + * This operation confirms the beneficiary nomination and completes the beneficiary transfer. + * + * @param tokenRegistryAddress - The address of the token registry contract + * @param tokenId - The unique identifier of the token + * @param remark - Optional remark/comment to attach to the transaction + * @param encryptionKey - Optional encryption key for encrypting the remark + * @param newBeneficiary - The address of the new beneficiary to endorse + * @param network - The blockchain network to execute the transaction on + * @param dryRun - If true, simulates the transaction without executing it + * @param rest - Additional parameters (e.g., wallet configuration, gas settings) + * @returns Promise resolving to an object containing the transaction receipt and nominated beneficiary address + * @throws Error if provider is required but not available, or if transaction receipt is null + */ +export const endorseNominatedBeneficiary = async ({ + tokenRegistryAddress, + tokenId, + remark, + encryptionKey, + newBeneficiary, + network, + dryRun, + ...rest +}: TitleEscrowNominateBeneficiaryCommand): Promise<{ + transactionReceipt: TransactionReceipt; + nominatedBeneficiary: string; +}> => { + // Initialize wallet/signer for the transaction + const wallet = await getWalletOrSigner({ network, ...rest }); + + // Get the network ID for the specified network + const networkId = getSupportedNetwork(network).networkId; + + // Connect to the title escrow contract for this token + const titleEscrow = await connectToTitleEscrow({ + tokenId, + address: tokenRegistryAddress, + wallet, + }); + + // Set the nominated beneficiary and validate the nomination + const nominatedBeneficiary = newBeneficiary; + await validateNominateBeneficiary({ beneficiaryNominee: nominatedBeneficiary, titleEscrow }); + + // Validate and encrypt the remark if encryption key is provided + const encryptedRemark = validateAndEncryptRemark(remark, encryptionKey); + // Dry run mode: estimate gas and exit without executing the transaction + if (dryRun) { + await dryRunMode({ + estimatedGas: await titleEscrow.estimateGas.transferBeneficiary( + nominatedBeneficiary, + encryptedRemark, + ), + network, + }); + process.exit(0); + } + let transaction; + + // Execute transaction with appropriate gas settings based on network capabilities + if (canEstimateGasPrice(network)) { + // Ensure provider is available for gas estimation + if (!wallet.provider) { + throw new Error('Provider is required for gas estimation'); + } + + // Get current gas fees from the network + const gasFees = await getGasFees({ provider: wallet.provider, ...rest }); + + // Execute beneficiary transfer with EIP-1559 gas parameters + transaction = await transferBeneficiaryImpl( + { tokenRegistryAddress, tokenId }, + wallet, + { remarks: remark, newBeneficiaryAddress: nominatedBeneficiary }, + { + chainId: networkId as unknown as CHAIN_ID, + maxFeePerGas: gasFees.maxFeePerGas?.toString(), + maxPriorityFeePerGas: gasFees.maxPriorityFeePerGas?.toString(), + id: encryptionKey, + }, + ); + } else { + // Execute beneficiary transfer without gas estimation (for networks that don't support it) + transaction = await transferBeneficiaryImpl( + { tokenRegistryAddress, tokenId }, + wallet, + { remarks: remark, newBeneficiaryAddress: nominatedBeneficiary }, + { + chainId: networkId as unknown as CHAIN_ID, + id: encryptionKey, + }, + ); + } + + // Wait for transaction to be mined + signale.await(`Waiting for transaction ${transaction.hash} to be mined`); + const transactionReceipt = await transaction.wait(); + + // Validate receipt exists + if (!transactionReceipt) { + throw new Error('Transaction receipt is null'); + } + + // Return transaction receipt and the nominated beneficiary address + return { + transactionReceipt: transactionReceipt as unknown as TransactionReceipt, + nominatedBeneficiary: nominatedBeneficiary, + }; +}; diff --git a/src/commands/title-escrow/index.ts b/src/commands/title-escrow/index.ts new file mode 100644 index 0000000..3c86a9a --- /dev/null +++ b/src/commands/title-escrow/index.ts @@ -0,0 +1,10 @@ +import { Argv } from 'yargs'; + +export const command = 'title-escrow '; + +export const describe = 'Invoke a function over a title escrow smart contract on the blockchain'; + +export const builder = (yargs: Argv): Argv => + yargs.commandDir(__dirname, { extensions: ['ts', 'js'] }); + +export const handler = (): void => {}; diff --git a/src/commands/title-escrow/nominate-change-of-owner.ts b/src/commands/title-escrow/nominate-change-of-owner.ts new file mode 100644 index 0000000..eb3859c --- /dev/null +++ b/src/commands/title-escrow/nominate-change-of-owner.ts @@ -0,0 +1,258 @@ +import { input } from '@inquirer/prompts'; +import { error, info, success, warn } from 'signale'; +import signale from 'signale'; +import { TransactionReceipt } from 'ethers'; +import { CHAIN_ID, nominate as nominateImpl } from '@trustvc/trustvc'; +import { TitleEscrowNominateBeneficiaryCommand } from '../../types'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + promptRemarkAndEncryptionKey, + promptNetworkSelection, + promptWalletSelection, + TransactionReceiptFees, + getSupportedNetwork, + getWalletOrSigner, + dryRunMode, + canEstimateGasPrice, + getGasFees, +} from '../../utils'; +import { + connectToTitleEscrow, + validateAndEncryptRemark, + validateNominateBeneficiary, +} from '../helpers'; + +export const command = 'nominate-change-owner'; + +export const describe = 'Nominates the change of owner of transferable record to another address'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + + await nominateChangeOwnerHandler(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +// Prompt user for all required inputs +export const promptForInputs = async (): Promise => { + // Network selection + const network = await promptNetworkSelection(); + + // Token Registry Address + const tokenRegistry = await input({ + message: 'Enter the token registry contract address:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Token registry address is required'; + } + if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { + return 'Invalid Ethereum address format'; + } + return true; + }, + }); + + // Token ID (Document Hash) + const tokenId = await input({ + message: 'Enter the document hash (tokenId) of the transferable record:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Token ID is required'; + } + return true; + }, + }); + + // New Beneficiary Address + const newBeneficiary = await input({ + message: 'Enter the address of the new beneficiary (owner):', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'New beneficiary address is required'; + } + if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { + return 'Invalid Ethereum address format'; + } + return true; + }, + }); + + // Wallet selection + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + + // Optional: Remark and Encryption Key + const { remark, encryptionKey } = await promptRemarkAndEncryptionKey(); + + // Build the result object + const baseResult = { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + newBeneficiary, + remark, + encryptionKey, + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + // Add wallet-specific properties + if (encryptedWalletPath) { + return { + ...baseResult, + encryptedWalletPath, + } as TitleEscrowNominateBeneficiaryCommand; + } else if (keyFile) { + return { + ...baseResult, + keyFile, + } as TitleEscrowNominateBeneficiaryCommand; + } else if (key) { + return { + ...baseResult, + key, + } as TitleEscrowNominateBeneficiaryCommand; + } + + // For environment variable case (when all wallet options are undefined) + return baseResult as TitleEscrowNominateBeneficiaryCommand; +}; + +// Nominate the change of owner with the provided inputs +export const nominateChangeOwnerHandler = async (args: TitleEscrowNominateBeneficiaryCommand) => { + try { + info( + `Connecting to the registry ${args.tokenRegistryAddress} and attempting to nominate the change of owner of the transferable record ${args.tokenId} to new owner at ${args.newBeneficiary}`, + ); + warn( + `Please note that if you do not have the correct privileges to the transferable record, then this command will fail.`, + ); + + const transaction = await nominateBeneficiary(args); + + const network = args.network as NetworkCmdName; + displayTransactionPrice(transaction as unknown as TransactionReceiptFees, network); + const { hash: transactionHash } = transaction; + + success( + `Transferable record with hash ${args.tokenId}'s holder has been successfully nominated to new owner with address ${args.newBeneficiary}`, + ); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transactionHash}`, + ); + + return args.tokenRegistryAddress; + } catch (e) { + error(getErrorMessage(e)); + } +}; + +/** + * Nominates a new beneficiary for a transferable record. + * This creates a nomination that must be endorsed to complete the beneficiary transfer. + * + * @param tokenRegistryAddress - The address of the token registry contract + * @param tokenId - The unique identifier of the token + * @param newBeneficiary - The address of the new beneficiary to nominate + * @param remark - Optional remark/comment to attach to the transaction + * @param encryptionKey - Optional encryption key for encrypting the remark + * @param network - The blockchain network to execute the transaction on + * @param dryRun - If true, simulates the transaction without executing it + * @param rest - Additional parameters (e.g., wallet configuration, gas settings) + * @returns Promise resolving to the transaction receipt + * @throws Error if provider is required but not available, or if transaction receipt is null + */ +export const nominateBeneficiary = async ({ + tokenRegistryAddress, + tokenId, + newBeneficiary, + remark, + encryptionKey, + network, + dryRun, + ...rest +}: TitleEscrowNominateBeneficiaryCommand): Promise => { + // Initialize wallet/signer for the transaction + const wallet = await getWalletOrSigner({ network, ...rest }); + + // Get the network ID for the specified network + const networkId = getSupportedNetwork(network).networkId; + + // Connect to the title escrow contract for this token + const titleEscrow = await connectToTitleEscrow({ + tokenId, + address: tokenRegistryAddress, + wallet, + }); + + // Validate and encrypt the remark if encryption key is provided + const encryptedRemark = validateAndEncryptRemark(remark, encryptionKey); + + // Validate the beneficiary nomination + await validateNominateBeneficiary({ beneficiaryNominee: newBeneficiary, titleEscrow }); + // Dry run mode: estimate gas and exit without executing the transaction + if (dryRun) { + await validateNominateBeneficiary({ beneficiaryNominee: newBeneficiary, titleEscrow }); + await dryRunMode({ + estimatedGas: await titleEscrow.estimateGas.nominate(newBeneficiary, encryptedRemark), + network, + }); + process.exit(0); + } + let transaction; + + // Execute transaction with appropriate gas settings based on network capabilities + if (canEstimateGasPrice(network)) { + // Ensure provider is available for gas estimation + if (!wallet.provider) { + throw new Error('Provider is required for gas estimation'); + } + + // Get current gas fees from the network + const gasFees = await getGasFees({ provider: wallet.provider, ...rest }); + + // Execute nomination with EIP-1559 gas parameters + transaction = await nominateImpl( + { tokenRegistryAddress, tokenId }, + wallet, + { remarks: remark, newBeneficiaryAddress: newBeneficiary }, + { + chainId: networkId as unknown as CHAIN_ID, + maxFeePerGas: gasFees.maxFeePerGas?.toString(), + maxPriorityFeePerGas: gasFees.maxPriorityFeePerGas?.toString(), + id: encryptionKey, + }, + ); + } else { + // Execute nomination without gas estimation (for networks that don't support it) + transaction = await nominateImpl( + { tokenRegistryAddress, tokenId }, + wallet, + { remarks: remark, newBeneficiaryAddress: newBeneficiary }, + { + chainId: networkId as unknown as CHAIN_ID, + id: encryptionKey, + }, + ); + } + + // Wait for transaction to be mined + signale.await(`Waiting for transaction ${transaction.hash} to be mined`); + const receipt = await transaction.wait(); + + // Validate receipt exists + if (!receipt) { + throw new Error('Transaction receipt is null'); + } + + return receipt as unknown as TransactionReceipt; +}; diff --git a/src/commands/token-registry/index.ts b/src/commands/token-registry/index.ts new file mode 100644 index 0000000..6ed159c --- /dev/null +++ b/src/commands/token-registry/index.ts @@ -0,0 +1,10 @@ +import { Argv } from 'yargs'; + +export const command = 'token-registry '; + +export const describe = 'Invoke a function over a token registry smart contract on the blockchain'; + +export const builder = (yargs: Argv): Argv => + yargs.commandDir(__dirname, { extensions: ['ts', 'js'] }); + +export const handler = (): void => {}; diff --git a/src/commands/token-registry/mint.ts b/src/commands/token-registry/mint.ts index 1a6fea8..686948e 100644 --- a/src/commands/token-registry/mint.ts +++ b/src/commands/token-registry/mint.ts @@ -1,3 +1,210 @@ +import { input } from '@inquirer/prompts'; +import signale, { error, info, success } from 'signale'; +import { TokenRegistryMintCommand } from '../../types'; +import { + addAddressPrefix, + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + promptRemarkAndEncryptionKey, + promptNetworkSelection, + promptWalletSelection, + getWalletOrSigner, + canEstimateGasPrice, + getGasFees, +} from '../../utils'; +import { BigNumberish, TransactionReceipt } from 'ethers'; +import { mint } from '@trustvc/trustvc'; + export const command = 'mint'; + export const describe = 'Mint a hash to a token registry deployed on the blockchain'; -export { mintHandler as handler } from '../token-registry/token-registry'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + + await mintToken(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +// Prompt user for all required inputs +export const promptForInputs = async (): Promise => { + // Network selection + const network = await promptNetworkSelection(); + + // Token Registry Address + const address = await input({ + message: 'Enter the token registry contract address:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Token registry address is required'; + } + if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { + return 'Invalid Ethereum address format'; + } + return true; + }, + }); + + // Token ID (Document Hash) + const tokenId = await input({ + message: 'Enter the document hash (tokenId) to mint:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Token ID is required'; + } + return true; + }, + }); + + // Beneficiary Address + const beneficiary = await input({ + message: 'Enter the beneficiary address (initial recipient):', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Beneficiary address is required'; + } + if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { + return 'Invalid Ethereum address format'; + } + return true; + }, + }); + + // Holder Address + const holder = await input({ + message: 'Enter the holder address (initial holder):', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Holder address is required'; + } + if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { + return 'Invalid Ethereum address format'; + } + return true; + }, + }); + + // Wallet selection + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + + // Optional: Remark and Encryption Key + const { remark, encryptionKey } = await promptRemarkAndEncryptionKey(); + + // Build the result object with proper typing + const baseResult = { + network, + address, + tokenId, + beneficiary, + holder, + remark, + encryptionKey, + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + // Add wallet-specific properties based on selected wallet type + if (encryptedWalletPath) { + return { + ...baseResult, + encryptedWalletPath, + } as TokenRegistryMintCommand; + } else if (keyFile) { + return { + ...baseResult, + keyFile, + } as TokenRegistryMintCommand; + } else if (key) { + return { + ...baseResult, + key, + } as TokenRegistryMintCommand; + } + + // For environment variable case (when all wallet options are undefined) + return baseResult as TokenRegistryMintCommand; +}; + +// Mint the token with the provided inputs +export const mintToken = async (args: TokenRegistryMintCommand) => { + try { + info( + `Issuing ${args.tokenId} to the initial recipient ${args.beneficiary} and initial holder ${args.holder} in the registry ${args.address}`, + ); + + const transaction = await mintToTokenRegistry({ + ...args, + tokenId: addAddressPrefix(args.tokenId), + }); + + const network = args.network as NetworkCmdName; + + displayTransactionPrice(transaction as any, network); + const { hash: transactionHash } = transaction; + + success( + `Token with hash ${args.tokenId} has been minted on ${args.address} with the initial recipient being ${args.beneficiary} and initial holder ${args.holder}`, + ); + info( + `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transactionHash}`, + ); + + return args.address; + } catch (e) { + error(getErrorMessage(e)); + } +}; + +const mintToTokenRegistry = async ({ + address, + beneficiary, + holder, + tokenId, + remark, + encryptionKey, + network, + dryRun, + ...rest +}: TokenRegistryMintCommand): Promise => { + const wallet = await getWalletOrSigner({ network, ...rest }); + let transactionOptions: { maxFeePerGas?: BigNumberish; maxPriorityFeePerGas?: BigNumberish } = {}; + + if (dryRun) { + console.log('🔧 Dry run mode is currently undergoing upgrades and will be available soon.'); + process.exit(0); + } + + if (canEstimateGasPrice(network)) { + if (!wallet.provider) { + throw new Error('Provider is required for gas estimation'); + } + const gasFees = await getGasFees({ provider: wallet.provider, ...rest }); + transactionOptions = { + maxFeePerGas: gasFees.maxFeePerGas as BigNumberish, + maxPriorityFeePerGas: gasFees.maxPriorityFeePerGas as BigNumberish, + }; + } + + const transaction = await mint( + { tokenRegistryAddress: address }, + wallet, + { beneficiaryAddress: beneficiary, holderAddress: holder, tokenId, remarks: remark }, + { id: encryptionKey, ...transactionOptions }, + ); + signale.await(`Waiting for transaction ${transaction.hash} to be mined`); + const receipt = (await transaction.wait()) as unknown as TransactionReceipt; + if (!receipt) { + throw new Error('Transaction receipt not found'); + } + return receipt; +}; diff --git a/src/commands/token-registry/token-registry.ts b/src/commands/token-registry/token-registry.ts deleted file mode 100644 index 59eda53..0000000 --- a/src/commands/token-registry/token-registry.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { TransactionReceipt } from '@ethersproject/providers'; -import { input, select } from '@inquirer/prompts'; -import { mint } from '@trustvc/trustvc'; -import { BigNumber } from 'ethers'; -import { error, info, success } from 'signale'; -import { Argv } from 'yargs'; -import { TokenRegistryMintCommand } from '../../types'; -import { - addAddressPrefix, - canEstimateGasPrice, - displayTransactionPrice, - getErrorMessage, - getEtherscanAddress, - getGasFees, - NetworkCmdName, -} from '../../utils'; -import { getWalletOrSigner } from '../../utils/wallet'; - -export const command = 'token-registry '; - -export const describe = 'Invoke a function over a token registry smart contract on the blockchain'; - -export const builder = (yargs: Argv): Argv => - yargs.command({ - command: 'mint', - describe: 'Mint a hash to a token registry deployed on the blockchain', - handler: mintHandler, - }); - -export const handler = (): void => { - error('Invalid or missing method. Available methods: mint'); - process.exit(1); -}; - -export const mintHandler = async (): Promise => { - try { - const answers = await promptForInputs(); - if (!answers) return; - - await mintToken(answers); - } catch (err: unknown) { - error(err instanceof Error ? err.message : String(err)); - } -}; - -// Prompt user for all required inputs -export const promptForInputs = async (): Promise => { - // Network selection - const network = await select({ - message: 'Select the network:', - choices: [ - { name: 'Local', value: NetworkCmdName.Local }, - { name: 'Ethereum Mainnet', value: NetworkCmdName.Mainnet }, - { name: 'Sepolia Testnet', value: NetworkCmdName.Sepolia }, - { name: 'Polygon Mainnet', value: NetworkCmdName.Matic }, - { name: 'Polygon Amoy Testnet', value: NetworkCmdName.Amoy }, - { name: 'XDC Network', value: NetworkCmdName.XDC }, - { name: 'XDC Apothem Testnet', value: NetworkCmdName.XDCApothem }, - { name: 'Stability Testnet', value: NetworkCmdName.StabilityTestnet }, - { name: 'Stability Mainnet', value: NetworkCmdName.Stability }, - { name: 'Astron', value: NetworkCmdName.Astron }, - { name: 'Astron Testnet', value: NetworkCmdName.AstronTestnet }, - ], - default: NetworkCmdName.Sepolia, - }); - - // Token Registry Address - const address = await input({ - message: 'Enter the token registry contract address:', - required: true, - validate: (value: string) => { - if (!value || value.trim() === '') { - return 'Token registry address is required'; - } - if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { - return 'Invalid Ethereum address format'; - } - return true; - }, - }); - - // Token ID (Document Hash) - const tokenId = await input({ - message: 'Enter the document hash (tokenId) to mint:', - required: true, - validate: (value: string) => { - if (!value || value.trim() === '') { - return 'Token ID is required'; - } - return true; - }, - }); - - // Beneficiary Address - const beneficiary = await input({ - message: 'Enter the beneficiary address (initial recipient):', - required: true, - validate: (value: string) => { - if (!value || value.trim() === '') { - return 'Beneficiary address is required'; - } - if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { - return 'Invalid Ethereum address format'; - } - return true; - }, - }); - - // Holder Address - const holder = await input({ - message: 'Enter the holder address (initial holder):', - required: true, - validate: (value: string) => { - if (!value || value.trim() === '') { - return 'Holder address is required'; - } - if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { - return 'Invalid Ethereum address format'; - } - return true; - }, - }); - - // Wallet option - const walletOption = await select({ - message: 'Select wallet/private key option:', - choices: [ - { - name: 'Encrypted wallet file (recommended)', - value: 'encryptedWallet', - description: 'Path to an encrypted wallet JSON file', - }, - { - name: 'Environment variable (OA_PRIVATE_KEY)', - value: 'envVariable', - description: 'Use private key from OA_PRIVATE_KEY environment variable', - }, - { - name: 'Private key file', - value: 'keyFile', - description: 'Path to a file containing the private key', - }, - { - name: 'Private key directly', - value: 'keyDirect', - description: 'Provide private key directly (will be stored in bash history)', - }, - ], - default: 'encryptedWallet', - }); - - let encryptedWalletPath: string | undefined; - let key: string | undefined; - let keyFile: string | undefined; - - if (walletOption === 'encryptedWallet') { - encryptedWalletPath = await input({ - message: 'Enter the path to your encrypted wallet JSON file:', - default: './wallet.json', - required: true, - }); - } else if (walletOption === 'envVariable') { - // Check if OA_PRIVATE_KEY is set - if (!process.env.OA_PRIVATE_KEY) { - throw new Error( - 'OA_PRIVATE_KEY environment variable is not set. Please set it or choose another option.', - ); - } - info('Using private key from OA_PRIVATE_KEY environment variable'); - // The key will be picked up automatically by getPrivateKey in wallet.ts - } else if (walletOption === 'keyFile') { - keyFile = await input({ - message: 'Enter the path to your private key file:', - required: true, - }); - } else if (walletOption === 'keyDirect') { - key = await input({ - message: 'Enter your private key:', - required: true, - }); - } - - // Optional: Remark - const remark = await input({ - message: 'Enter a remark for the minting (optional):', - required: false, - }); - - // Optional: Encryption Key (only if remark is provided) - let encryptionKey: string | undefined; - if (remark && remark.trim() !== '') { - encryptionKey = await input({ - message: 'Enter an encryption key for the document (optional):', - required: false, - }); - } - - // Build the result object with proper typing - const baseResult = { - network, - address, - tokenId, - beneficiary, - holder, - remark: remark || undefined, - encryptionKey: encryptionKey || undefined, - dryRun: false, - maxPriorityFeePerGasScale: 1, - }; - - // Add wallet-specific properties based on selected wallet type - if (encryptedWalletPath) { - return { - ...baseResult, - encryptedWalletPath, - } as TokenRegistryMintCommand; - } else if (keyFile) { - return { - ...baseResult, - keyFile, - } as TokenRegistryMintCommand; - } else if (key) { - return { - ...baseResult, - key, - } as TokenRegistryMintCommand; - } else if (walletOption === 'envVariable') { - // For environment variable, return base result without key/keyFile - // The getPrivateKey function will pick it up from process.env.OA_PRIVATE_KEY - return baseResult as TokenRegistryMintCommand; - } - - throw new Error('No wallet option selected'); -}; - -// Mint the token with the provided inputs -export const mintToken = async (args: TokenRegistryMintCommand) => { - try { - info( - `Issuing ${args.tokenId} to the initial recipient ${args.beneficiary} and initial holder ${args.holder} in the registry ${args.address}`, - ); - - // Execute the minting transaction - const transaction = await executeMint(args); - - const network = args.network as NetworkCmdName; - displayTransactionPrice(transaction, network); - const { transactionHash } = transaction; - - success( - `Token with hash ${args.tokenId} has been minted on ${args.address} with the initial recipient being ${args.beneficiary} and initial holder ${args.holder}`, - ); - info( - `Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transactionHash}`, - ); - - return args.address; - } catch (e) { - error(getErrorMessage(e)); - } -}; - -// Execute the blockchain minting transaction -const executeMint = async ({ - address, - beneficiary, - holder, - tokenId, - remark, - encryptionKey, - network, - dryRun, - ...rest -}: TokenRegistryMintCommand): Promise => { - const wallet = await getWalletOrSigner({ network, ...rest }); - let transactionOptions: { maxFeePerGas?: BigNumber; maxPriorityFeePerGas?: BigNumber } = {}; - - if (dryRun) { - console.log('🔧 Dry run mode is currently undergoing upgrades and will be available soon.'); - process.exit(0); - } - - if (canEstimateGasPrice(network)) { - const gasFees = await getGasFees({ provider: wallet.provider, ...rest }); - transactionOptions = { - maxFeePerGas: gasFees.maxFeePerGas as BigNumber, - maxPriorityFeePerGas: gasFees.maxPriorityFeePerGas as BigNumber, - }; - } - - const transaction = await mint( - { tokenRegistryAddress: address }, - wallet, - { - beneficiaryAddress: beneficiary, - holderAddress: holder, - tokenId: addAddressPrefix(tokenId), - remarks: remark, - }, - { id: encryptionKey, ...transactionOptions }, - ); - info(`Waiting for transaction ${transaction.hash} to be mined`); - return transaction.wait(); -}; diff --git a/src/types.ts b/src/types.ts index a0cb233..1986c47 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,3 +45,23 @@ export type TokenRegistryMintCommand = NetworkOption & remark?: string; encryptionKey?: string; }; + +export type BaseTitleEscrowCommand = NetworkAndWalletSignerOption & + GasOption & { + tokenRegistryAddress: string; + tokenId: string; + remark?: string; + encryptionKey?: string; + }; +export type TitleEscrowTransferHolderCommand = BaseTitleEscrowCommand & { + newHolder: string; +}; + +export type TitleEscrowEndorseTransferOfOwnersCommand = BaseTitleEscrowCommand & { + newHolder: string; + newOwner: string; +}; + +export type TitleEscrowNominateBeneficiaryCommand = BaseTitleEscrowCommand & { + newBeneficiary: string; +}; diff --git a/src/utils/cli-options.ts b/src/utils/cli-options.ts index 716de4e..6406d36 100644 --- a/src/utils/cli-options.ts +++ b/src/utils/cli-options.ts @@ -1,5 +1,7 @@ +import { input, select } from '@inquirer/prompts'; +import { info } from 'signale'; import { Argv } from 'yargs'; -import { supportedNetwork } from './networks'; +import { NetworkCmdName, supportedNetwork } from './networks'; export interface NetworkOption { network: string; @@ -146,3 +148,135 @@ export const withNetworkAndWalletSignerOption = (yargs: Argv): Argv => withNetworkOption( withRpcUrlOption(withAwsKmsSignerOption(withWalletOption(withPrivateKeyOption(yargs)))), ); + +/** + * Prompts for network selection with all available networks. + * @returns The selected network as NetworkCmdName + */ +export const promptNetworkSelection = async (): Promise => { + const network = await select({ + message: 'Select the network:', + choices: [ + { name: 'Local', value: NetworkCmdName.Local }, + { name: 'Ethereum Mainnet', value: NetworkCmdName.Mainnet }, + { name: 'Sepolia Testnet', value: NetworkCmdName.Sepolia }, + { name: 'Polygon Mainnet', value: NetworkCmdName.Matic }, + { name: 'Polygon Amoy Testnet', value: NetworkCmdName.Amoy }, + { name: 'XDC Network', value: NetworkCmdName.XDC }, + { name: 'XDC Apothem Testnet', value: NetworkCmdName.XDCApothem }, + { name: 'Stability Testnet', value: NetworkCmdName.StabilityTestnet }, + { name: 'Stability Mainnet', value: NetworkCmdName.Stability }, + { name: 'Astron', value: NetworkCmdName.Astron }, + { name: 'Astron Testnet', value: NetworkCmdName.AstronTestnet }, + ], + default: NetworkCmdName.Sepolia, + }); + + return network; +}; + +/** + * Prompts for wallet/private key selection and returns the selected credentials. + * @returns An object containing encryptedWalletPath, key, or keyFile based on user selection + */ +export const promptWalletSelection = async (): Promise<{ + encryptedWalletPath?: string; + key?: string; + keyFile?: string; +}> => { + const walletOption = await select({ + message: 'Select wallet/private key option:', + choices: [ + { + name: 'Encrypted wallet file (recommended)', + value: 'encryptedWallet', + description: 'Path to an encrypted wallet JSON file', + }, + { + name: 'Environment variable (OA_PRIVATE_KEY)', + value: 'envVariable', + description: 'Use private key from OA_PRIVATE_KEY environment variable', + }, + { + name: 'Private key file', + value: 'keyFile', + description: 'Path to a file containing the private key', + }, + { + name: 'Private key directly', + value: 'keyDirect', + description: 'Provide private key directly (will be stored in bash history)', + }, + ], + default: 'encryptedWallet', + }); + + let encryptedWalletPath: string | undefined; + let key: string | undefined; + let keyFile: string | undefined; + + if (walletOption === 'encryptedWallet') { + encryptedWalletPath = await input({ + message: 'Enter the path to your encrypted wallet JSON file:', + default: './wallet.json', + required: true, + }); + } else if (walletOption === 'envVariable') { + if (!process.env.OA_PRIVATE_KEY) { + throw new Error( + 'OA_PRIVATE_KEY environment variable is not set. Please set it or choose another option.', + ); + } + info('Using private key from OA_PRIVATE_KEY environment variable'); + // Return empty object - the key will be picked up from environment variable + } else if (walletOption === 'keyFile') { + keyFile = await input({ + message: 'Enter the path to your private key file:', + required: true, + }); + } else if (walletOption === 'keyDirect') { + key = await input({ + message: 'Enter your private key:', + required: true, + }); + } + + return { + encryptedWalletPath, + key, + keyFile, + }; +}; + +/** + * Prompts for optional remark and encryption key. + * If a remark is provided, also prompts for the document ID to use as encryption key. + * @returns An object containing the remark and encryptionKey (both optional) + */ +export const promptRemarkAndEncryptionKey = async (): Promise<{ + remark?: string; + encryptionKey?: string; +}> => { + // Optional: Remark + const remark = await input({ + message: 'Enter a remark (optional):', + required: false, + }); + + // Optional: Encryption Key (only if remark is provided) + let encryptionKey: string | undefined; + if (remark && remark.trim() !== '') { + info( + 'This document ID will be used to encrypt the Document. You can find it inside your document for example: "urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692"', + ); + encryptionKey = await input({ + message: 'Enter the document Id :', + required: false, + }); + } + + return { + remark: remark || undefined, + encryptionKey: encryptionKey || undefined, + }; +}; diff --git a/src/utils/dryRun.ts b/src/utils/dryRun.ts new file mode 100644 index 0000000..01ef4ac --- /dev/null +++ b/src/utils/dryRun.ts @@ -0,0 +1,112 @@ +import { formatUnits, TransactionRequest } from 'ethers'; +import { getSpotRate, green, highlight, red } from '../utils'; +import { BigNumberish } from 'ethers'; +import { convertWeiFiatDollars } from '../utils'; +import { getSupportedNetwork } from '../utils'; + +export interface FeeDataType { + maxFeePerGas: BigNumberish | null; + maxPriorityFeePerGas: BigNumberish | null; +} + +export const dryRunMode = async ({ + transaction, + estimatedGas, + network, +}: { + network: string; + transaction?: TransactionRequest; + estimatedGas?: BigNumberish; +}): Promise => { + // estimated gas or a transaction must be provided, if a transaction is provided let's estimate the gas automatically + // the transaction is run on the provided network + const provider = getSupportedNetwork(network ?? 'mainnet').provider(); + let _estimatedGas = estimatedGas; + if (!estimatedGas && transaction) { + _estimatedGas = await provider.estimateGas(transaction); + } + if (!_estimatedGas) { + throw new Error('Please provide estimatedGas or transaction'); + } + + const blockNumber = await provider.getBlockNumber(); + const feeData = await provider.getFeeData(); + const zero = 0n; + const { maxFeePerGas, gasPrice, maxPriorityFeePerGas } = { + maxFeePerGas: feeData.maxFeePerGas ?? zero, + gasPrice: feeData.gasPrice ?? zero, + maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? zero, + }; + + const gasCost = gasPrice * BigInt(_estimatedGas.toString()); + const maxCost = maxFeePerGas * BigInt(_estimatedGas.toString()); + const maxPriorityCost = maxPriorityFeePerGas * BigInt(_estimatedGas.toString()); + + const spotRateETHUSD = await getSpotRate('ETH', 'USD'); + const spotRateETHSGD = await getSpotRate('ETH', 'SGD'); + const spotRateMATICUSD = await getSpotRate('POL', 'USD'); + const spotRateMATICSGD = await getSpotRate('POL', 'SGD'); + const estimatedFeeUSD = convertWeiFiatDollars(gasCost, spotRateETHUSD); + + console.log( + red( + '\n\n/!\\ Welcome to the fee table. Please read the information below to understand the transaction fee', + ), + ); + console.log( + `\nThe table below display information about the cost of the transaction on the mainnet network, depending on the gas price selected. Multiple modes are displayed to help you better help you to choose a gas price depending on your needs:\n`, + ); + + console.log(green('Information about the network:')); + console.log(`Costs based on block number: ${highlight(blockNumber)}`); + console.table({ + current: { + 'block number': blockNumber, + 'gas price (gwei)': formatUnits(gasPrice, 'gwei'), + 'max priority fee per gas (gwei)': formatUnits(maxPriorityFeePerGas, 'gwei'), + 'max fee per gas (gwei)': formatUnits(maxFeePerGas, 'gwei'), + }, + }); + + console.log(green('Information about the transaction:')); + + console.log( + `Estimated gas required: ${highlight(_estimatedGas.toString())} gas, which will cost approximately ${highlight( + `US$${estimatedFeeUSD}`, + )} based on prevailing gas price.`, + ); + + console.table({ + GWEI: { + 'gas cost': formatUnits(gasCost, 'gwei'), + 'priority fee price': formatUnits(maxPriorityCost, 'gwei'), + 'max fee price': formatUnits(maxCost, 'gwei'), + }, + ETH: { + 'gas cost': formatUnits(gasCost, 'ether'), + 'priority fee price': formatUnits(maxPriorityCost, 'ether'), + 'max fee price': formatUnits(maxCost, 'ether'), + }, + ETHUSD: { + 'gas cost': convertWeiFiatDollars(gasCost, spotRateETHUSD), + 'priority fee price': convertWeiFiatDollars(maxPriorityCost, spotRateETHUSD), + 'max fee price': convertWeiFiatDollars(maxCost, spotRateETHUSD), + }, + ETHSGD: { + 'gas cost': convertWeiFiatDollars(gasCost, spotRateETHSGD), + 'priority fee price': convertWeiFiatDollars(maxPriorityCost, spotRateETHSGD), + 'max fee price': convertWeiFiatDollars(maxCost, spotRateETHSGD), + }, + MATICUSD: { + 'gas cost': convertWeiFiatDollars(gasCost, spotRateMATICUSD), + 'priority fee price': convertWeiFiatDollars(maxPriorityCost, spotRateMATICUSD), + 'max fee price': convertWeiFiatDollars(maxCost, spotRateMATICUSD), + }, + MATICSGD: { + 'gas cost': convertWeiFiatDollars(gasCost, spotRateMATICSGD), + 'priority fee price': convertWeiFiatDollars(maxPriorityCost, spotRateMATICSGD), + 'max fee price': convertWeiFiatDollars(maxCost, spotRateMATICSGD), + }, + }); + console.log(red('Please read the information above to understand the table')); +}; diff --git a/src/utils/gas-station.ts b/src/utils/gas-station.ts index d8efb3a..9cf2e3b 100644 --- a/src/utils/gas-station.ts +++ b/src/utils/gas-station.ts @@ -1,12 +1,12 @@ -import { BigNumber, ethers } from 'ethers'; +import { BigNumberish, ethers } from 'ethers'; import fetch from 'node-fetch'; export type GasStationFunction = ( gasStationUrl: string, ) => () => Promise; export type GasStationFeeData = { - maxPriorityFeePerGas: BigNumber | null; - maxFeePerGas: BigNumber | null; + maxPriorityFeePerGas: BigNumberish | null; + maxFeePerGas: BigNumberish | null; }; export const gasStation: GasStationFunction = @@ -25,7 +25,7 @@ export const gasStation: GasStationFunction = } }; -const safeParseUnits = (_value: number | string, decimals: number): BigNumber => { +const safeParseUnits = (_value: number | string, decimals: number): BigNumberish => { const value = String(_value); if (!value.match(/^[0-9.]+$/)) { throw new Error(`invalid gwei value: ${_value}`); @@ -49,8 +49,9 @@ const safeParseUnits = (_value: number | string, decimals: number): BigNumber => // Too many decimals and some non-zero ending, take the ceiling if (comps[1].length > 9 && !comps[1].substring(9).match(/^0+$/)) { - comps[1] = BigNumber.from(comps[1].substring(0, 9)).add(BigNumber.from(1)).toString(); + const fractionalPart = BigInt(comps[1].substring(0, 9)) + BigInt(1); + comps[1] = fractionalPart.toString(); } - return ethers.utils.parseUnits(`${comps[0]}.${comps[1]}`, decimals); + return ethers.parseUnits(`${comps[0]}.${comps[1]}`, decimals); }; diff --git a/src/utils/index.ts b/src/utils/index.ts index 48a046e..683bcb1 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -21,3 +21,6 @@ export * from './progress'; // Wallet and Signer export * from './wallet'; + +// Dry Run +export * from './dryRun'; diff --git a/src/utils/networks.ts b/src/utils/networks.ts index 0f78045..5071ded 100644 --- a/src/utils/networks.ts +++ b/src/utils/networks.ts @@ -1,4 +1,4 @@ -import { providers } from 'ethers'; +import { Provider, JsonRpcProvider, InfuraProvider } from 'ethers'; import type { GasStationFunction } from './gas-station'; import { gasStation } from './gas-station'; @@ -6,7 +6,7 @@ export type networkCurrency = 'ETH' | 'MATIC' | 'XDC' | 'FREE' | 'ASTRON'; type SupportedNetwork = { explorer: string; - provider: () => providers.Provider; + provider: () => Provider; networkId: number; networkName: (typeof NetworkCmdName)[keyof typeof NetworkCmdName]; currency: networkCurrency; @@ -28,14 +28,14 @@ export enum NetworkCmdName { } const defaultInfuraProvider = - (networkName: string): (() => providers.Provider) => + (networkName: string): (() => Provider) => () => - new providers.InfuraProvider(networkName); + new InfuraProvider(networkName); const jsonRpcProvider = - (url: string): (() => providers.Provider) => + (url: string): (() => Provider) => () => - new providers.JsonRpcProvider(url); + new JsonRpcProvider(url); /** * Creates a provider that checks for an environment variable override @@ -46,14 +46,14 @@ const jsonRpcProvider = const getProviderWithEnvOverride = ( networkName: NetworkCmdName, - defaultProvider: () => providers.Provider, - ): (() => providers.Provider) => + defaultProvider: () => Provider, + ): (() => Provider) => () => { const envVarName = `${networkName.toUpperCase()}_RPC`; const customRpcUrl = process.env[envVarName]; if (customRpcUrl) { - return new providers.JsonRpcProvider(customRpcUrl); + return new JsonRpcProvider(customRpcUrl); } return defaultProvider(); @@ -91,7 +91,10 @@ export const supportedNetwork: { }, [NetworkCmdName.Matic]: { explorer: 'https://polygonscan.com', - provider: getProviderWithEnvOverride(NetworkCmdName.Matic, defaultInfuraProvider('matic')), + provider: getProviderWithEnvOverride( + NetworkCmdName.Matic, + jsonRpcProvider('https://sepolia.infura.io/v3/bb46da3f80e040e8ab73c0a9ff365d18'), + ), networkId: 137, networkName: NetworkCmdName.Matic, currency: 'MATIC', diff --git a/src/utils/transaction.ts b/src/utils/transaction.ts index 3d6a592..ae7864e 100644 --- a/src/utils/transaction.ts +++ b/src/utils/transaction.ts @@ -1,5 +1,4 @@ -import { BigNumber, Overrides, constants, utils, ethers } from 'ethers'; -import { Provider } from '@ethersproject/abstract-provider'; +import { BigNumberish, Overrides, Provider, formatEther } from 'ethers'; import { info } from 'signale'; import fetch, { RequestInit } from 'node-fetch'; import { GasPriceScale } from './cli-options'; @@ -13,22 +12,25 @@ import { // TransactionReceipt export interface TransactionReceiptFees { - effectiveGasPrice: BigNumber; - gasUsed: BigNumber; + effectiveGasPrice: BigNumberish; + gasUsed: BigNumberish; + transactionHash?: string; + hash?: string; } export const scaleBigNumber = ( - wei: BigNumber | null | undefined, + wei: BigNumberish | null | undefined, multiplier: number, precision = 2, -): BigNumber => { +): bigint => { if (wei === null || typeof wei === 'undefined') { throw new Error('Wei not specified'); } const padding = Math.pow(10, precision); const newMultiplier = Math.round(padding * multiplier); + const weiBigInt = BigInt(wei.toString()); - const newWei = wei.mul(newMultiplier).div(padding); + const newWei = (weiBigInt * BigInt(newMultiplier)) / BigInt(padding); return newWei; }; @@ -48,10 +50,9 @@ export const getGasFees = async ({ }; }; -export const getFeeData = async ( - provider: ethers.providers.Provider, -): Promise => { - const networkName = getSupportedNetworkNameFromId((await provider.getNetwork()).chainId); +export const getFeeData = async (provider: Provider): Promise => { + const network = await provider.getNetwork(); + const networkName = getSupportedNetworkNameFromId(Number(network.chainId)); const gasStation = getSupportedNetwork(networkName)?.gasStation; const feeData = gasStation && (await gasStation()); @@ -60,10 +61,10 @@ export const getFeeData = async ( }; export const calculateMaxFee = ( - maxFee: BigNumber | null | undefined, - priorityFee: BigNumber | null | undefined, + maxFee: BigNumberish | null | undefined, + priorityFee: BigNumberish | null | undefined, scale: number, -): BigNumber => { +): bigint => { if (maxFee === null || typeof maxFee === 'undefined') { throw new Error('Max Fee not specified'); } @@ -71,11 +72,12 @@ export const calculateMaxFee = ( throw new Error('Priority Fee not specified'); } if (scale === 1) { - return maxFee; + return BigInt(maxFee.toString()); } - - const priorityFeeChange = scaleBigNumber(priorityFee, scale).sub(priorityFee); - return maxFee.add(priorityFeeChange); + const priorityFeeBigInt = BigInt(priorityFee.toString()); + const maxFeeBigInt = BigInt(maxFee.toString()); + const priorityFeeChange = scaleBigNumber(priorityFee, scale) - priorityFeeBigInt; + return maxFeeBigInt + priorityFeeChange; }; export const canEstimateGasPrice = (network: string): boolean => { @@ -105,14 +107,21 @@ export const displayTransactionPrice = async ( ) { return; } + + // Check if gas data is available (effectiveGasPrice or gasPrice) + const gasPrice = (transaction as any).effectiveGasPrice || (transaction as any).gasPrice; + if (!gasPrice || !transaction.gasUsed) { + return; + } + const currency = supportedNetwork[network].currency; - const totalWEI = transaction.effectiveGasPrice.mul(transaction.gasUsed); + const effectiveGasPrice = BigInt(gasPrice.toString()); + const gasUsed = BigInt(transaction.gasUsed.toString()); + const totalWEI = effectiveGasPrice * gasUsed; const spotRate = await getSpotRate(currency, 'USD'); const totalUSD = convertWeiFiatDollars(totalWEI, spotRate); - info( - `Transaction fee of ${utils.formatEther(totalWEI)} ${currency} / ~ ${currency}-USD ${totalUSD}`, - ); + info(`Transaction fee of ${formatEther(totalWEI)} ${currency} / ~ ${currency}-USD ${totalUSD}`); }; export const request = (url: string, options?: RequestInit): Promise => { @@ -136,12 +145,17 @@ export const getSpotRate = async ( }; // Minimally precision of 2 to get precision of 1 cent -export const convertWeiFiatDollars = (cost: BigNumber, spotRate: number, precision = 5): number => { +export const convertWeiFiatDollars = ( + cost: BigNumberish, + spotRate: number, + precision = 5, +): number => { const padding = Math.pow(10, precision); const spotRateCents = Math.ceil(spotRate * padding); // Higher better than lower - const costInWeiFiatCents = cost.mul(BigNumber.from(spotRateCents)); - const costInFiatDollars = costInWeiFiatCents.div(constants.WeiPerEther).div(padding).toNumber(); // Fiat Dollar - const costInFiatCents = - (costInWeiFiatCents.div(constants.WeiPerEther).toNumber() % padding) / padding; /// Fiat Cents + const costBigInt = BigInt(cost.toString()); + const costInWeiFiatCents = costBigInt * BigInt(spotRateCents); + const WeiPerEther = 1000000000000000000n; // 10^18 + const costInFiatDollars = Number(costInWeiFiatCents / WeiPerEther / BigInt(padding)); // Fiat Dollar + const costInFiatCents = Number((costInWeiFiatCents / WeiPerEther) % BigInt(padding)) / padding; /// Fiat Cents return costInFiatDollars + costInFiatCents; }; diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts index de252eb..351ae51 100644 --- a/src/utils/wallet.ts +++ b/src/utils/wallet.ts @@ -1,11 +1,9 @@ import { readFileSync } from 'fs'; import signale from 'signale'; -import { ethers, Signer, Wallet } from 'ethers'; -import { Provider } from '@ethersproject/abstract-provider'; +import { JsonRpcProvider, Signer, Wallet, Provider, HDNodeWallet } from 'ethers'; import { addAddressPrefix } from './formatting'; import { - isAwsKmsSignerOption, isRpcUrlOption, isWalletOption, NetworkOption, @@ -16,7 +14,6 @@ import { import { readFile } from './file-io'; import inquirer from 'inquirer'; import { progress as defaultProgress } from './progress'; -import { AwsKmsSigner } from '@trustvc/trustvc'; import { getSupportedNetwork } from './networks'; const getKeyFromFile = (file?: string): undefined | string => { @@ -48,11 +45,11 @@ export const getWalletOrSigner = async ({ }: WalletOrSignerOption & Partial & Partial & { progress?: (progress: number) => void }): Promise< - Wallet | ConnectedSigner + Wallet | HDNodeWallet | ConnectedSigner > => { // Use custom RPC URL if provided, otherwise use the default network provider const provider = isRpcUrlOption(options) - ? new ethers.providers.JsonRpcProvider(options.rpcUrl) + ? new JsonRpcProvider(options.rpcUrl) : getSupportedNetwork(network ?? 'mainnet').provider(); if (isWalletOption(options)) { const { password } = await inquirer.prompt({ @@ -62,22 +59,25 @@ export const getWalletOrSigner = async ({ }); const file = await readFile(options.encryptedWalletPath); - const wallet = await ethers.Wallet.fromEncryptedJson(file, password, progress); + const wallet = await Wallet.fromEncryptedJson(file, password, progress); signale.info('Wallet successfully decrypted'); - return wallet.connect(provider); - } else if (isAwsKmsSignerOption(options)) { - const kmsCredentials = { - accessKeyId: options.accessKeyId, // credentials for your IAM user with KMS access - secretAccessKey: options.secretAccessKey, // credentials for your IAM user with KMS access - region: options.region, - keyId: options.kmsKeyId, - sessionToken: options.sessionToken, - }; + const connectedWallet = wallet.connect(provider); + return connectedWallet as Wallet | HDNodeWallet; + } + // else if (isAwsKmsSignerOption(options)) { + // const kmsCredentials = { + // accessKeyId: options.accessKeyId, // credentials for your IAM user with KMS access + // secretAccessKey: options.secretAccessKey, // credentials for your IAM user with KMS access + // region: options.region, + // keyId: options.kmsKeyId, + // sessionToken: options.sessionToken, + // }; - const signer = new AwsKmsSigner(kmsCredentials).connect(provider); - if (signer.provider) return signer as ConnectedSigner; - throw new Error('Unable to attach the provider to the kms signer'); - } else { + // const signer = new AwsKmsSigner(kmsCredentials).connect(provider); + // if (signer.provider) return signer as ConnectedSigner; + // throw new Error('Unable to attach the provider to the kms signer'); + // } + else { const privateKey = getPrivateKey(options as any); if (privateKey) { diff --git a/tests/commands/title-escrow/endorseNominatedBeneficiary-astron.test.ts b/tests/commands/title-escrow/endorseNominatedBeneficiary-astron.test.ts new file mode 100644 index 0000000..f0d71b0 --- /dev/null +++ b/tests/commands/title-escrow/endorseNominatedBeneficiary-astron.test.ts @@ -0,0 +1,543 @@ +import { TransactionReceipt } from '@ethersproject/providers'; +import * as prompts from '@inquirer/prompts'; +import { transferBeneficiary as transferBeneficiaryImpl } from '@trustvc/trustvc'; +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { TitleEscrowNominateBeneficiaryCommand } from '../../../src/types'; +import { + endorseNominatedBeneficiary, + endorseTransferOwnerHandler, + handler, + promptForInputs, +} from '../../../src/commands/title-escrow/endorse-transfer-of-owner'; +import { NetworkCmdName } from '../../../src/utils'; +import * as helpers from '../../../src/commands/helpers'; + +vi.mock('@inquirer/prompts'); + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + transferBeneficiary: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/helpers', () => ({ + connectToTitleEscrow: vi.fn().mockResolvedValue({ + beneficiary: vi.fn().mockResolvedValue('0x3333333333333333333333333333333333333333'), + }), + validateNominateBeneficiary: vi.fn(), + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +vi.mock('../../../src/utils/wallet', () => ({ + getWalletOrSigner: vi.fn(), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + displayTransactionPrice: vi.fn(), + canEstimateGasPrice: vi.fn(() => false), + getGasFees: vi.fn(), + }; +}); + +const endorseNominatedBeneficiaryParams: TitleEscrowNominateBeneficiaryCommand = { + tokenId: '0x12345', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + newBeneficiary: '0x1111111111111111111111111111111111111111', + network: 'astron', + maxPriorityFeePerGasScale: 1, + dryRun: false, +}; + +describe('title-escrow/endorse-transfer-owner', () => { + // increase timeout because ethers is throttling + vi.setConfig({ testTimeout: 30_000 }); + vi.spyOn(global, 'fetch').mockImplementation( + vi.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + standard: { + maxPriorityFee: 0, + maxFee: 0, + }, + }), + }), + ) as any, + ); + + describe('promptForInputs', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should return correct answers for valid inputs with encrypted wallet', async () => { + const mockInputs = { + network: NetworkCmdName.Astron, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + remark: 'Test remark', + encryptionKey: 'test-key', + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) // Network selection + .mockResolvedValueOnce('encryptedWallet'); // Wallet option + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) // Token registry address + .mockResolvedValueOnce(mockInputs.tokenId) // Token ID + .mockResolvedValueOnce(mockInputs.newBeneficiary) // New beneficiary + .mockResolvedValueOnce('./wallet.json') // Encrypted wallet path + .mockResolvedValueOnce(mockInputs.remark) // Remark + .mockResolvedValueOnce(mockInputs.encryptionKey); // Encryption key + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.newBeneficiary).toBe(mockInputs.newBeneficiary); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.encryptionKey); + expect(result.dryRun).toBe(false); + expect(result.maxPriorityFeePerGasScale).toBe(1); + }); + + it('should return correct answers for valid inputs with private key file', async () => { + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce('keyFile'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce('./private-key.txt') // keyFile + .mockResolvedValueOnce(''); // Empty remark + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).keyFile).toBe('./private-key.txt'); + expect(result.remark).toBeUndefined(); + expect(result.encryptionKey).toBeUndefined(); + }); + + it('should return correct answers for valid inputs with direct private key', async () => { + const mockInputs = { + network: NetworkCmdName.Astron, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce('keyDirect'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') // key + .mockResolvedValueOnce(''); // Empty remark + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).key).toBe( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + ); + }); + + it('should return correct answers when using environment variable for private key', async () => { + const originalEnv = process.env.OA_PRIVATE_KEY; + process.env.OA_PRIVATE_KEY = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + + const mockInputs = { + network: NetworkCmdName.Astron, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce('envVariable'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce(''); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).key).toBeUndefined(); + expect((result as any).keyFile).toBeUndefined(); + expect((result as any).encryptedWalletPath).toBeUndefined(); + + // Restore original env + if (originalEnv) { + process.env.OA_PRIVATE_KEY = originalEnv; + } else { + delete process.env.OA_PRIVATE_KEY; + } + }); + + it('should throw error when OA_PRIVATE_KEY environment variable is not set', async () => { + const originalEnv = process.env.OA_PRIVATE_KEY; + delete process.env.OA_PRIVATE_KEY; + + (prompts.select as any) + .mockResolvedValueOnce(NetworkCmdName.Astron) + .mockResolvedValueOnce('envVariable'); + + (prompts.input as any) + .mockResolvedValueOnce('0x1234567890123456789012345678901234567890') + .mockResolvedValueOnce('0xabcdef1234567890') + .mockResolvedValueOnce('0x0987654321098765432109876543210987654321'); + + await expect(promptForInputs()).rejects.toThrowError( + 'OA_PRIVATE_KEY environment variable is not set. Please set it or choose another option.', + ); + + // Restore original env + if (originalEnv) { + process.env.OA_PRIVATE_KEY = originalEnv; + } + }); + }); + + describe('endorseTransferOwnerHandler', () => { + let transferBeneficiaryMock: MockedFunction; + let getWalletOrSignerMock: MockedFunction; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import('@trustvc/trustvc'); + transferBeneficiaryMock = trustvcModule.transferBeneficiary as MockedFunction; + + const walletModule = await import('../../../src/utils/wallet'); + getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + + // Setup wallet mock + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + }); + + it('should successfully endorse transfer and display transaction details', async () => { + const mockArgs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash123', + blockNumber: 12345, + blockHash: '0xblockhash', + confirmations: 1, + from: '0xfrom', + to: mockArgs.tokenRegistryAddress, + gasUsed: { toNumber: () => 100000 } as any, + cumulativeGasUsed: { toNumber: () => 100000 } as any, + effectiveGasPrice: { toNumber: () => 1000000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + transferBeneficiaryMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const result = await endorseTransferOwnerHandler(mockArgs); + + expect(transferBeneficiaryMock).toHaveBeenCalledWith( + { tokenRegistryAddress: mockArgs.tokenRegistryAddress, tokenId: mockArgs.tokenId }, + expect.anything(), + expect.objectContaining({ + newBeneficiaryAddress: mockArgs.newBeneficiary, + }), + expect.anything(), + ); + expect(result).toBe(mockArgs.tokenRegistryAddress); + }); + + it('should handle endorsement with remark and encryption key', async () => { + const mockArgs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + remark: 'Important transfer', + encryptionKey: 'secret-key-123', + key: '0xprivatekey', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash456', + blockNumber: 12346, + blockHash: '0xblockhash2', + confirmations: 1, + from: '0xfrom', + to: mockArgs.tokenRegistryAddress, + gasUsed: { toNumber: () => 120000 } as any, + cumulativeGasUsed: { toNumber: () => 120000 } as any, + effectiveGasPrice: { toNumber: () => 1500000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + transferBeneficiaryMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const result = await endorseTransferOwnerHandler(mockArgs); + + expect(transferBeneficiaryMock).toHaveBeenCalledWith( + { tokenRegistryAddress: mockArgs.tokenRegistryAddress, tokenId: mockArgs.tokenId }, + expect.anything(), + expect.objectContaining({ + newBeneficiaryAddress: mockArgs.newBeneficiary, + remarks: mockArgs.remark, + }), + expect.objectContaining({ + id: mockArgs.encryptionKey, + }), + ); + expect(result).toBe(mockArgs.tokenRegistryAddress); + }); + + it('should handle errors during endorsement', async () => { + const mockArgs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const errorMessage = 'Transaction failed: insufficient funds'; + transferBeneficiaryMock.mockRejectedValue(new Error(errorMessage)); + + const result = await endorseTransferOwnerHandler(mockArgs); + + expect(result).toBeUndefined(); + expect(transferBeneficiaryMock).toHaveBeenCalled(); + }); + + it('should handle non-Error exceptions during endorsement', async () => { + const mockArgs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + transferBeneficiaryMock.mockRejectedValue('String error message'); + + const result = await endorseTransferOwnerHandler(mockArgs); + + expect(result).toBeUndefined(); + }); + }); + + describe('handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should successfully execute the complete endorse transfer flow', async () => { + const mockInputs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash', + blockNumber: 12345, + blockHash: '0xblockhash', + confirmations: 1, + from: '0xfrom', + to: mockInputs.tokenRegistryAddress, + gasUsed: { toNumber: () => 100000 } as any, + cumulativeGasUsed: { toNumber: () => 100000 } as any, + effectiveGasPrice: { toNumber: () => 1000000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce('encryptedWallet'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistryAddress) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce(mockInputs.encryptedWalletPath) + .mockResolvedValueOnce(''); + + const trustvcModule = await import('@trustvc/trustvc'); + const transferBeneficiaryMock = trustvcModule.transferBeneficiary as MockedFunction; + transferBeneficiaryMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const walletModule = await import('../../../src/utils/wallet'); + const getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + + const result = await handler(); + + expect(result).toBeUndefined(); + }); + + it('should handle errors in handler', async () => { + const errorMessage = 'Prompt error'; + (prompts.select as any).mockRejectedValue(new Error(errorMessage)); + + await handler(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + + it('should handle non-Error exceptions in handler', async () => { + const errorMessage = 'String error'; + (prompts.select as any).mockRejectedValue(errorMessage); + + await handler(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + }); + + describe('endorseNominatedBeneficiary', () => { + beforeEach(() => { + delete process.env.OA_PRIVATE_KEY; + vi.mocked(transferBeneficiaryImpl).mockResolvedValue({ + hash: 'hash', + wait: () => Promise.resolve({ transactionHash: 'transactionHash' }), + } as any); + + // Re-setup the validateNominateBeneficiary mock + vi.mocked(helpers.validateNominateBeneficiary).mockImplementation( + async ({ beneficiaryNominee }) => { + if (beneficiaryNominee === '0x2222222222222222222222222222222222222222') { + throw new Error( + 'new beneficiary address is the same as the current beneficiary address', + ); + } + }, + ); + }); + + it('should pass in the correct params and call the following procedures to invoke an endorsement of transfer of owner of a transferable record', async () => { + const privateKey = '0000000000000000000000000000000000000000000000000000000000000001'; + await endorseNominatedBeneficiary({ + ...endorseNominatedBeneficiaryParams, + key: privateKey, + }); + + expect(transferBeneficiaryImpl).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if nominee is the owner address', async () => { + const privateKey = '0000000000000000000000000000000000000000000000000000000000000001'; + await expect( + endorseNominatedBeneficiary({ + ...endorseNominatedBeneficiaryParams, + newBeneficiary: '0x2222222222222222222222222222222222222222', + key: privateKey, + }), + ).rejects.toThrow(`new beneficiary address is the same as the current beneficiary address`); + }); + }); +}); diff --git a/tests/commands/title-escrow/endorseNominatedBeneficiary-astrontestnet.test.ts b/tests/commands/title-escrow/endorseNominatedBeneficiary-astrontestnet.test.ts new file mode 100644 index 0000000..edf3b66 --- /dev/null +++ b/tests/commands/title-escrow/endorseNominatedBeneficiary-astrontestnet.test.ts @@ -0,0 +1,82 @@ +import { transferBeneficiary as transferBeneficiaryImpl } from '@trustvc/trustvc'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TitleEscrowNominateBeneficiaryCommand } from '../../../src/types'; +import { endorseNominatedBeneficiary } from '../../../src/commands/title-escrow/endorse-transfer-of-owner'; + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + transferBeneficiary: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/helpers', () => ({ + connectToTitleEscrow: vi.fn().mockResolvedValue({ + beneficiary: vi.fn().mockResolvedValue('0x3333333333333333333333333333333333333333'), + }), + validateNominateBeneficiary: vi.fn().mockImplementation(async ({ beneficiaryNominee }) => { + if (beneficiaryNominee === '0x2222222222222222222222222222222222222222') { + throw new Error('new beneficiary address is the same as the current beneficiary address'); + } + }), + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +const endorseNominatedBeneficiaryParams: TitleEscrowNominateBeneficiaryCommand = { + tokenId: '0x12345', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + newBeneficiary: '0x1111111111111111111111111111111111111111', + network: 'astrontestnet', + maxPriorityFeePerGasScale: 1, + dryRun: false, +}; + +describe('title-escrow', () => { + // increase timeout because ethers is throttling + vi.setConfig({ testTimeout: 30_000 }); + vi.spyOn(global, 'fetch').mockImplementation( + vi.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + standard: { + maxPriorityFee: 0, + maxFee: 0, + }, + }), + }), + ) as any, + ); + + describe('endorse transfer of owner of transferable record', () => { + beforeEach(() => { + delete process.env.OA_PRIVATE_KEY; + vi.mocked(transferBeneficiaryImpl).mockResolvedValue({ + hash: 'hash', + wait: () => Promise.resolve({ transactionHash: 'transactionHash' }), + } as any); + }); + + it('should pass in the correct params and call the following procedures to invoke an endorsement of transfer of owner of a transferable record', async () => { + const privateKey = '0000000000000000000000000000000000000000000000000000000000000001'; + await endorseNominatedBeneficiary({ + ...endorseNominatedBeneficiaryParams, + key: privateKey, + }); + + expect(transferBeneficiaryImpl).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if nominee is the owner address', async () => { + const privateKey = '0000000000000000000000000000000000000000000000000000000000000001'; + await expect( + endorseNominatedBeneficiary({ + ...endorseNominatedBeneficiaryParams, + newBeneficiary: '0x2222222222222222222222222222222222222222', + key: privateKey, + }), + ).rejects.toThrow(`new beneficiary address is the same as the current beneficiary address`); + }); + }); +}); diff --git a/tests/commands/title-escrow/endorseNominatedBeneficiary.test.ts b/tests/commands/title-escrow/endorseNominatedBeneficiary.test.ts new file mode 100644 index 0000000..c5d3094 --- /dev/null +++ b/tests/commands/title-escrow/endorseNominatedBeneficiary.test.ts @@ -0,0 +1,532 @@ +import { TransactionReceipt } from '@ethersproject/providers'; +import * as prompts from '@inquirer/prompts'; +import { transferBeneficiary as transferBeneficiaryImpl } from "@trustvc/trustvc"; +import { beforeEach, describe, expect, it, vi, MockedFunction } from "vitest"; +import { TitleEscrowNominateBeneficiaryCommand } from "../../../src/types"; +import { + endorseNominatedBeneficiary, + endorseTransferOwnerHandler, + handler, + promptForInputs, +} from "../../../src/commands/title-escrow/endorse-transfer-of-owner"; +import { NetworkCmdName } from '../../../src/utils'; + +vi.mock('@inquirer/prompts'); + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock("@trustvc/trustvc", async () => { + const actual = await vi.importActual("@trustvc/trustvc"); + return { + ...actual, + transferBeneficiary: vi.fn(), + }; +}); + +vi.mock("../../../src/commands/helpers", () => ({ + connectToTitleEscrow: vi.fn().mockResolvedValue({ + beneficiary: vi.fn().mockResolvedValue("0x3333333333333333333333333333333333333333"), + }), + validateNominateBeneficiary: vi.fn().mockImplementation(async ({ beneficiaryNominee }) => { + if (beneficiaryNominee === "0x2222222222222222222222222222222222222222") { + throw new Error("new beneficiary address is the same as the current beneficiary address"); + } + }), + validateAndEncryptRemark: vi.fn().mockReturnValue("encrypted-remark"), +})); + +vi.mock('../../../src/utils/wallet', () => ({ + getWalletOrSigner: vi.fn(), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + displayTransactionPrice: vi.fn(), + canEstimateGasPrice: vi.fn(() => false), + getGasFees: vi.fn(), + }; +}); + +const endorseNominatedBeneficiaryParams: TitleEscrowNominateBeneficiaryCommand = { + tokenId: "0x12345", + remark: "remark", + encryptionKey: "1234", + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + newBeneficiary: "0x1111111111111111111111111111111111111111", + network: "sepolia", + maxPriorityFeePerGasScale: 1, + dryRun: false, +}; + +describe('title-escrow/endorse-transfer-owner', () => { + vi.setConfig({ testTimeout: 30_000 }); + vi.spyOn(global, 'fetch').mockImplementation( + vi.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + standard: { + maxPriorityFee: 0, + maxFee: 0, + }, + }), + }), + ) as any, + ); + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('promptForInputs', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should return correct answers for valid inputs with encrypted wallet', async () => { + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + remark: 'Test remark', + encryptionKey: 'test-key', + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce('encryptedWallet'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce('./wallet.json') + .mockResolvedValueOnce(mockInputs.remark) + .mockResolvedValueOnce(mockInputs.encryptionKey); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.newBeneficiary).toBe(mockInputs.newBeneficiary); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.encryptionKey); + expect(result.dryRun).toBe(false); + expect(result.maxPriorityFeePerGasScale).toBe(1); + }); + + it('should return correct answers for valid inputs with private key file', async () => { + const mockInputs = { + network: NetworkCmdName.Mainnet, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + }; + + (prompts.select as any).mockResolvedValueOnce(mockInputs.network).mockResolvedValueOnce('keyFile'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce('./private-key.txt') + .mockResolvedValueOnce(''); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).keyFile).toBe('./private-key.txt'); + expect(result.remark).toBeUndefined(); + expect(result.encryptionKey).toBeUndefined(); + }); + + it('should return correct answers for valid inputs with direct private key', async () => { + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + }; + + (prompts.select as any).mockResolvedValueOnce(mockInputs.network).mockResolvedValueOnce('keyDirect'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') + .mockResolvedValueOnce(''); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).key).toBe('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'); + }); + + it('should return correct answers when using environment variable for private key', async () => { + const originalEnv = process.env.OA_PRIVATE_KEY; + process.env.OA_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + }; + + (prompts.select as any).mockResolvedValueOnce(mockInputs.network).mockResolvedValueOnce('envVariable'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce(''); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).key).toBeUndefined(); + expect((result as any).keyFile).toBeUndefined(); + expect((result as any).encryptedWalletPath).toBeUndefined(); + + if (originalEnv) { + process.env.OA_PRIVATE_KEY = originalEnv; + } else { + delete process.env.OA_PRIVATE_KEY; + } + }); + + it('should throw error when OA_PRIVATE_KEY environment variable is not set', async () => { + const originalEnv = process.env.OA_PRIVATE_KEY; + delete process.env.OA_PRIVATE_KEY; + + (prompts.select as any).mockResolvedValueOnce(NetworkCmdName.Sepolia).mockResolvedValueOnce('envVariable'); + + (prompts.input as any) + .mockResolvedValueOnce('0x1234567890123456789012345678901234567890') + .mockResolvedValueOnce('0xabcdef1234567890') + .mockResolvedValueOnce('0x0987654321098765432109876543210987654321'); + + await expect(promptForInputs()).rejects.toThrowError( + 'OA_PRIVATE_KEY environment variable is not set. Please set it or choose another option.', + ); + + if (originalEnv) { + process.env.OA_PRIVATE_KEY = originalEnv; + } + }); + }); + + describe('endorseTransferOwnerHandler', () => { + let transferBeneficiaryMock: MockedFunction; + let getWalletOrSignerMock: MockedFunction; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import('@trustvc/trustvc'); + transferBeneficiaryMock = trustvcModule.transferBeneficiary as MockedFunction; + + const walletModule = await import('../../../src/utils/wallet'); + getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + }); + + it('should successfully endorse transfer of owner and display transaction details', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash123', + blockNumber: 12345, + blockHash: '0xblockhash', + confirmations: 1, + from: '0xfrom', + to: mockArgs.tokenRegistryAddress, + gasUsed: { toNumber: () => 100000 } as any, + cumulativeGasUsed: { toNumber: () => 100000 } as any, + effectiveGasPrice: { toNumber: () => 1000000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + transferBeneficiaryMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const result = await endorseTransferOwnerHandler(mockArgs); + + expect(transferBeneficiaryMock).toHaveBeenCalled(); + expect(result).toBe(mockArgs.tokenRegistryAddress); + }); + + it('should handle endorse with remark and encryption key', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + remark: 'Important endorsement', + encryptionKey: 'secret-key-123', + key: '0xprivatekey', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash456', + blockNumber: 12346, + blockHash: '0xblockhash2', + confirmations: 1, + from: '0xfrom', + to: mockArgs.tokenRegistryAddress, + gasUsed: { toNumber: () => 120000 } as any, + cumulativeGasUsed: { toNumber: () => 120000 } as any, + effectiveGasPrice: { toNumber: () => 1500000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + transferBeneficiaryMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const result = await endorseTransferOwnerHandler(mockArgs); + + expect(transferBeneficiaryMock).toHaveBeenCalled(); + expect(result).toBe(mockArgs.tokenRegistryAddress); + }); + + it('should handle errors during endorse', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const errorMessage = 'Transaction failed: insufficient funds'; + transferBeneficiaryMock.mockRejectedValue(new Error(errorMessage)); + + const result = await endorseTransferOwnerHandler(mockArgs); + + expect(result).toBeUndefined(); + expect(transferBeneficiaryMock).toHaveBeenCalled(); + }); + + it('should handle non-Error exceptions during endorse', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + transferBeneficiaryMock.mockRejectedValue('String error message'); + + const result = await endorseTransferOwnerHandler(mockArgs); + + expect(result).toBeUndefined(); + }); + }); + + describe('handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should successfully execute the complete endorse transfer owner flow', async () => { + const mockInputs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash', + blockNumber: 12345, + blockHash: '0xblockhash', + confirmations: 1, + from: '0xfrom', + to: mockInputs.tokenRegistryAddress, + gasUsed: { toNumber: () => 100000 } as any, + cumulativeGasUsed: { toNumber: () => 100000 } as any, + effectiveGasPrice: { toNumber: () => 1000000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce('encryptedWallet'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistryAddress) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce(mockInputs.encryptedWalletPath) + .mockResolvedValueOnce(''); + + const trustvcModule = await import('@trustvc/trustvc'); + const transferBeneficiaryMock = trustvcModule.transferBeneficiary as MockedFunction; + transferBeneficiaryMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const walletModule = await import('../../../src/utils/wallet'); + const getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + + const result = await handler(); + + expect(result).toBeUndefined(); + }); + + it('should handle errors in handler', async () => { + const errorMessage = 'Prompt error'; + (prompts.select as any).mockRejectedValue(new Error(errorMessage)); + + await handler(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + + it('should handle non-Error exceptions in handler', async () => { + const errorMessage = 'String error'; + (prompts.select as any).mockRejectedValue(errorMessage); + + await handler(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + }); + + describe('endorseNominatedBeneficiary', () => { + beforeEach(async () => { + delete process.env.OA_PRIVATE_KEY; + vi.mocked(transferBeneficiaryImpl).mockResolvedValue({ + hash: "hash", + wait: () => Promise.resolve({ transactionHash: "transactionHash" }), + } as any); + + const walletModule = await import('../../../src/utils/wallet'); + const getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + + // Re-setup the helpers mocks + const helpersModule = await import('../../../src/commands/helpers'); + const mockTitleEscrow = { + beneficiary: vi.fn().mockResolvedValue('0x3333333333333333333333333333333333333333'), + }; + + const connectToTitleEscrowMock = helpersModule.connectToTitleEscrow as MockedFunction; + connectToTitleEscrowMock.mockResolvedValue(mockTitleEscrow); + + const validateNominateBeneficiaryMock = helpersModule.validateNominateBeneficiary as MockedFunction; + validateNominateBeneficiaryMock.mockImplementation(async ({ beneficiaryNominee }: any) => { + if (beneficiaryNominee === '0x2222222222222222222222222222222222222222') { + throw new Error('new beneficiary address is the same as the current beneficiary address'); + } + }); + }); + + it("should pass in the correct params and call the following procedures to invoke an endorsement of transfer of owner of a transferable record", async () => { + const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; + await endorseNominatedBeneficiary({ + ...endorseNominatedBeneficiaryParams, + key: privateKey, + }); + + expect(transferBeneficiaryImpl).toHaveBeenCalledTimes(1); + }); + + it("should throw an error if nominee is the owner address", async () => { + const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; + await expect( + endorseNominatedBeneficiary({ + ...endorseNominatedBeneficiaryParams, + newBeneficiary: "0x2222222222222222222222222222222222222222", + key: privateKey, + }) + ).rejects.toThrow(`new beneficiary address is the same as the current beneficiary address`); + }); + }); +}); diff --git a/tests/commands/title-escrow/nominateBeneficiary-astron.test.ts b/tests/commands/title-escrow/nominateBeneficiary-astron.test.ts new file mode 100644 index 0000000..9a28c32 --- /dev/null +++ b/tests/commands/title-escrow/nominateBeneficiary-astron.test.ts @@ -0,0 +1,85 @@ +import { v5Contracts, nominate as nominateImpl } from '@trustvc/trustvc'; +import { beforeEach, describe, expect, it, vi, Mock } from 'vitest'; +import { TitleEscrowNominateBeneficiaryCommand } from '../../../src/types'; +import { nominateBeneficiary } from '../../../src/commands/title-escrow/nominate-change-of-owner'; + +const { TitleEscrow__factory, TradeTrustToken__factory } = v5Contracts; +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + nominate: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/helpers', () => ({ + connectToTitleEscrow: vi.fn().mockResolvedValue({ + beneficiary: vi.fn().mockResolvedValue('0x3333333333333333333333333333333333333333'), + }), + validateNominateBeneficiary: vi.fn().mockImplementation(async ({ beneficiaryNominee }) => { + if (beneficiaryNominee === '0x1111111111111111111111111111111111111111') { + throw new Error('new beneficiary address is the same as the current beneficiary address'); + } + }), + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +const nominateBeneficiaryParams: TitleEscrowNominateBeneficiaryCommand = { + newBeneficiary: '0x2222222222222222222222222222222222222222', + tokenId: '0x12345', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + network: 'astron', + maxPriorityFeePerGasScale: 1, + dryRun: false, +}; + +describe('title-escrow', () => { + describe('nominate change of owner of transferable record', () => { + const mockedTradeTrustTokenFactory: Mock = + TradeTrustToken__factory as any; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mock static method + const _mockedConnectERC721: Mock = mockedTradeTrustTokenFactory.connect; + const mockedTokenFactory: Mock = TitleEscrow__factory as any; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mock static method + const _mockedConnectTokenFactory: Mock = mockedTokenFactory.connect; + const _mockedOwnerOf = vi.fn(); + const _mockNominateBeneficiary = vi.fn(); + const _mockedTitleEscrowAddress = '0x2133'; + const _mockedBeneficiary = '0xdssfs'; + const _mockedHolder = '0xdsfls'; + const _mockGetBeneficiary = vi.fn(); + const _mockGetHolder = vi.fn(); + const _mockCallStaticNominateBeneficiary = vi.fn().mockResolvedValue(undefined); + + beforeEach(() => { + delete process.env.OA_PRIVATE_KEY; + vi.mocked(nominateImpl).mockResolvedValue({ + hash: 'hash', + wait: () => Promise.resolve({ transactionHash: 'transactionHash' }), + } as any); + }); + + it('should pass in the correct params and call the following procedures to invoke an nomination of change of owner of a transferable record', async () => { + const privateKey = '0000000000000000000000000000000000000000000000000000000000000001'; + await nominateBeneficiary({ + ...nominateBeneficiaryParams, + key: privateKey, + }); + + expect(nominateImpl).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if new owner addresses is the same as current owner', async () => { + const privateKey = '0000000000000000000000000000000000000000000000000000000000000001'; + await expect( + nominateBeneficiary({ + ...nominateBeneficiaryParams, + newBeneficiary: '0x1111111111111111111111111111111111111111', + key: privateKey, + }), + ).rejects.toThrow('new beneficiary address is the same as the current beneficiary address'); + }); + }); +}); diff --git a/tests/commands/title-escrow/nominateBeneficiary-astrontestnet.test.ts b/tests/commands/title-escrow/nominateBeneficiary-astrontestnet.test.ts new file mode 100644 index 0000000..3a4347e --- /dev/null +++ b/tests/commands/title-escrow/nominateBeneficiary-astrontestnet.test.ts @@ -0,0 +1,84 @@ +import { v5Contracts, nominate as nominateImpl } from "@trustvc/trustvc"; +import { beforeEach, describe, expect, it, vi, Mock } from "vitest"; +import { TitleEscrowNominateBeneficiaryCommand } from "../../../src/types"; +import { nominateBeneficiary } from "../../../src/commands/title-escrow/nominate-change-of-owner"; + +const { TitleEscrow__factory, TradeTrustToken__factory } = v5Contracts; +vi.mock("@trustvc/trustvc", async () => { + const actual = await vi.importActual("@trustvc/trustvc"); + return { + ...actual, + nominate: vi.fn(), + }; +}); + +vi.mock("../../../src/commands/helpers", () => ({ + connectToTitleEscrow: vi.fn().mockResolvedValue({ + beneficiary: vi.fn().mockResolvedValue("0x3333333333333333333333333333333333333333"), + }), + validateNominateBeneficiary: vi.fn().mockImplementation(async ({ beneficiaryNominee }) => { + if (beneficiaryNominee === "0x1111111111111111111111111111111111111111") { + throw new Error("new beneficiary address is the same as the current beneficiary address"); + } + }), + validateAndEncryptRemark: vi.fn().mockReturnValue("encrypted-remark"), +})); + +const nominateBeneficiaryParams: TitleEscrowNominateBeneficiaryCommand = { + newBeneficiary: "0x2222222222222222222222222222222222222222", + tokenId: "0x12345", + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + network: "astrontestnet", + maxPriorityFeePerGasScale: 1, + dryRun: false, +}; + +describe("title-escrow", () => { + describe("nominate change of owner of transferable record", () => { + const mockedTradeTrustTokenFactory: Mock = TradeTrustToken__factory as any; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mock static method + const _mockedConnectERC721: Mock = mockedTradeTrustTokenFactory.connect; + const mockedTokenFactory: Mock = TitleEscrow__factory as any; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mock static method + const _mockedConnectTokenFactory: Mock = mockedTokenFactory.connect; + const _mockedOwnerOf = vi.fn(); + const _mockNominateBeneficiary = vi.fn(); + const _mockedTitleEscrowAddress = "0x2133"; + const _mockedBeneficiary = "0xdssfs"; + const _mockedHolder = "0xdsfls"; + const _mockGetBeneficiary = vi.fn(); + const _mockGetHolder = vi.fn(); + const _mockCallStaticNominateBeneficiary = vi.fn().mockResolvedValue(undefined); + + beforeEach(() => { + delete process.env.OA_PRIVATE_KEY; + vi.mocked(nominateImpl).mockResolvedValue({ + hash: "hash", + wait: () => Promise.resolve({ transactionHash: "transactionHash" }), + } as any); + }); + + it("should pass in the correct params and call the following procedures to invoke an nomination of change of owner of a transferable record", async () => { + const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; + await nominateBeneficiary({ + ...nominateBeneficiaryParams, + key: privateKey, + }); + + expect(nominateImpl).toHaveBeenCalledTimes(1); + }); + + it("should throw an error if new owner addresses is the same as current owner", async () => { + const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; + await expect( + nominateBeneficiary({ + ...nominateBeneficiaryParams, + newBeneficiary: "0x1111111111111111111111111111111111111111", + key: privateKey, + }) + ).rejects.toThrow("new beneficiary address is the same as the current beneficiary address"); + }); + }); +}); diff --git a/tests/commands/title-escrow/nominateBeneficiary.test.ts b/tests/commands/title-escrow/nominateBeneficiary.test.ts new file mode 100644 index 0000000..a476c44 --- /dev/null +++ b/tests/commands/title-escrow/nominateBeneficiary.test.ts @@ -0,0 +1,549 @@ +import { TransactionReceipt } from '@ethersproject/providers'; +import * as prompts from '@inquirer/prompts'; +import { nominate as nominateImpl } from "@trustvc/trustvc"; +import { beforeEach, describe, expect, it, vi, MockedFunction } from "vitest"; +import { TitleEscrowNominateBeneficiaryCommand } from "../../../src/types"; +import { + nominateBeneficiary, + nominateChangeOwnerHandler, + handler, + promptForInputs, +} from "../../../src/commands/title-escrow/nominate-change-of-owner"; +import { NetworkCmdName } from '../../../src/utils'; + +vi.mock('@inquirer/prompts'); + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock("@trustvc/trustvc", async () => { + const actual = await vi.importActual("@trustvc/trustvc"); + return { + ...actual, + nominate: vi.fn(), + }; +}); + +vi.mock("../../../src/commands/helpers", () => ({ + connectToTitleEscrow: vi.fn().mockResolvedValue({ + beneficiary: vi.fn().mockResolvedValue("0x3333333333333333333333333333333333333333"), + }), + validateNominateBeneficiary: vi.fn().mockImplementation(async ({ beneficiaryNominee }) => { + if (beneficiaryNominee === "0x1111111111111111111111111111111111111111") { + throw new Error("new beneficiary address is the same as the current beneficiary address"); + } + }), + validateAndEncryptRemark: vi.fn().mockReturnValue("encrypted-remark"), +})); + +vi.mock('../../../src/utils/wallet', () => ({ + getWalletOrSigner: vi.fn(), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + displayTransactionPrice: vi.fn(), + canEstimateGasPrice: vi.fn(() => false), + getGasFees: vi.fn(), + }; +}); + +const nominateBeneficiaryParams: TitleEscrowNominateBeneficiaryCommand = { + newBeneficiary: "0x2222222222222222222222222222222222222222", + remark: "remark", + encryptionKey: "1234", + tokenId: "0x12345", + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + network: "sepolia", + maxPriorityFeePerGasScale: 1, + dryRun: false, +}; + +describe('title-escrow/nominate-change-owner', () => { + vi.setConfig({ testTimeout: 30_000 }); + vi.spyOn(global, 'fetch').mockImplementation( + vi.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + standard: { + maxPriorityFee: 0, + maxFee: 0, + }, + }), + }), + ) as any, + ); + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('promptForInputs', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should return correct answers for valid inputs with encrypted wallet', async () => { + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + remark: 'Test remark', + encryptionKey: 'test-key', + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce('encryptedWallet'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce('./wallet.json') + .mockResolvedValueOnce(mockInputs.remark) + .mockResolvedValueOnce(mockInputs.encryptionKey); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.newBeneficiary).toBe(mockInputs.newBeneficiary); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.encryptionKey); + expect(result.dryRun).toBe(false); + expect(result.maxPriorityFeePerGasScale).toBe(1); + }); + + it('should return correct answers for valid inputs with private key file', async () => { + const mockInputs = { + network: NetworkCmdName.Mainnet, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + }; + + (prompts.select as any).mockResolvedValueOnce(mockInputs.network).mockResolvedValueOnce('keyFile'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce('./private-key.txt') + .mockResolvedValueOnce(''); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).keyFile).toBe('./private-key.txt'); + expect(result.remark).toBeUndefined(); + expect(result.encryptionKey).toBeUndefined(); + }); + + it('should return correct answers for valid inputs with direct private key', async () => { + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + }; + + (prompts.select as any).mockResolvedValueOnce(mockInputs.network).mockResolvedValueOnce('keyDirect'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') + .mockResolvedValueOnce(''); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).key).toBe('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'); + }); + + it('should return correct answers when using environment variable for private key', async () => { + const originalEnv = process.env.OA_PRIVATE_KEY; + process.env.OA_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + }; + + (prompts.select as any).mockResolvedValueOnce(mockInputs.network).mockResolvedValueOnce('envVariable'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce(''); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).key).toBeUndefined(); + expect((result as any).keyFile).toBeUndefined(); + expect((result as any).encryptedWalletPath).toBeUndefined(); + + if (originalEnv) { + process.env.OA_PRIVATE_KEY = originalEnv; + } else { + delete process.env.OA_PRIVATE_KEY; + } + }); + + it('should throw error when OA_PRIVATE_KEY environment variable is not set', async () => { + const originalEnv = process.env.OA_PRIVATE_KEY; + delete process.env.OA_PRIVATE_KEY; + + (prompts.select as any).mockResolvedValueOnce(NetworkCmdName.Sepolia).mockResolvedValueOnce('envVariable'); + + (prompts.input as any) + .mockResolvedValueOnce('0x1234567890123456789012345678901234567890') + .mockResolvedValueOnce('0xabcdef1234567890') + .mockResolvedValueOnce('0x0987654321098765432109876543210987654321'); + + await expect(promptForInputs()).rejects.toThrowError( + 'OA_PRIVATE_KEY environment variable is not set. Please set it or choose another option.', + ); + + if (originalEnv) { + process.env.OA_PRIVATE_KEY = originalEnv; + } + }); + }); + + describe('nominateChangeOwnerHandler', () => { + let nominateMock: MockedFunction; + let getWalletOrSignerMock: MockedFunction; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import('@trustvc/trustvc'); + nominateMock = trustvcModule.nominate as MockedFunction; + + const walletModule = await import('../../../src/utils/wallet'); + getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + }); + + it('should successfully nominate change of owner and display transaction details', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash123', + blockNumber: 12345, + blockHash: '0xblockhash', + confirmations: 1, + from: '0xfrom', + to: mockArgs.tokenRegistryAddress, + gasUsed: { toNumber: () => 100000 } as any, + cumulativeGasUsed: { toNumber: () => 100000 } as any, + effectiveGasPrice: { toNumber: () => 1000000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + nominateMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const result = await nominateChangeOwnerHandler(mockArgs); + + expect(nominateMock).toHaveBeenCalledWith( + { tokenRegistryAddress: mockArgs.tokenRegistryAddress, tokenId: mockArgs.tokenId }, + expect.anything(), + expect.objectContaining({ + newBeneficiaryAddress: mockArgs.newBeneficiary, + }), + expect.anything(), + ); + expect(result).toBe(mockArgs.tokenRegistryAddress); + }); + + it('should handle nominate with remark and encryption key', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + remark: 'Important nomination', + encryptionKey: 'secret-key-123', + key: '0xprivatekey', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash456', + blockNumber: 12346, + blockHash: '0xblockhash2', + confirmations: 1, + from: '0xfrom', + to: mockArgs.tokenRegistryAddress, + gasUsed: { toNumber: () => 120000 } as any, + cumulativeGasUsed: { toNumber: () => 120000 } as any, + effectiveGasPrice: { toNumber: () => 1500000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + nominateMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const result = await nominateChangeOwnerHandler(mockArgs); + + expect(nominateMock).toHaveBeenCalledWith( + { tokenRegistryAddress: mockArgs.tokenRegistryAddress, tokenId: mockArgs.tokenId }, + expect.anything(), + expect.objectContaining({ + newBeneficiaryAddress: mockArgs.newBeneficiary, + remarks: mockArgs.remark, + }), + expect.objectContaining({ + id: mockArgs.encryptionKey, + }), + ); + expect(result).toBe(mockArgs.tokenRegistryAddress); + }); + + it('should handle errors during nominate', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const errorMessage = 'Transaction failed: insufficient funds'; + nominateMock.mockRejectedValue(new Error(errorMessage)); + + const result = await nominateChangeOwnerHandler(mockArgs); + + expect(result).toBeUndefined(); + expect(nominateMock).toHaveBeenCalled(); + }); + + it('should handle non-Error exceptions during nominate', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + nominateMock.mockRejectedValue('String error message'); + + const result = await nominateChangeOwnerHandler(mockArgs); + + expect(result).toBeUndefined(); + }); + }); + + describe('handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should successfully execute the complete nominate change owner flow', async () => { + const mockInputs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash', + blockNumber: 12345, + blockHash: '0xblockhash', + confirmations: 1, + from: '0xfrom', + to: mockInputs.tokenRegistryAddress, + gasUsed: { toNumber: () => 100000 } as any, + cumulativeGasUsed: { toNumber: () => 100000 } as any, + effectiveGasPrice: { toNumber: () => 1000000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce('encryptedWallet'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistryAddress) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce(mockInputs.encryptedWalletPath) + .mockResolvedValueOnce(''); + + const trustvcModule = await import('@trustvc/trustvc'); + const nominateMock = trustvcModule.nominate as MockedFunction; + nominateMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const walletModule = await import('../../../src/utils/wallet'); + const getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + + const result = await handler(); + + expect(result).toBeUndefined(); + }); + + it('should handle errors in handler', async () => { + const errorMessage = 'Prompt error'; + (prompts.select as any).mockRejectedValue(new Error(errorMessage)); + + await handler(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + + it('should handle non-Error exceptions in handler', async () => { + const errorMessage = 'String error'; + (prompts.select as any).mockRejectedValue(errorMessage); + + await handler(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + }); + + describe('nominateBeneficiary', () => { + beforeEach(async () => { + delete process.env.OA_PRIVATE_KEY; + vi.mocked(nominateImpl).mockResolvedValue({ + hash: 'hash', + wait: () => Promise.resolve({ transactionHash: 'transactionHash' }), + } as any); + + const walletModule = await import('../../../src/utils/wallet'); + const getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + + // Re-setup the helpers mocks + const helpersModule = await import('../../../src/commands/helpers'); + const mockTitleEscrow = { + beneficiary: vi.fn().mockResolvedValue('0x3333333333333333333333333333333333333333'), + }; + + const connectToTitleEscrowMock = helpersModule.connectToTitleEscrow as MockedFunction; + connectToTitleEscrowMock.mockResolvedValue(mockTitleEscrow); + + const validateNominateBeneficiaryMock = helpersModule.validateNominateBeneficiary as MockedFunction; + validateNominateBeneficiaryMock.mockImplementation(async ({ beneficiaryNominee }: any) => { + if (beneficiaryNominee === '0x1111111111111111111111111111111111111111') { + throw new Error('new beneficiary address is the same as the current beneficiary address'); + } + }); + }); + + it('should pass in the correct params and call the following procedures to invoke an nomination of change of owner of a transferable record', async () => { + const privateKey = '0000000000000000000000000000000000000000000000000000000000000001'; + await nominateBeneficiary({ + ...nominateBeneficiaryParams, + key: privateKey, + }); + + expect(nominateImpl).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if new owner addresses is the same as current owner', async () => { + const privateKey = '0000000000000000000000000000000000000000000000000000000000000001'; + await expect( + nominateBeneficiary({ + ...nominateBeneficiaryParams, + newBeneficiary: '0x1111111111111111111111111111111111111111', + key: privateKey, + }), + ).rejects.toThrow('new beneficiary address is the same as the current beneficiary address'); + }); + }); +}); diff --git a/tests/commands/title-escrow/transferHolder-astron.test.ts b/tests/commands/title-escrow/transferHolder-astron.test.ts new file mode 100644 index 0000000..2735ea3 --- /dev/null +++ b/tests/commands/title-escrow/transferHolder-astron.test.ts @@ -0,0 +1,537 @@ +import { TransactionReceipt } from "@ethersproject/providers"; +import * as prompts from "@inquirer/prompts"; +import { v5Contracts, transferHolder as transferHolderImpl } from "@trustvc/trustvc"; +import { beforeEach, describe, expect, it, vi, Mock, MockedFunction } from "vitest"; +import { TitleEscrowTransferHolderCommand } from "../../../src/types"; +import { + transferHolder, + changeHolderHandler, + handler, + promptForInputs, +} from "../../../src/commands/title-escrow/change-holder"; +import { NetworkCmdName } from "../../../src/utils"; + +const { TitleEscrow__factory, TradeTrustToken__factory } = v5Contracts; + +vi.mock("@inquirer/prompts"); + +vi.mock("signale", async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock("@trustvc/trustvc", async () => { + const actual = await vi.importActual("@trustvc/trustvc"); + return { + ...actual, + transferHolder: vi.fn(), + }; +}); + +vi.mock("../../../src/commands/helpers", () => ({ + connectToTitleEscrow: vi.fn().mockResolvedValue({ + holder: vi.fn().mockResolvedValue("0x3333333333333333333333333333333333333333"), + }), + validateAndEncryptRemark: vi.fn().mockReturnValue("encrypted-remark"), +})); + +vi.mock("../../../src/utils/wallet", () => ({ + getWalletOrSigner: vi.fn(), +})); + +vi.mock("../../../src/utils", async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => "https://etherscan.io"), + displayTransactionPrice: vi.fn(), + canEstimateGasPrice: vi.fn(() => false), + getGasFees: vi.fn(), + }; +}); + +const transferHolderParams: TitleEscrowTransferHolderCommand = { + newHolder: "0xabcd", + tokenId: "0xzyxw", + tokenRegistryAddress: "0x1234", + network: "astron", + maxPriorityFeePerGasScale: 1, + dryRun: false, +}; + +describe("title-escrow/change-holder", () => { + vi.setConfig({ testTimeout: 30_000 }); + vi.spyOn(global, "fetch").mockImplementation( + vi.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + standard: { + maxPriorityFee: 0, + maxFee: 0, + }, + }), + }) + ) as any + ); + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe("promptForInputs", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should return correct answers for valid inputs with encrypted wallet", async () => { + const mockInputs = { + network: NetworkCmdName.Astron, + tokenRegistry: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newHolder: "0x0987654321098765432109876543210987654321", + remark: "Test remark", + encryptionKey: "test-key", + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce("encryptedWallet"); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce("./wallet.json") + .mockResolvedValueOnce(mockInputs.remark) + .mockResolvedValueOnce(mockInputs.encryptionKey); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.newHolder).toBe(mockInputs.newHolder); + expect((result as any).encryptedWalletPath).toBe("./wallet.json"); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.encryptionKey); + expect(result.dryRun).toBe(false); + expect(result.maxPriorityFeePerGasScale).toBe(1); + }); + + it("should return correct answers for valid inputs with private key file", async () => { + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newHolder: "0x0987654321098765432109876543210987654321", + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce("keyFile"); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce("./private-key.txt") + .mockResolvedValueOnce(""); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).keyFile).toBe("./private-key.txt"); + expect(result.remark).toBeUndefined(); + expect(result.encryptionKey).toBeUndefined(); + }); + + it("should return correct answers for valid inputs with direct private key", async () => { + const mockInputs = { + network: NetworkCmdName.Astron, + tokenRegistry: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newHolder: "0x0987654321098765432109876543210987654321", + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce("keyDirect"); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + .mockResolvedValueOnce(""); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).key).toBe( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + ); + }); + + it("should return correct answers when using environment variable for private key", async () => { + const originalEnv = process.env.OA_PRIVATE_KEY; + process.env.OA_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + const mockInputs = { + network: NetworkCmdName.Astron, + tokenRegistry: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newHolder: "0x0987654321098765432109876543210987654321", + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce("envVariable"); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce(""); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).key).toBeUndefined(); + expect((result as any).keyFile).toBeUndefined(); + expect((result as any).encryptedWalletPath).toBeUndefined(); + + if (originalEnv) { + process.env.OA_PRIVATE_KEY = originalEnv; + } else { + delete process.env.OA_PRIVATE_KEY; + } + }); + + it("should throw error when OA_PRIVATE_KEY environment variable is not set", async () => { + const originalEnv = process.env.OA_PRIVATE_KEY; + delete process.env.OA_PRIVATE_KEY; + + (prompts.select as any) + .mockResolvedValueOnce(NetworkCmdName.Astron) + .mockResolvedValueOnce("envVariable"); + + (prompts.input as any) + .mockResolvedValueOnce("0x1234567890123456789012345678901234567890") + .mockResolvedValueOnce("0xabcdef1234567890") + .mockResolvedValueOnce("0x0987654321098765432109876543210987654321"); + + await expect(promptForInputs()).rejects.toThrowError( + "OA_PRIVATE_KEY environment variable is not set. Please set it or choose another option." + ); + + if (originalEnv) { + process.env.OA_PRIVATE_KEY = originalEnv; + } + }); + }); + + describe("changeHolderHandler", () => { + let transferHolderMock: MockedFunction; + let getWalletOrSignerMock: MockedFunction; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import("@trustvc/trustvc"); + transferHolderMock = trustvcModule.transferHolder as MockedFunction; + + const walletModule = await import("../../../src/utils/wallet"); + getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + }); + + it("should successfully change holder and display transaction details", async () => { + const mockArgs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newHolder: "0x0987654321098765432109876543210987654321", + encryptedWalletPath: "./wallet.json", + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: "0xtxhash123", + blockNumber: 12345, + blockHash: "0xblockhash", + confirmations: 1, + from: "0xfrom", + to: mockArgs.tokenRegistryAddress, + gasUsed: { toNumber: () => 100000 } as any, + cumulativeGasUsed: { toNumber: () => 100000 } as any, + effectiveGasPrice: { toNumber: () => 1000000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: "", + transactionIndex: 0, + logs: [], + logsBloom: "0x", + }; + + transferHolderMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const result = await changeHolderHandler(mockArgs); + + expect(transferHolderMock).toHaveBeenCalledWith( + { tokenRegistryAddress: mockArgs.tokenRegistryAddress, tokenId: mockArgs.tokenId }, + expect.anything(), + expect.objectContaining({ + holderAddress: mockArgs.newHolder, + }), + expect.anything() + ); + expect(result).toBe(mockArgs.tokenRegistryAddress); + }); + + it("should handle change holder with remark and encryption key", async () => { + const mockArgs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newHolder: "0x0987654321098765432109876543210987654321", + remark: "Important transfer", + encryptionKey: "secret-key-123", + key: "0xprivatekey", + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: "0xtxhash456", + blockNumber: 12346, + blockHash: "0xblockhash2", + confirmations: 1, + from: "0xfrom", + to: mockArgs.tokenRegistryAddress, + gasUsed: { toNumber: () => 120000 } as any, + cumulativeGasUsed: { toNumber: () => 120000 } as any, + effectiveGasPrice: { toNumber: () => 1500000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: "", + transactionIndex: 0, + logs: [], + logsBloom: "0x", + }; + + transferHolderMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const result = await changeHolderHandler(mockArgs); + + expect(transferHolderMock).toHaveBeenCalledWith( + { tokenRegistryAddress: mockArgs.tokenRegistryAddress, tokenId: mockArgs.tokenId }, + expect.anything(), + expect.objectContaining({ + holderAddress: mockArgs.newHolder, + remarks: mockArgs.remark, + }), + expect.objectContaining({ + id: mockArgs.encryptionKey, + }) + ); + expect(result).toBe(mockArgs.tokenRegistryAddress); + }); + + it("should handle errors during change holder", async () => { + const mockArgs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newHolder: "0x0987654321098765432109876543210987654321", + encryptedWalletPath: "./wallet.json", + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const errorMessage = "Transaction failed: insufficient funds"; + transferHolderMock.mockRejectedValue(new Error(errorMessage)); + + const result = await changeHolderHandler(mockArgs); + + expect(result).toBeUndefined(); + expect(transferHolderMock).toHaveBeenCalled(); + }); + + it("should handle non-Error exceptions during change holder", async () => { + const mockArgs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newHolder: "0x0987654321098765432109876543210987654321", + encryptedWalletPath: "./wallet.json", + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + transferHolderMock.mockRejectedValue("String error message"); + + const result = await changeHolderHandler(mockArgs); + + expect(result).toBeUndefined(); + }); + }); + + describe("handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should successfully execute the complete change holder flow", async () => { + const mockInputs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newHolder: "0x0987654321098765432109876543210987654321", + encryptedWalletPath: "./wallet.json", + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: "0xtxhash", + blockNumber: 12345, + blockHash: "0xblockhash", + confirmations: 1, + from: "0xfrom", + to: mockInputs.tokenRegistryAddress, + gasUsed: { toNumber: () => 100000 } as any, + cumulativeGasUsed: { toNumber: () => 100000 } as any, + effectiveGasPrice: { toNumber: () => 1000000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: "", + transactionIndex: 0, + logs: [], + logsBloom: "0x", + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce("encryptedWallet"); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistryAddress) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce(mockInputs.encryptedWalletPath) + .mockResolvedValueOnce(""); + + const trustvcModule = await import("@trustvc/trustvc"); + const transferHolderMock = trustvcModule.transferHolder as MockedFunction; + transferHolderMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const walletModule = await import("../../../src/utils/wallet"); + const getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + + const result = await handler(); + + expect(result).toBeUndefined(); + }); + + it("should handle errors in handler", async () => { + const errorMessage = "Prompt error"; + (prompts.select as any).mockRejectedValue(new Error(errorMessage)); + + await handler(); + + const signaleModule = await import("signale"); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + + it("should handle non-Error exceptions in handler", async () => { + const errorMessage = "String error"; + (prompts.select as any).mockRejectedValue(errorMessage); + + await handler(); + + const signaleModule = await import("signale"); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + }); + + describe("transferHolder", () => { + const mockedTradeTrustTokenFactory: Mock = + TradeTrustToken__factory as any; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mock static method + const _mockedConnectERC721: Mock = mockedTradeTrustTokenFactory.connect; + + const mockedTokenFactory: Mock = TitleEscrow__factory as any; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mock static method + const _mockedConnectTokenFactory: Mock = mockedTokenFactory.connect; + const _mockedOwnerOf = vi.fn(); + const _mockTransferHolder = vi.fn(); + const _mockCallStaticTransferHolder = vi.fn().mockResolvedValue(undefined); + const _mockedTitleEscrowAddress = "0x2133"; + + beforeEach(() => { + delete process.env.OA_PRIVATE_KEY; + vi.mocked(transferHolderImpl).mockResolvedValue({ + hash: "hash", + wait: () => Promise.resolve({ transactionHash: "transactionHash" }), + } as any); + }); + + it("should pass in the correct params and call the following procedures to invoke a change in holder of a transferable record", async () => { + const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; + await transferHolder({ + ...transferHolderParams, + key: privateKey, + }); + + expect(transferHolderImpl).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/commands/title-escrow/transferHolder-astrontestnet.test.ts b/tests/commands/title-escrow/transferHolder-astrontestnet.test.ts new file mode 100644 index 0000000..6a01855 --- /dev/null +++ b/tests/commands/title-escrow/transferHolder-astrontestnet.test.ts @@ -0,0 +1,58 @@ +import { v5Contracts, transferHolder as transferHolderImpl } from "@trustvc/trustvc"; +import { beforeEach, describe, expect, it, vi, Mock } from "vitest"; +import { TitleEscrowTransferHolderCommand } from "../../../src/types"; +import { transferHolder } from "../../../src/commands/title-escrow/change-holder"; + +const { TitleEscrow__factory, TradeTrustToken__factory } = v5Contracts; +vi.mock("@trustvc/trustvc", async () => { + const actual = await vi.importActual("@trustvc/trustvc"); + return { + ...actual, + transferHolder: vi.fn(), + }; +}); + +const transferHolderParams: TitleEscrowTransferHolderCommand = { + newHolder: "0xabcd", + tokenId: "0xzyxw", + tokenRegistryAddress: "0x1234", + network: "astrontestnet", + maxPriorityFeePerGasScale: 1, + dryRun: false, +}; + +describe("title-escrow", () => { + describe("change holder of transferable record", () => { + const mockedTradeTrustTokenFactory: Mock = TradeTrustToken__factory as any; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mock static method + const _mockedConnectERC721: Mock = mockedTradeTrustTokenFactory.connect; + + const mockedTokenFactory: Mock = TitleEscrow__factory as any; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mock static method + const _mockedConnectTokenFactory: Mock = mockedTokenFactory.connect; + const _mockedOwnerOf = vi.fn(); + const _mockTransferHolder = vi.fn(); + const _mockCallStaticTransferHolder = vi.fn().mockResolvedValue(undefined); + const _mockedTitleEscrowAddress = "0x2133"; + + beforeEach(() => { + delete process.env.OA_PRIVATE_KEY; + vi.mocked(transferHolderImpl).mockResolvedValue({ + hash: "hash", + wait: () => Promise.resolve({ transactionHash: "transactionHash" }), + } as any); + }); + + it("should pass in the correct params and call the following procedures to invoke a change in holder of a transferable record", async () => { + const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; + await transferHolder({ + ...transferHolderParams, + key: privateKey, + }); + + expect(transferHolderImpl).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/commands/title-escrow/transferHolder.test.ts b/tests/commands/title-escrow/transferHolder.test.ts new file mode 100644 index 0000000..d53fea4 --- /dev/null +++ b/tests/commands/title-escrow/transferHolder.test.ts @@ -0,0 +1,511 @@ +import { TransactionReceipt } from '@ethersproject/providers'; +import * as prompts from '@inquirer/prompts'; +import { transferHolder as transferHolderImpl } from '@trustvc/trustvc'; +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { TitleEscrowTransferHolderCommand } from '../../../src/types'; +import { + transferHolder, + changeHolderHandler, + handler, + promptForInputs, +} from '../../../src/commands/title-escrow/change-holder'; +import { NetworkCmdName } from '../../../src/utils'; + +vi.mock('@inquirer/prompts'); + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + transferHolder: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/helpers', () => ({ + connectToTitleEscrow: vi.fn().mockResolvedValue({ + holder: vi.fn().mockResolvedValue('0x3333333333333333333333333333333333333333'), + }), + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +vi.mock('../../../src/utils/wallet', () => ({ + getWalletOrSigner: vi.fn(), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + displayTransactionPrice: vi.fn(), + canEstimateGasPrice: vi.fn(() => false), + getGasFees: vi.fn(), + }; +}); + +const transferHolderParams: TitleEscrowTransferHolderCommand = { + newHolder: '0x1111111111111111111111111111111111111111', + remark: '0xabcd', + encryptionKey: '1234', + tokenId: '0x12345', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + network: 'sepolia', + maxPriorityFeePerGasScale: 1, + dryRun: false, +}; + +describe('title-escrow/change-holder', () => { + vi.setConfig({ testTimeout: 30_000 }); + vi.spyOn(global, 'fetch').mockImplementation( + vi.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + standard: { + maxPriorityFee: 0, + maxFee: 0, + }, + }), + }), + ) as any, + ); + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('promptForInputs', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should return correct answers for valid inputs with encrypted wallet', async () => { + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newHolder: '0x0987654321098765432109876543210987654321', + remark: 'Test remark', + encryptionKey: 'test-key', + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce('encryptedWallet'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce('./wallet.json') + .mockResolvedValueOnce(mockInputs.remark) + .mockResolvedValueOnce(mockInputs.encryptionKey); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.newHolder).toBe(mockInputs.newHolder); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.encryptionKey); + expect(result.dryRun).toBe(false); + expect(result.maxPriorityFeePerGasScale).toBe(1); + }); + + it('should return correct answers for valid inputs with private key file', async () => { + const mockInputs = { + network: NetworkCmdName.Mainnet, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newHolder: '0x0987654321098765432109876543210987654321', + }; + + (prompts.select as any).mockResolvedValueOnce(mockInputs.network).mockResolvedValueOnce('keyFile'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce('./private-key.txt') + .mockResolvedValueOnce(''); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).keyFile).toBe('./private-key.txt'); + expect(result.remark).toBeUndefined(); + expect(result.encryptionKey).toBeUndefined(); + }); + + it('should return correct answers for valid inputs with direct private key', async () => { + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newHolder: '0x0987654321098765432109876543210987654321', + }; + + (prompts.select as any).mockResolvedValueOnce(mockInputs.network).mockResolvedValueOnce('keyDirect'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') + .mockResolvedValueOnce(''); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).key).toBe('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'); + }); + + it('should return correct answers when using environment variable for private key', async () => { + const originalEnv = process.env.OA_PRIVATE_KEY; + process.env.OA_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newHolder: '0x0987654321098765432109876543210987654321', + }; + + (prompts.select as any).mockResolvedValueOnce(mockInputs.network).mockResolvedValueOnce('envVariable'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce(''); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).key).toBeUndefined(); + expect((result as any).keyFile).toBeUndefined(); + expect((result as any).encryptedWalletPath).toBeUndefined(); + + if (originalEnv) { + process.env.OA_PRIVATE_KEY = originalEnv; + } else { + delete process.env.OA_PRIVATE_KEY; + } + }); + + it('should throw error when OA_PRIVATE_KEY environment variable is not set', async () => { + const originalEnv = process.env.OA_PRIVATE_KEY; + delete process.env.OA_PRIVATE_KEY; + + (prompts.select as any).mockResolvedValueOnce(NetworkCmdName.Sepolia).mockResolvedValueOnce('envVariable'); + + (prompts.input as any) + .mockResolvedValueOnce('0x1234567890123456789012345678901234567890') + .mockResolvedValueOnce('0xabcdef1234567890') + .mockResolvedValueOnce('0x0987654321098765432109876543210987654321'); + + await expect(promptForInputs()).rejects.toThrowError( + 'OA_PRIVATE_KEY environment variable is not set. Please set it or choose another option.', + ); + + if (originalEnv) { + process.env.OA_PRIVATE_KEY = originalEnv; + } + }); + }); + + describe('changeHolderHandler', () => { + let transferHolderMock: MockedFunction; + let getWalletOrSignerMock: MockedFunction; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import('@trustvc/trustvc'); + transferHolderMock = trustvcModule.transferHolder as MockedFunction; + + const walletModule = await import('../../../src/utils/wallet'); + getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + }); + + it('should successfully change holder and display transaction details', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newHolder: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash123', + blockNumber: 12345, + blockHash: '0xblockhash', + confirmations: 1, + from: '0xfrom', + to: mockArgs.tokenRegistryAddress, + gasUsed: { toNumber: () => 100000 } as any, + cumulativeGasUsed: { toNumber: () => 100000 } as any, + effectiveGasPrice: { toNumber: () => 1000000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + transferHolderMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const result = await changeHolderHandler(mockArgs); + + expect(transferHolderMock).toHaveBeenCalledWith( + { tokenRegistryAddress: mockArgs.tokenRegistryAddress, tokenId: mockArgs.tokenId }, + expect.anything(), + expect.objectContaining({ + holderAddress: mockArgs.newHolder, + }), + expect.anything(), + ); + expect(result).toBe(mockArgs.tokenRegistryAddress); + }); + + it('should handle change holder with remark and encryption key', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newHolder: '0x0987654321098765432109876543210987654321', + remark: 'Important transfer', + encryptionKey: 'secret-key-123', + key: '0xprivatekey', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash456', + blockNumber: 12346, + blockHash: '0xblockhash2', + confirmations: 1, + from: '0xfrom', + to: mockArgs.tokenRegistryAddress, + gasUsed: { toNumber: () => 120000 } as any, + cumulativeGasUsed: { toNumber: () => 120000 } as any, + effectiveGasPrice: { toNumber: () => 1500000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + transferHolderMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const result = await changeHolderHandler(mockArgs); + + expect(transferHolderMock).toHaveBeenCalledWith( + { tokenRegistryAddress: mockArgs.tokenRegistryAddress, tokenId: mockArgs.tokenId }, + expect.anything(), + expect.objectContaining({ + holderAddress: mockArgs.newHolder, + remarks: mockArgs.remark, + }), + expect.objectContaining({ + id: mockArgs.encryptionKey, + }), + ); + expect(result).toBe(mockArgs.tokenRegistryAddress); + }); + + it('should handle errors during change holder', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newHolder: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const errorMessage = 'Transaction failed: insufficient funds'; + transferHolderMock.mockRejectedValue(new Error(errorMessage)); + + const result = await changeHolderHandler(mockArgs); + + expect(result).toBeUndefined(); + expect(transferHolderMock).toHaveBeenCalled(); + }); + + it('should handle non-Error exceptions during change holder', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newHolder: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + transferHolderMock.mockRejectedValue('String error message'); + + const result = await changeHolderHandler(mockArgs); + + expect(result).toBeUndefined(); + }); + }); + + describe('handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should successfully execute the complete change holder flow', async () => { + const mockInputs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newHolder: '0x0987654321098765432109876543210987654321', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash', + blockNumber: 12345, + blockHash: '0xblockhash', + confirmations: 1, + from: '0xfrom', + to: mockInputs.tokenRegistryAddress, + gasUsed: { toNumber: () => 100000 } as any, + cumulativeGasUsed: { toNumber: () => 100000 } as any, + effectiveGasPrice: { toNumber: () => 1000000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce('encryptedWallet'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistryAddress) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce(mockInputs.encryptedWalletPath) + .mockResolvedValueOnce(''); + + const trustvcModule = await import('@trustvc/trustvc'); + const transferHolderMock = trustvcModule.transferHolder as MockedFunction; + transferHolderMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const walletModule = await import('../../../src/utils/wallet'); + const getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + + const result = await handler(); + + expect(result).toBeUndefined(); + }); + + it('should handle errors in handler', async () => { + const errorMessage = 'Prompt error'; + (prompts.select as any).mockRejectedValue(new Error(errorMessage)); + + await handler(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + + it('should handle non-Error exceptions in handler', async () => { + const errorMessage = 'String error'; + (prompts.select as any).mockRejectedValue(errorMessage); + + await handler(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + }); + + describe('transferHolder', () => { + beforeEach(() => { + delete process.env.OA_PRIVATE_KEY; + vi.mocked(transferHolderImpl).mockResolvedValue({ + hash: 'hash', + wait: () => Promise.resolve({ transactionHash: 'transactionHash' }), + } as any); + }); + + it('should pass in the correct params and call the following procedures to invoke a change in holder of a transferable record', async () => { + const privateKey = '0000000000000000000000000000000000000000000000000000000000000001'; + await transferHolder({ + ...transferHolderParams, + key: privateKey, + }); + + expect(transferHolderImpl).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/commands/title-escrow/transferOwners-astron.test.ts b/tests/commands/title-escrow/transferOwners-astron.test.ts new file mode 100644 index 0000000..77be119 --- /dev/null +++ b/tests/commands/title-escrow/transferOwners-astron.test.ts @@ -0,0 +1,589 @@ +import { TransactionReceipt } from "@ethersproject/providers"; +import * as prompts from "@inquirer/prompts"; +import { transferOwners as transferOwnersImpl } from "@trustvc/trustvc"; +import { beforeEach, describe, expect, it, vi, MockedFunction } from "vitest"; +import { TitleEscrowEndorseTransferOfOwnersCommand } from "../../../src/types"; +import { + transferOwners, + endorseChangeOwnerHandler, + handler, + promptForInputs, +} from "../../../src/commands/title-escrow/endorse-change-of-owner"; +import { NetworkCmdName } from "../../../src/utils"; + +vi.mock("@inquirer/prompts"); + +vi.mock("signale", async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock("@trustvc/trustvc", async () => { + const actual = await vi.importActual("@trustvc/trustvc"); + return { + ...actual, + transferOwners: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/helpers', () => { + const mockTitleEscrow = { + beneficiary: vi.fn().mockResolvedValue('0x3333333333333333333333333333333333333333'), + holder: vi.fn().mockResolvedValue('0x4444444444444444444444444444444444444444'), + }; + + return { + connectToTitleEscrow: vi.fn().mockResolvedValue(mockTitleEscrow), + validateEndorseChangeOwner: vi.fn().mockImplementation(async ({ newOwner, newHolder, titleEscrow }) => { + const beneficiary = await titleEscrow.beneficiary(); + const holder = await titleEscrow.holder(); + if (newOwner === beneficiary && newHolder === holder) { + throw new Error('new owner and new holder addresses are the same as the current owner and holder addresses'); + } + }), + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), + }; +}); + +vi.mock("../../../src/utils/wallet", () => ({ + getWalletOrSigner: vi.fn(), +})); + +vi.mock("../../../src/utils", async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => "https://etherscan.io"), + displayTransactionPrice: vi.fn(), + canEstimateGasPrice: vi.fn(() => false), + getGasFees: vi.fn(), + }; +}); + +const endorseChangeOwnersParams: TitleEscrowEndorseTransferOfOwnersCommand = { + newHolder: "0x1111111111111111111111111111111111111111", + newOwner: "0x2222222222222222222222222222222222222222", + tokenId: "0x12345", + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + network: "astron", + maxPriorityFeePerGasScale: 1, + dryRun: false, +}; + +describe("title-escrow/endorse-change-owner", () => { + // increase timeout because ethers is throttling + vi.setConfig({ testTimeout: 30_000 }); + vi.spyOn(global, "fetch").mockImplementation( + vi.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + standard: { + maxPriorityFee: 0, + maxFee: 0, + }, + }), + }) + ) as any + ); + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe("promptForInputs", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should return correct answers for valid inputs with encrypted wallet", async () => { + const mockInputs = { + network: NetworkCmdName.Astron, + tokenRegistry: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newOwner: "0x0987654321098765432109876543210987654321", + newHolder: "0x1111111111111111111111111111111111111111", + remark: "Test remark", + encryptionKey: "test-key", + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce("encryptedWallet"); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newOwner) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce("./wallet.json") + .mockResolvedValueOnce(mockInputs.remark) + .mockResolvedValueOnce(mockInputs.encryptionKey); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.newOwner).toBe(mockInputs.newOwner); + expect(result.newHolder).toBe(mockInputs.newHolder); + expect((result as any).encryptedWalletPath).toBe("./wallet.json"); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.encryptionKey); + expect(result.dryRun).toBe(false); + expect(result.maxPriorityFeePerGasScale).toBe(1); + }); + + it("should return correct answers for valid inputs with private key file", async () => { + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newOwner: "0x0987654321098765432109876543210987654321", + newHolder: "0x1111111111111111111111111111111111111111", + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce("keyFile"); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newOwner) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce("./private-key.txt") + .mockResolvedValueOnce(""); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).keyFile).toBe("./private-key.txt"); + expect(result.remark).toBeUndefined(); + expect(result.encryptionKey).toBeUndefined(); + }); + + it("should return correct answers for valid inputs with direct private key", async () => { + const mockInputs = { + network: NetworkCmdName.Astron, + tokenRegistry: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newOwner: "0x0987654321098765432109876543210987654321", + newHolder: "0x1111111111111111111111111111111111111111", + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce("keyDirect"); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newOwner) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + .mockResolvedValueOnce(""); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).key).toBe( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + ); + }); + + it("should return correct answers when using environment variable for private key", async () => { + const originalEnv = process.env.OA_PRIVATE_KEY; + process.env.OA_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + const mockInputs = { + network: NetworkCmdName.Astron, + tokenRegistry: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newOwner: "0x0987654321098765432109876543210987654321", + newHolder: "0x1111111111111111111111111111111111111111", + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce("envVariable"); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newOwner) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce(""); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).key).toBeUndefined(); + expect((result as any).keyFile).toBeUndefined(); + expect((result as any).encryptedWalletPath).toBeUndefined(); + + if (originalEnv) { + process.env.OA_PRIVATE_KEY = originalEnv; + } else { + delete process.env.OA_PRIVATE_KEY; + } + }); + + it("should throw error when OA_PRIVATE_KEY environment variable is not set", async () => { + const originalEnv = process.env.OA_PRIVATE_KEY; + delete process.env.OA_PRIVATE_KEY; + + (prompts.select as any) + .mockResolvedValueOnce(NetworkCmdName.Astron) + .mockResolvedValueOnce("envVariable"); + + (prompts.input as any) + .mockResolvedValueOnce("0x1234567890123456789012345678901234567890") + .mockResolvedValueOnce("0xabcdef1234567890") + .mockResolvedValueOnce("0x0987654321098765432109876543210987654321") + .mockResolvedValueOnce("0x1111111111111111111111111111111111111111"); + + await expect(promptForInputs()).rejects.toThrowError( + "OA_PRIVATE_KEY environment variable is not set. Please set it or choose another option." + ); + + if (originalEnv) { + process.env.OA_PRIVATE_KEY = originalEnv; + } + }); + }); + + describe("endorseChangeOwnerHandler", () => { + let transferOwnersMock: MockedFunction; + let getWalletOrSignerMock: MockedFunction; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import("@trustvc/trustvc"); + transferOwnersMock = trustvcModule.transferOwners as MockedFunction; + + const walletModule = await import("../../../src/utils/wallet"); + getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + }); + + it("should successfully endorse change of owner and display transaction details", async () => { + const mockArgs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newOwner: "0x0987654321098765432109876543210987654321", + newHolder: "0x1111111111111111111111111111111111111111", + encryptedWalletPath: "./wallet.json", + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: "0xtxhash123", + blockNumber: 12345, + blockHash: "0xblockhash", + confirmations: 1, + from: "0xfrom", + to: mockArgs.tokenRegistryAddress, + gasUsed: { toNumber: () => 100000 } as any, + cumulativeGasUsed: { toNumber: () => 100000 } as any, + effectiveGasPrice: { toNumber: () => 1000000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: "", + transactionIndex: 0, + logs: [], + logsBloom: "0x", + }; + + transferOwnersMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const result = await endorseChangeOwnerHandler(mockArgs); + + expect(transferOwnersMock).toHaveBeenCalledWith( + { tokenRegistryAddress: mockArgs.tokenRegistryAddress, tokenId: mockArgs.tokenId }, + expect.anything(), + expect.objectContaining({ + newBeneficiaryAddress: mockArgs.newOwner, + newHolderAddress: mockArgs.newHolder, + }), + expect.anything() + ); + expect(result).toBe(mockArgs.tokenRegistryAddress); + }); + + it("should handle endorse change of owner with remark and encryption key", async () => { + const mockArgs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newOwner: "0x0987654321098765432109876543210987654321", + newHolder: "0x1111111111111111111111111111111111111111", + remark: "Important transfer", + encryptionKey: "secret-key-123", + key: "0xprivatekey", + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: "0xtxhash456", + blockNumber: 12346, + blockHash: "0xblockhash2", + confirmations: 1, + from: "0xfrom", + to: mockArgs.tokenRegistryAddress, + gasUsed: { toNumber: () => 120000 } as any, + cumulativeGasUsed: { toNumber: () => 120000 } as any, + effectiveGasPrice: { toNumber: () => 1500000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: "", + transactionIndex: 0, + logs: [], + logsBloom: "0x", + }; + + transferOwnersMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const result = await endorseChangeOwnerHandler(mockArgs); + + expect(transferOwnersMock).toHaveBeenCalledWith( + { tokenRegistryAddress: mockArgs.tokenRegistryAddress, tokenId: mockArgs.tokenId }, + expect.anything(), + expect.objectContaining({ + newBeneficiaryAddress: mockArgs.newOwner, + newHolderAddress: mockArgs.newHolder, + remarks: mockArgs.remark, + }), + expect.objectContaining({ + id: mockArgs.encryptionKey, + }) + ); + expect(result).toBe(mockArgs.tokenRegistryAddress); + }); + + it("should handle errors during endorse change of owner", async () => { + const mockArgs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newOwner: "0x0987654321098765432109876543210987654321", + newHolder: "0x1111111111111111111111111111111111111111", + encryptedWalletPath: "./wallet.json", + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const errorMessage = "Transaction failed: insufficient funds"; + transferOwnersMock.mockRejectedValue(new Error(errorMessage)); + + const result = await endorseChangeOwnerHandler(mockArgs); + + expect(result).toBeUndefined(); + expect(transferOwnersMock).toHaveBeenCalled(); + }); + + it("should handle non-Error exceptions during endorse change of owner", async () => { + const mockArgs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newOwner: "0x0987654321098765432109876543210987654321", + newHolder: "0x1111111111111111111111111111111111111111", + encryptedWalletPath: "./wallet.json", + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + transferOwnersMock.mockRejectedValue("String error message"); + + const result = await endorseChangeOwnerHandler(mockArgs); + + expect(result).toBeUndefined(); + }); + }); + + describe("handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should successfully execute the complete endorse change owner flow", async () => { + const mockInputs: any = { + network: NetworkCmdName.Astron, + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + tokenId: "0xabcdef1234567890", + newOwner: "0x0987654321098765432109876543210987654321", + newHolder: "0x1111111111111111111111111111111111111111", + encryptedWalletPath: "./wallet.json", + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: "0xtxhash", + blockNumber: 12345, + blockHash: "0xblockhash", + confirmations: 1, + from: "0xfrom", + to: mockInputs.tokenRegistryAddress, + gasUsed: { toNumber: () => 100000 } as any, + cumulativeGasUsed: { toNumber: () => 100000 } as any, + effectiveGasPrice: { toNumber: () => 1000000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: "", + transactionIndex: 0, + logs: [], + logsBloom: "0x", + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce("encryptedWallet"); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistryAddress) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newOwner) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce(mockInputs.encryptedWalletPath) + .mockResolvedValueOnce(""); + + const trustvcModule = await import("@trustvc/trustvc"); + const transferOwnersMock = trustvcModule.transferOwners as MockedFunction; + transferOwnersMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const walletModule = await import("../../../src/utils/wallet"); + const getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + + const result = await handler(); + + expect(result).toBeUndefined(); + }); + + it("should handle errors in handler", async () => { + const errorMessage = "Prompt error"; + (prompts.select as any).mockRejectedValue(new Error(errorMessage)); + + await handler(); + + const signaleModule = await import("signale"); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + + it("should handle non-Error exceptions in handler", async () => { + const errorMessage = "String error"; + (prompts.select as any).mockRejectedValue(errorMessage); + + await handler(); + + const signaleModule = await import("signale"); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + }); + + describe("transferOwners", () => { + beforeEach(async () => { + delete process.env.OA_PRIVATE_KEY; + vi.mocked(transferOwnersImpl).mockResolvedValue({ + hash: "hash", + wait: () => Promise.resolve({ transactionHash: "transactionHash" }), + } as any); + + const walletModule = await import("../../../src/utils/wallet"); + const getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + + // Re-setup the helpers mocks + const helpersModule = await import("../../../src/commands/helpers"); + const mockTitleEscrow = { + beneficiary: vi.fn().mockResolvedValue('0x3333333333333333333333333333333333333333'), + holder: vi.fn().mockResolvedValue('0x4444444444444444444444444444444444444444'), + }; + + const connectToTitleEscrowMock = helpersModule.connectToTitleEscrow as MockedFunction; + connectToTitleEscrowMock.mockResolvedValue(mockTitleEscrow); + + const validateEndorseChangeOwnerMock = helpersModule.validateEndorseChangeOwner as MockedFunction; + validateEndorseChangeOwnerMock.mockImplementation(async ({ newOwner, newHolder, titleEscrow }: any) => { + const beneficiary = await titleEscrow.beneficiary(); + const holder = await titleEscrow.holder(); + if (newOwner === beneficiary && newHolder === holder) { + throw new Error('new owner and new holder addresses are the same as the current owner and holder addresses'); + } + }); + }); + + it("should pass in the correct params and call the following procedures to invoke an endorsement of change of owner of a transferable record", async () => { + const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; + await transferOwners({ + ...endorseChangeOwnersParams, + key: privateKey, + }); + + expect(transferOwnersImpl).toHaveBeenCalledTimes(1); + }); + + it("should throw an error if new owner and new holder addresses are the same as current owner and holder addressses", async () => { + const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; + await expect( + transferOwners({ + ...endorseChangeOwnersParams, + newOwner: '0x3333333333333333333333333333333333333333', + newHolder: '0x4444444444444444444444444444444444444444', + key: privateKey, + }) + ).rejects.toThrow("new owner and new holder addresses are the same as the current owner and holder addresses"); + }); + }); +}); diff --git a/tests/commands/title-escrow/transferOwners-astrontestnet.test.ts b/tests/commands/title-escrow/transferOwners-astrontestnet.test.ts new file mode 100644 index 0000000..5916632 --- /dev/null +++ b/tests/commands/title-escrow/transferOwners-astrontestnet.test.ts @@ -0,0 +1,91 @@ +import { transferOwners as transferOwnersImpl } from "@trustvc/trustvc"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TitleEscrowEndorseTransferOfOwnersCommand } from "../../../src/types"; +import { transferOwners } from "../../../src/commands/title-escrow/endorse-change-of-owner"; + +vi.mock("@trustvc/trustvc", async () => { + const actual = await vi.importActual("@trustvc/trustvc"); + return { + ...actual, + transferOwners: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/helpers', () => { + const mockTitleEscrow = { + beneficiary: vi.fn().mockResolvedValue('0x3333333333333333333333333333333333333333'), + holder: vi.fn().mockResolvedValue('0x4444444444444444444444444444444444444444'), + }; + + return { + connectToTitleEscrow: vi.fn().mockResolvedValue(mockTitleEscrow), + validateEndorseChangeOwner: vi.fn().mockImplementation(async ({ newOwner, newHolder, titleEscrow }) => { + const beneficiary = await titleEscrow.beneficiary(); + const holder = await titleEscrow.holder(); + if (newOwner === beneficiary && newHolder === holder) { + throw new Error('new owner and new holder addresses are the same as the current owner and holder addresses'); + } + }), + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), + }; +}); + +const endorseChangeOwnersParams: TitleEscrowEndorseTransferOfOwnersCommand = { + newHolder: "0x1111111111111111111111111111111111111111", + newOwner: "0x2222222222222222222222222222222222222222", + tokenId: "0x12345", + tokenRegistryAddress: "0x1234567890123456789012345678901234567890", + network: "astrontestnet", + maxPriorityFeePerGasScale: 1, + dryRun: false, +}; + +describe("title-escrow", () => { + // increase timeout because ethers is throttling + vi.setConfig({ testTimeout: 30_000 }); + vi.spyOn(global, "fetch").mockImplementation( + vi.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + standard: { + maxPriorityFee: 0, + maxFee: 0, + }, + }), + }) + ) as any + ); + + describe("endorse change of owners of transferable record", () => { + beforeEach(() => { + delete process.env.OA_PRIVATE_KEY; + vi.mocked(transferOwnersImpl).mockResolvedValue({ + hash: "hash", + wait: () => Promise.resolve({ transactionHash: "transactionHash" }), + } as any); + }); + + it("should pass in the correct params and call the following procedures to invoke an endorsement of change of owner of a transferable record", async () => { + const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; + await transferOwners({ + ...endorseChangeOwnersParams, + key: privateKey, + }); + + expect(transferOwnersImpl).toHaveBeenCalledTimes(1); + }); + + it("should throw an error if new owner and new holder addresses are the same as current owner and holder addressses", async () => { + const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; + await expect( + transferOwners({ + ...endorseChangeOwnersParams, + newOwner: '0x3333333333333333333333333333333333333333', + newHolder: '0x4444444444444444444444444444444444444444', + key: privateKey, + }) + ).rejects.toThrow("new owner and new holder addresses are the same as the current owner and holder addresses"); + }); + }); +}); diff --git a/tests/commands/title-escrow/transferOwners.test.ts b/tests/commands/title-escrow/transferOwners.test.ts new file mode 100644 index 0000000..6f52c31 --- /dev/null +++ b/tests/commands/title-escrow/transferOwners.test.ts @@ -0,0 +1,585 @@ +import { TransactionReceipt } from '@ethersproject/providers'; +import * as prompts from '@inquirer/prompts'; +import { transferOwners as transferOwnersImpl } from '@trustvc/trustvc'; +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { TitleEscrowEndorseTransferOfOwnersCommand } from '../../../src/types'; +import { + transferOwners, + endorseChangeOwnerHandler, + handler, + promptForInputs, +} from '../../../src/commands/title-escrow/endorse-change-of-owner'; +import { NetworkCmdName } from '../../../src/utils'; + +vi.mock('@inquirer/prompts'); + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + transferOwners: vi.fn(), + }; +}); + +vi.mock('../../../src/commands/helpers', () => { + const mockTitleEscrow = { + beneficiary: vi.fn().mockResolvedValue('0x3333333333333333333333333333333333333333'), + holder: vi.fn().mockResolvedValue('0x4444444444444444444444444444444444444444'), + }; + + return { + connectToTitleEscrow: vi.fn().mockResolvedValue(mockTitleEscrow), + validateEndorseChangeOwner: vi + .fn() + .mockImplementation(async ({ newOwner, newHolder, titleEscrow }) => { + const beneficiary = await titleEscrow.beneficiary(); + const holder = await titleEscrow.holder(); + if (newOwner === beneficiary && newHolder === holder) { + throw new Error( + 'new owner and new holder addresses are the same as the current owner and holder addresses', + ); + } + }), + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), + }; +}); + +vi.mock('../../../src/utils/wallet', () => ({ + getWalletOrSigner: vi.fn(), +})); + +vi.mock('../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + displayTransactionPrice: vi.fn(), + canEstimateGasPrice: vi.fn(() => false), + getGasFees: vi.fn(), + }; +}); + +const endorseChangeOwnersParams: TitleEscrowEndorseTransferOfOwnersCommand = { + newHolder: '0x1111111111111111111111111111111111111111', + newOwner: '0x2222222222222222222222222222222222222222', + tokenId: '0x12345', + remark: '0xabcd', + encryptionKey: '1234', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + network: 'sepolia', + maxPriorityFeePerGasScale: 1, + dryRun: false, +}; + +describe('title-escrow/endorse-change-owner', () => { + vi.setConfig({ testTimeout: 30_000 }); + vi.spyOn(global, 'fetch').mockImplementation( + vi.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + standard: { + maxPriorityFee: 0, + maxFee: 0, + }, + }), + }), + ) as any, + ); + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('promptForInputs', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should return correct answers for valid inputs with encrypted wallet', async () => { + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newOwner: '0x0987654321098765432109876543210987654321', + newHolder: '0x1111111111111111111111111111111111111111', + remark: 'Test remark', + encryptionKey: 'test-key', + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce('encryptedWallet'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newOwner) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce('./wallet.json') + .mockResolvedValueOnce(mockInputs.remark) + .mockResolvedValueOnce(mockInputs.encryptionKey); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.newOwner).toBe(mockInputs.newOwner); + expect(result.newHolder).toBe(mockInputs.newHolder); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.encryptionKey); + expect(result.dryRun).toBe(false); + expect(result.maxPriorityFeePerGasScale).toBe(1); + }); + + it('should return correct answers for valid inputs with private key file', async () => { + const mockInputs = { + network: NetworkCmdName.Mainnet, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newOwner: '0x0987654321098765432109876543210987654321', + newHolder: '0x1111111111111111111111111111111111111111', + }; + + (prompts.select as any).mockResolvedValueOnce(mockInputs.network).mockResolvedValueOnce('keyFile'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newOwner) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce('./private-key.txt') + .mockResolvedValueOnce(''); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).keyFile).toBe('./private-key.txt'); + expect(result.remark).toBeUndefined(); + expect(result.encryptionKey).toBeUndefined(); + }); + + it('should return correct answers for valid inputs with direct private key', async () => { + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newOwner: '0x0987654321098765432109876543210987654321', + newHolder: '0x1111111111111111111111111111111111111111', + }; + + (prompts.select as any).mockResolvedValueOnce(mockInputs.network).mockResolvedValueOnce('keyDirect'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newOwner) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') + .mockResolvedValueOnce(''); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).key).toBe('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'); + }); + + it('should return correct answers when using environment variable for private key', async () => { + const originalEnv = process.env.OA_PRIVATE_KEY; + process.env.OA_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + + const mockInputs = { + network: NetworkCmdName.Sepolia, + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newOwner: '0x0987654321098765432109876543210987654321', + newHolder: '0x1111111111111111111111111111111111111111', + }; + + (prompts.select as any).mockResolvedValueOnce(mockInputs.network).mockResolvedValueOnce('envVariable'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistry) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newOwner) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce(''); + + const result = await promptForInputs(); + + expect(result.network).toBe(mockInputs.network); + expect((result as any).key).toBeUndefined(); + expect((result as any).keyFile).toBeUndefined(); + expect((result as any).encryptedWalletPath).toBeUndefined(); + + if (originalEnv) { + process.env.OA_PRIVATE_KEY = originalEnv; + } else { + delete process.env.OA_PRIVATE_KEY; + } + }); + + it('should throw error when OA_PRIVATE_KEY environment variable is not set', async () => { + const originalEnv = process.env.OA_PRIVATE_KEY; + delete process.env.OA_PRIVATE_KEY; + + (prompts.select as any).mockResolvedValueOnce(NetworkCmdName.Sepolia).mockResolvedValueOnce('envVariable'); + + (prompts.input as any) + .mockResolvedValueOnce('0x1234567890123456789012345678901234567890') + .mockResolvedValueOnce('0xabcdef1234567890') + .mockResolvedValueOnce('0x0987654321098765432109876543210987654321') + .mockResolvedValueOnce('0x1111111111111111111111111111111111111111'); + + await expect(promptForInputs()).rejects.toThrowError( + 'OA_PRIVATE_KEY environment variable is not set. Please set it or choose another option.', + ); + + if (originalEnv) { + process.env.OA_PRIVATE_KEY = originalEnv; + } + }); + }); + + describe('endorseChangeOwnerHandler', () => { + let transferOwnersMock: MockedFunction; + let getWalletOrSignerMock: MockedFunction; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import('@trustvc/trustvc'); + transferOwnersMock = trustvcModule.transferOwners as MockedFunction; + + const walletModule = await import('../../../src/utils/wallet'); + getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + }); + + it('should successfully endorse change of owner and display transaction details', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newOwner: '0x0987654321098765432109876543210987654321', + newHolder: '0x1111111111111111111111111111111111111111', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash123', + blockNumber: 12345, + blockHash: '0xblockhash', + confirmations: 1, + from: '0xfrom', + to: mockArgs.tokenRegistryAddress, + gasUsed: { toNumber: () => 100000 } as any, + cumulativeGasUsed: { toNumber: () => 100000 } as any, + effectiveGasPrice: { toNumber: () => 1000000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + transferOwnersMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const result = await endorseChangeOwnerHandler(mockArgs); + + expect(transferOwnersMock).toHaveBeenCalledWith( + { tokenRegistryAddress: mockArgs.tokenRegistryAddress, tokenId: mockArgs.tokenId }, + expect.anything(), + expect.objectContaining({ + newBeneficiaryAddress: mockArgs.newOwner, + newHolderAddress: mockArgs.newHolder, + }), + expect.anything(), + ); + expect(result).toBe(mockArgs.tokenRegistryAddress); + }); + + it('should handle endorse change of owner with remark and encryption key', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newOwner: '0x0987654321098765432109876543210987654321', + newHolder: '0x1111111111111111111111111111111111111111', + remark: 'Important transfer', + encryptionKey: 'secret-key-123', + key: '0xprivatekey', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash456', + blockNumber: 12346, + blockHash: '0xblockhash2', + confirmations: 1, + from: '0xfrom', + to: mockArgs.tokenRegistryAddress, + gasUsed: { toNumber: () => 120000 } as any, + cumulativeGasUsed: { toNumber: () => 120000 } as any, + effectiveGasPrice: { toNumber: () => 1500000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + transferOwnersMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const result = await endorseChangeOwnerHandler(mockArgs); + + expect(transferOwnersMock).toHaveBeenCalledWith( + { tokenRegistryAddress: mockArgs.tokenRegistryAddress, tokenId: mockArgs.tokenId }, + expect.anything(), + expect.objectContaining({ + newBeneficiaryAddress: mockArgs.newOwner, + newHolderAddress: mockArgs.newHolder, + remarks: mockArgs.remark, + }), + expect.objectContaining({ + id: mockArgs.encryptionKey, + }), + ); + expect(result).toBe(mockArgs.tokenRegistryAddress); + }); + + it('should handle errors during endorse change of owner', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newOwner: '0x0987654321098765432109876543210987654321', + newHolder: '0x1111111111111111111111111111111111111111', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const errorMessage = 'Transaction failed: insufficient funds'; + transferOwnersMock.mockRejectedValue(new Error(errorMessage)); + + const result = await endorseChangeOwnerHandler(mockArgs); + + expect(result).toBeUndefined(); + expect(transferOwnersMock).toHaveBeenCalled(); + }); + + it('should handle non-Error exceptions during endorse change of owner', async () => { + const mockArgs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newOwner: '0x0987654321098765432109876543210987654321', + newHolder: '0x1111111111111111111111111111111111111111', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + transferOwnersMock.mockRejectedValue('String error message'); + + const result = await endorseChangeOwnerHandler(mockArgs); + + expect(result).toBeUndefined(); + }); + }); + + describe('handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should successfully execute the complete endorse change owner flow', async () => { + const mockInputs: any = { + network: NetworkCmdName.Sepolia, + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newOwner: '0x0987654321098765432109876543210987654321', + newHolder: '0x1111111111111111111111111111111111111111', + encryptedWalletPath: './wallet.json', + dryRun: false, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction: TransactionReceipt = { + transactionHash: '0xtxhash', + blockNumber: 12345, + blockHash: '0xblockhash', + confirmations: 1, + from: '0xfrom', + to: mockInputs.tokenRegistryAddress, + gasUsed: { toNumber: () => 100000 } as any, + cumulativeGasUsed: { toNumber: () => 100000 } as any, + effectiveGasPrice: { toNumber: () => 1000000000 } as any, + byzantium: true, + type: 2, + status: 1, + contractAddress: '', + transactionIndex: 0, + logs: [], + logsBloom: '0x', + }; + + (prompts.select as any) + .mockResolvedValueOnce(mockInputs.network) + .mockResolvedValueOnce('encryptedWallet'); + + (prompts.input as any) + .mockResolvedValueOnce(mockInputs.tokenRegistryAddress) + .mockResolvedValueOnce(mockInputs.tokenId) + .mockResolvedValueOnce(mockInputs.newOwner) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce(mockInputs.encryptedWalletPath) + .mockResolvedValueOnce(''); + + const trustvcModule = await import('@trustvc/trustvc'); + const transferOwnersMock = trustvcModule.transferOwners as MockedFunction; + transferOwnersMock.mockResolvedValue({ + hash: mockTransaction.transactionHash, + wait: vi.fn().mockResolvedValue(mockTransaction), + }); + + const walletModule = await import('../../../src/utils/wallet'); + const getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + + const result = await handler(); + + expect(result).toBeUndefined(); + }); + + it('should handle errors in handler', async () => { + const errorMessage = 'Prompt error'; + (prompts.select as any).mockRejectedValue(new Error(errorMessage)); + + await handler(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + + it('should handle non-Error exceptions in handler', async () => { + const errorMessage = 'String error'; + (prompts.select as any).mockRejectedValue(errorMessage); + + await handler(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + }); + + describe('transferOwners', () => { + beforeEach(async () => { + delete process.env.OA_PRIVATE_KEY; + vi.mocked(transferOwnersImpl).mockResolvedValue({ + hash: 'hash', + wait: () => Promise.resolve({ transactionHash: 'transactionHash' }), + } as any); + + const walletModule = await import('../../../src/utils/wallet'); + const getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + }); + + // Re-setup the helpers mocks + const helpersModule = await import('../../../src/commands/helpers'); + const mockTitleEscrow = { + beneficiary: vi.fn().mockResolvedValue('0x3333333333333333333333333333333333333333'), + holder: vi.fn().mockResolvedValue('0x4444444444444444444444444444444444444444'), + }; + + const connectToTitleEscrowMock = helpersModule.connectToTitleEscrow as MockedFunction; + connectToTitleEscrowMock.mockResolvedValue(mockTitleEscrow); + + const validateEndorseChangeOwnerMock = helpersModule.validateEndorseChangeOwner as MockedFunction; + validateEndorseChangeOwnerMock.mockImplementation(async ({ newOwner, newHolder, titleEscrow }: any) => { + const beneficiary = await titleEscrow.beneficiary(); + const holder = await titleEscrow.holder(); + if (newOwner === beneficiary && newHolder === holder) { + throw new Error('new owner and new holder addresses are the same as the current owner and holder addresses'); + } + }); + }); + + it('should pass in the correct params and call the following procedures to invoke an endorsement of change of owner of a transferable record', async () => { + const privateKey = '0000000000000000000000000000000000000000000000000000000000000001'; + await transferOwners({ + ...endorseChangeOwnersParams, + key: privateKey, + }); + + expect(transferOwnersImpl).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if new owner and new holder addresses are the same as current owner and holder addresses', async () => { + const privateKey = '0000000000000000000000000000000000000000000000000000000000000001'; + await expect( + transferOwners({ + ...endorseChangeOwnersParams, + newOwner: '0x3333333333333333333333333333333333333333', + newHolder: '0x4444444444444444444444444444444444444444', + key: privateKey, + }), + ).rejects.toThrow( + 'new owner and new holder addresses are the same as the current owner and holder addresses', + ); + }); + }); +}); diff --git a/tests/commands/token-registry/mint.test.ts b/tests/commands/token-registry/mint.test.ts index eced170..095d2bd 100644 --- a/tests/commands/token-registry/mint.test.ts +++ b/tests/commands/token-registry/mint.test.ts @@ -1,11 +1,11 @@ -import * as prompts from '@inquirer/prompts'; import { TransactionReceipt } from '@ethersproject/providers'; +import * as prompts from '@inquirer/prompts'; import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; import { - mintHandler as handler, + handler, mintToken, promptForInputs, -} from '../../../src/commands/token-registry/token-registry'; +} from '../../../src/commands/token-registry/mint'; import { NetworkCmdName } from '../../../src/utils'; vi.mock('@inquirer/prompts'); diff --git a/tests/utils/networks.test.ts b/tests/utils/networks.test.ts index fec1158..761f94f 100644 --- a/tests/utils/networks.test.ts +++ b/tests/utils/networks.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { getSupportedNetwork, NetworkCmdName } from '../../src/utils/networks'; -import { providers } from 'ethers'; +import { JsonRpcProvider, InfuraProvider } from 'ethers'; describe('networks', () => { describe('environment variable RPC override', () => { @@ -24,8 +24,8 @@ describe('networks', () => { const provider = network.provider(); // Check that the provider is using the custom RPC - expect(provider).toBeInstanceOf(providers.JsonRpcProvider); - expect((provider as providers.JsonRpcProvider).connection.url).toBe(customRpc); + expect(provider).toBeInstanceOf(JsonRpcProvider); + expect((provider as any)._getConnection().url).toBe(customRpc); }); it('should use default RPC when no environment variable is set', () => { @@ -35,7 +35,7 @@ describe('networks', () => { const provider = network.provider(); // The default Infura provider should be used - expect(provider).toBeInstanceOf(providers.InfuraProvider); + expect(provider).toBeInstanceOf(InfuraProvider); }); it('should use custom RPC for MAINNET_RPC', () => { @@ -45,8 +45,8 @@ describe('networks', () => { const network = getSupportedNetwork(NetworkCmdName.Mainnet); const provider = network.provider(); - expect(provider).toBeInstanceOf(providers.JsonRpcProvider); - expect((provider as providers.JsonRpcProvider).connection.url).toBe(customRpc); + expect(provider).toBeInstanceOf(JsonRpcProvider); + expect((provider as any)._getConnection().url).toBe(customRpc); }); it('should use custom RPC for AMOY_RPC', () => { @@ -56,8 +56,8 @@ describe('networks', () => { const network = getSupportedNetwork(NetworkCmdName.Amoy); const provider = network.provider(); - expect(provider).toBeInstanceOf(providers.JsonRpcProvider); - expect((provider as providers.JsonRpcProvider).connection.url).toBe(customRpc); + expect(provider).toBeInstanceOf(JsonRpcProvider); + expect((provider as any)._getConnection().url).toBe(customRpc); }); it('should use custom RPC for LOCAL_RPC', () => { @@ -67,8 +67,8 @@ describe('networks', () => { const network = getSupportedNetwork(NetworkCmdName.Local); const provider = network.provider(); - expect(provider).toBeInstanceOf(providers.JsonRpcProvider); - expect((provider as providers.JsonRpcProvider).connection.url).toBe(customRpc); + expect(provider).toBeInstanceOf(JsonRpcProvider); + expect((provider as any)._getConnection().url).toBe(customRpc); }); it('should handle multiple custom RPCs independently', () => { @@ -84,10 +84,10 @@ describe('networks', () => { const mainnetNetwork = getSupportedNetwork(NetworkCmdName.Mainnet); const mainnetProvider = mainnetNetwork.provider(); - expect(sepoliaProvider).toBeInstanceOf(providers.JsonRpcProvider); - expect((sepoliaProvider as providers.JsonRpcProvider).connection.url).toBe(customSepoliaRpc); - expect(mainnetProvider).toBeInstanceOf(providers.JsonRpcProvider); - expect((mainnetProvider as providers.JsonRpcProvider).connection.url).toBe(customMainnetRpc); + expect(sepoliaProvider).toBeInstanceOf(JsonRpcProvider); + expect((sepoliaProvider as any)._getConnection().url).toBe(customSepoliaRpc); + expect(mainnetProvider).toBeInstanceOf(JsonRpcProvider); + expect((mainnetProvider as any)._getConnection().url).toBe(customMainnetRpc); }); it('should use default for one network and custom for another', () => { @@ -101,10 +101,10 @@ describe('networks', () => { const mainnetNetwork = getSupportedNetwork(NetworkCmdName.Mainnet); const mainnetProvider = mainnetNetwork.provider(); - expect(sepoliaProvider).toBeInstanceOf(providers.JsonRpcProvider); - expect((sepoliaProvider as providers.JsonRpcProvider).connection.url).toBe(customSepoliaRpc); + expect(sepoliaProvider).toBeInstanceOf(JsonRpcProvider); + expect((sepoliaProvider as any)._getConnection().url).toBe(customSepoliaRpc); // Default Infura provider for mainnet - expect(mainnetProvider).toBeInstanceOf(providers.InfuraProvider); + expect(mainnetProvider).toBeInstanceOf(InfuraProvider); }); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index ad065a9..87e2fa6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ plugins: [], cacheDir: './node_modules/.vitest', test: { - include: ['tests/**/*.test.{ts,js}'], + include: ['tests/**/*.test.{ts,js}', 'src/**/*.test.{ts,js}'], exclude: ['dist', 'node_modules', '*/type{s}.{ts,js}'], coverage: { enabled: false,