diff --git a/.gitignore b/.gitignore index b0c1ad2..4c7a7f7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ didKeyPairs.json wellknown.json credentialStatus.json signed_vc.json +test.json +testSepolia.json # Dependencies node_modules/ diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index 9d210ae..7598ed5 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -1,7 +1,7 @@ // External dependencies -import { BytesLike, Wallet, HDNodeWallet, ZeroAddress } from 'ethers'; +import { BytesLike, Wallet, HDNodeWallet, ZeroAddress, Provider } from 'ethers'; import signale from 'signale'; -import { v5Contracts } from '@trustvc/trustvc'; +import { v5Contracts, checkSupportsInterface, v4SupportInterfaceIds, v5SupportInterfaceIds } from '@trustvc/trustvc'; import { encrypt } from '@trustvc/trustvc'; // Internal utilities @@ -9,6 +9,46 @@ import { ConnectedSigner } from '../utils'; // Contract factories from TrustVC v5 const { TitleEscrow__factory, TradeTrustToken__factory } = v5Contracts; + +// Interface for connectToTokenRegistry function arguments +interface ConnectToTokenRegistryArgs { + address: string; + wallet: Wallet | HDNodeWallet | ConnectedSigner; +} + +/** + * Connects to a token registry contract instance. + * + * @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 TradeTrustToken contract instance + * @throws Error if connection fails + */ +export const connectToTokenRegistry = async ({ + address, + wallet, +}: ConnectToTokenRegistryArgs): Promise> => { + try { + // Connect to the token registry contract + signale.info(`Connecting to token registry at: ${address}`); + const tokenRegistry = TradeTrustToken__factory.connect(address, wallet); + + // Validate the connection was successful + if (!tokenRegistry) { + const error = `Failed to connect to token registry at address: ${address}`; + signale.error(error); + throw new Error(error); + } + + signale.success(`Successfully connected to token registry`); + return tokenRegistry; + } catch (error) { + signale.error( + `Error in connectToTokenRegistry: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } +}; // Interface for connectToTitleEscrow function arguments interface ConnectToTitleEscrowArgs { tokenId: string; @@ -232,3 +272,44 @@ export const validateAndEncryptRemark = (remark?: string, keyId?: string): Bytes const encrpyted = encrypt(remark, keyId ?? ''); return encrpyted.startsWith('0x') ? encrpyted : `0x${encrpyted}`; }; + +/** + * Determines the version of a token registry contract by checking supported interfaces. + * Checks for V5 first, then V4 compatibility. + * + * @param tokenRegistryAddress - The address of the token registry contract + * @param provider - The provider to use for the contract call + * @returns Promise resolving to 'v5', 'v4', or 'unknown' + */ +export const getTokenRegistryVersion = async ( + tokenRegistryAddress: string, + provider: Provider, +): Promise<'v5' | 'v4' | 'unknown'> => { + try { + // Check if it's V5 + const isV5 = await checkSupportsInterface( + tokenRegistryAddress, + v5SupportInterfaceIds.SBT, + provider, + ); + + if (isV5) { + return 'v5'; + } + + // Check if it's V4 + const isV4 = await checkSupportsInterface( + tokenRegistryAddress, + v4SupportInterfaceIds.SBT, + provider, + ); + + if (isV4) { + return 'v4'; + } + + return 'unknown'; + } catch (error) { + throw new Error(`Failed to determine token registry version: ${error instanceof Error ? error.message : String(error)}`); + } +}; diff --git a/src/commands/token-registry/mint.ts b/src/commands/token-registry/mint.ts index 686948e..37c5cd4 100644 --- a/src/commands/token-registry/mint.ts +++ b/src/commands/token-registry/mint.ts @@ -1,4 +1,3 @@ -import { input } from '@inquirer/prompts'; import signale, { error, info, success } from 'signale'; import { TokenRegistryMintCommand } from '../../types'; import { @@ -7,14 +6,18 @@ import { getErrorMessage, getEtherscanAddress, NetworkCmdName, - promptRemarkAndEncryptionKey, - promptNetworkSelection, promptWalletSelection, getWalletOrSigner, canEstimateGasPrice, getGasFees, + extractDocumentInfo, + promptAndReadDocument, + promptRemark, + promptAddress, + performDryRunWithConfirmation, } from '../../utils'; -import { BigNumberish, TransactionReceipt } from 'ethers'; +import { connectToTokenRegistry, validateAndEncryptRemark } from '../helpers'; +import { TransactionReceipt } from 'ethers'; import { mint } from '@trustvc/trustvc'; export const command = 'mint'; @@ -34,82 +37,37 @@ export const handler = async (): Promise => { // 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; - }, - }); + // Extract document information using utility function + const document = await promptAndReadDocument(); - // 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; - }, - }); + // Extract document information using utility function + const { tokenRegistry, tokenId, network, documentId, registryVersion } = + await extractDocumentInfo(document); // 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; - }, - }); + const beneficiary = await promptAddress('beneficiary', 'initial recipient'); // 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; - }, - }); + const holder = await promptAddress('holder', 'initial holder'); // Wallet selection const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); - // Optional: Remark and Encryption Key - const { remark, encryptionKey } = await promptRemarkAndEncryptionKey(); + // Optional: Remark (only for V5) + const remark = await promptRemark(registryVersion); + + // Use document ID as encryption key + const encryptionKey = documentId; // Build the result object with proper typing const baseResult = { network, - address, + address: tokenRegistry, tokenId, beneficiary, holder, remark, encryptionKey, - dryRun: false, maxPriorityFeePerGasScale: 1, }; @@ -173,34 +131,74 @@ const mintToTokenRegistry = async ({ 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.'); + // Automatic dry run for Ethereum and Polygon networks + const shouldProceed = await performDryRunWithConfirmation({ + network, + getTransactionCallback: async () => { + const tokenRegistry = await connectToTokenRegistry({ address, wallet }); + + // Validate and encrypt the remark with document ID as encryption key + const encryptedRemark = validateAndEncryptRemark(remark, encryptionKey); + + // Populate the transaction for gas estimation + const tx = await tokenRegistry.mint.populateTransaction( + beneficiary, + holder, + tokenId, + encryptedRemark + ); + + // Ensure the transaction has a 'from' address for proper gas estimation + return { + ...tx, + from: await wallet.getAddress(), + }; + }, + }); + + if (!shouldProceed) { 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 }); - transactionOptions = { - maxFeePerGas: gasFees.maxFeePerGas as BigNumberish, - maxPriorityFeePerGas: gasFees.maxPriorityFeePerGas as BigNumberish, - }; + + // Execute mint with EIP-1559 gas parameters + transaction = await mint( + { tokenRegistryAddress: address }, + wallet, + { beneficiaryAddress: beneficiary, holderAddress: holder, tokenId, remarks: remark }, + { + id: encryptionKey, + maxFeePerGas: gasFees.maxFeePerGas?.toString(), + maxPriorityFeePerGas: gasFees.maxPriorityFeePerGas?.toString(), + }, + ); + } else { + // Execute mint without gas estimation (for networks that don't support it) + transaction = await mint( + { tokenRegistryAddress: address }, + wallet, + { beneficiaryAddress: beneficiary, holderAddress: holder, tokenId, remarks: remark }, + { + id: encryptionKey, + }, + ); } - 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) { diff --git a/src/types.ts b/src/types.ts index 1986c47..f67eb07 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,12 +1,18 @@ import { credentialStatus, issuer, RawVerifiableCredential } from '@trustvc/trustvc'; -import { GasOption, NetworkOption, RpcUrlOption, WalletOrSignerOption } from './utils'; +import { + GasOption, + NetworkAndWalletSignerOption, + NetworkOption, + RpcUrlOption, + WalletOrSignerOption, +} from './utils'; export type SignInput = { credential: RawVerifiableCredential; keyPairData: typeof issuer.IssuedDIDOption; encryptionAlgorithm: typeof credentialStatus.cryptoSuiteName; pathToSignedVC: string; -} +}; export type DidInput = { keyPairPath: string; domainName: string; diff --git a/src/utils/cli-options.ts b/src/utils/cli-options.ts index 6406d36..5af1420 100644 --- a/src/utils/cli-options.ts +++ b/src/utils/cli-options.ts @@ -1,7 +1,19 @@ -import { input, select } from '@inquirer/prompts'; -import { info } from 'signale'; +import { input, select, confirm } from '@inquirer/prompts'; +import { info, error } from 'signale'; import { Argv } from 'yargs'; -import { NetworkCmdName, supportedNetwork } from './networks'; +import { NetworkCmdName, supportedNetwork, getSupportedNetwork } from './networks'; +import { readDocumentFile } from './file-io'; +import { + getTokenRegistryAddress, + getTokenId, + getChainId, + CHAIN_ID, + SUPPORTED_CHAINS, +} from '@trustvc/trustvc'; +import fs from 'fs'; +import { getTokenRegistryVersion } from '../commands/helpers'; +import { getErrorMessage } from './index'; +import { dryRunMode } from './dryRun'; export interface NetworkOption { network: string; @@ -280,3 +292,253 @@ export const promptRemarkAndEncryptionKey = async (): Promise<{ encryptionKey: encryptionKey || undefined, }; }; + +/** + * Maps a chainId to a network name + * @param chainId - The chain ID from the document + * @returns The network name + */ +export const getNetworkFromChainId = (chainId: number): string => { + const chainIdMap: Record = { + 1: 'mainnet', + 11155111: 'sepolia', + 137: 'matic', + 80002: 'amoy', + 101010: 'stability', + 20180427: 'stabilitytestnet', + 1338: 'astron', + 21002: 'astrontestnet', + 50: 'xdc', + 51: 'xdcapothem', + 1337: 'local', + }; + + const network = chainIdMap[chainId]; + if (!network) { + throw new Error( + `Unsupported chainId: ${chainId}. Please add mapping or select network manually.`, + ); + } + + return network; +}; + +/** + * Prompts for document file path and reads the document. + * @returns The parsed document object + */ +export const promptAndReadDocument = async (): Promise => { + // Document file path + const documentPath = await input({ + message: 'Enter the path to the TT/JSON document file:', + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return 'Document file path is required'; + } + if (!fs.existsSync(value)) { + return 'File does not exist'; + } + if (!/\.(tt|json|jsonld)$/i.test(value)) { + return 'File must be a .tt, .json, or .jsonld file'; + } + return true; + }, + }); + + // Read and parse the document + let document: any; + try { + document = readDocumentFile(documentPath); + } catch (err) { + throw new Error( + `Failed to read document file: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return document; +}; + +/** + * Prompts for document file path, extracts and displays document information. + * @returns An object containing the document, tokenRegistry, tokenId, network, documentId, and registryVersion + */ +export const extractDocumentInfo = async ( + document: any, +): Promise<{ + document: any; + tokenRegistry: string; + tokenId: string; + network: string; + documentId: string; + registryVersion: string; +}> => { + // Extract information using trustvc utility functions + let tokenRegistry: string | undefined; + let tokenId: string | undefined; + let chainId: CHAIN_ID | undefined; + + try { + tokenRegistry = getTokenRegistryAddress(document); + tokenId = getTokenId(document); + chainId = getChainId(document); + } catch (err) { + throw new Error( + `Failed to extract document information: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Validate extracted values + if (!tokenRegistry) { + throw new Error('Document does not contain a valid token registry address'); + } + + if (!tokenId) { + throw new Error('Document does not contain a valid token ID'); + } + + if (!chainId) { + throw new Error('Document does not contain a valid chain ID'); + } + + // Map chainId to network name + const network = SUPPORTED_CHAINS[chainId].name; + + // Get provider to check token registry version (no wallet needed) + const provider = getSupportedNetwork(network).provider(); + const registryVersion = await getTokenRegistryVersion(tokenRegistry, provider); + + // Extract document ID + const documentId = document.id || 'N/A'; + + info(`Extracted from document:`); + info(` Network: ${network} (Chain ID: ${chainId})`); + info(` Token Registry (Version ${registryVersion.toUpperCase()}): ${tokenRegistry}`); + info(` Token ID: ${tokenId}`); + info(` Document ID: ${documentId}`); + + return { + document, + tokenRegistry, + tokenId, + network, + documentId, + registryVersion, + }; +}; + +/** + * Prompts for an optional remark based on token registry version. + * Only prompts if the registry version is V5. + * @param registryVersion - The token registry version ('v4' or 'v5') + * @returns The remark string or undefined + */ +export const promptRemark = async (registryVersion: string): Promise => { + if (registryVersion === 'v5') { + const remarkInput = await input({ + message: 'Enter a remark (optional, press Enter to skip):', + required: false, + }); + return remarkInput || undefined; + } else { + info('Remark is not supported for V4 token registries. Skipping remark input.'); + return undefined; + } +}; + +/** + * Prompts for an Ethereum address with validation. + * @param role - The role of the address (e.g., 'beneficiary', 'holder', 'new holder') + * @param description - Optional additional description (e.g., 'initial recipient', 'initial holder') + * @returns The validated Ethereum address + */ +export const promptAddress = async (role: string, description?: string): Promise => { + const roleCapitalized = role.charAt(0).toUpperCase() + role.slice(1); + const messageText = description + ? `Enter the address of the ${role} (${description}):` + : `Enter the address of the ${role}:`; + + const address = await input({ + message: messageText, + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + return `${roleCapitalized} address is required`; + } + if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { + return 'Invalid Ethereum address format'; + } + return true; + }, + }); + + return address; +}; + +/** + * Checks if the network requires automatic dry run (Ethereum and Polygon networks) + */ +export const shouldRunDryRun = (network: string): boolean => { + const dryRunNetworks = [ + NetworkCmdName.Mainnet, // Ethereum Mainnet + NetworkCmdName.Sepolia, // Ethereum Sepolia Testnet + NetworkCmdName.Matic, // Polygon Mainnet + NetworkCmdName.Amoy, // Polygon Amoy Testnet + ]; + return dryRunNetworks.includes(network as NetworkCmdName); +}; + +/** + * Performs automatic dry run for specified networks with gas estimation and user confirmation + * Uses the existing dryRunMode function for comprehensive display + */ +export const performDryRunWithConfirmation = async ({ + network, + getTransactionCallback, +}: { + network: string; + getTransactionCallback: () => Promise; +}): Promise => { + if (!shouldRunDryRun(network)) { + return true; // Proceed without dry run for other networks + } + + try { + // Get the populated transaction - dryRunMode will estimate gas automatically + const transaction = await getTransactionCallback(); + + // Use the existing dryRunMode function for comprehensive display + // It will automatically estimate gas from the transaction + await dryRunMode({ + network, + transaction, + }); + + // Ask user to proceed + const proceed = await confirm({ + message: '\nDo you want to proceed with the actual transaction?', + default: true, + }); + + if (!proceed) { + info('Transaction cancelled by user.'); + return false; + } + + info('\nāœ… Proceeding with transaction...'); + return true; + } catch (estimateError) { + error(`Gas estimation failed: ${getErrorMessage(estimateError)}`); + const proceedAnyway = await confirm({ + message: 'Gas estimation failed. Do you want to proceed anyway?', + default: false, + }); + + if (!proceedAnyway) { + info('Transaction cancelled by user.'); + return false; + } + + return true; + } +}; diff --git a/src/utils/file-io.ts b/src/utils/file-io.ts index f661453..45542b7 100644 --- a/src/utils/file-io.ts +++ b/src/utils/file-io.ts @@ -22,7 +22,7 @@ export const readFile = (filename: string): any => { return fs.readFileSync(filename, 'utf8'); }; -export const readOpenAttestationFile = (filename: string): any => { +export const readDocumentFile = (filename: string): any => { return JSON.parse(readFile(filename)); }; diff --git a/src/utils/networks.ts b/src/utils/networks.ts index 5071ded..20e9360 100644 --- a/src/utils/networks.ts +++ b/src/utils/networks.ts @@ -44,10 +44,7 @@ const jsonRpcProvider = * Example: SEPOLIA_RPC=https://sepolia.infura.io/v3/your-key */ const getProviderWithEnvOverride = - ( - networkName: NetworkCmdName, - defaultProvider: () => Provider, - ): (() => Provider) => + (networkName: NetworkCmdName, defaultProvider: () => Provider): (() => Provider) => () => { const envVarName = `${networkName.toUpperCase()}_RPC`; const customRpcUrl = process.env[envVarName]; @@ -109,7 +106,7 @@ export const supportedNetwork: { networkId: 80002, networkName: NetworkCmdName.Amoy, currency: 'MATIC', - gasStation: gasStation('https://gasstation.polygon.technology/amoy'), + gasStation: gasStation('https://gasstation.polygon.technology/v2'), }, [NetworkCmdName.XDC]: { explorer: 'https://xdcscan.io', diff --git a/tests/commands/token-registry/mint.test.ts b/tests/commands/token-registry/mint.test.ts index 095d2bd..639f3dd 100644 --- a/tests/commands/token-registry/mint.test.ts +++ b/tests/commands/token-registry/mint.test.ts @@ -1,14 +1,7 @@ import { TransactionReceipt } from '@ethersproject/providers'; -import * as prompts from '@inquirer/prompts'; import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; -import { - handler, - mintToken, - promptForInputs, -} from '../../../src/commands/token-registry/mint'; +import { handler, mintToken, promptForInputs } from '../../../src/commands/token-registry/mint'; import { NetworkCmdName } from '../../../src/utils'; - -vi.mock('@inquirer/prompts'); vi.mock('signale', async (importOriginal) => { const originalSignale = await importOriginal(); return { @@ -28,6 +21,18 @@ vi.mock('signale', async (importOriginal) => { vi.mock('@trustvc/trustvc', () => ({ mint: vi.fn(), + v5Contracts: { + TitleEscrow__factory: {}, + TradeTrustToken__factory: {}, + }, + checkSupportsInterface: vi.fn(), + v4SupportInterfaceIds: {}, + v5SupportInterfaceIds: {}, + encrypt: vi.fn(), + getTokenRegistryAddress: vi.fn(), + getTokenId: vi.fn(), + getChainId: vi.fn(), + SUPPORTED_CHAINS: {}, })); vi.mock('../../../src/utils/wallet', () => ({ @@ -47,9 +52,31 @@ vi.mock('../../../src/utils', async (importOriginal) => { displayTransactionPrice: vi.fn(), canEstimateGasPrice: vi.fn(() => false), getGasFees: vi.fn(), + promptAndReadDocument: vi.fn(), + extractDocumentInfo: vi.fn(), + promptAddress: vi.fn(), + promptWalletSelection: vi.fn(), + promptRemark: vi.fn(), + performDryRunWithConfirmation: vi.fn(async () => true), // Mock to always proceed }; }); +vi.mock('../../../src/commands/helpers', () => ({ + connectToTokenRegistry: vi.fn(async () => ({ + mint: { + populateTransaction: vi.fn(), + }, + })), + connectToTitleEscrow: vi.fn(), + validateEndorseChangeOwner: vi.fn(), + validateNominateBeneficiary: vi.fn(), + validatePreviousBeneficiary: vi.fn(), + validatePreviousHolder: vi.fn(), + validateEndorseTransferOwner: vi.fn(), + validateAndEncryptRemark: vi.fn((remark?: string) => (remark ? `0x${remark}` : '0x')), + getTokenRegistryVersion: vi.fn(), +})); + describe('token-registry/mint', () => { beforeEach(() => { vi.clearAllMocks(); @@ -70,21 +97,31 @@ describe('token-registry/mint', () => { beneficiary: '0x0987654321098765432109876543210987654321', holder: '0x1111111111111111111111111111111111111111', remark: 'Test remark', - encryptionKey: 'test-key', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', }; - (prompts.select as any) - .mockResolvedValueOnce(mockInputs.network) // Network selection - .mockResolvedValueOnce('encryptedWallet'); // Wallet option + const mockDocument = { + id: mockInputs.documentId, + tokenRegistry: mockInputs.address, + }; - (prompts.input as any) - .mockResolvedValueOnce(mockInputs.address) // Token registry address - .mockResolvedValueOnce(mockInputs.tokenId) // Token ID + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: mockInputs.address, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as any) .mockResolvedValueOnce(mockInputs.beneficiary) // Beneficiary - .mockResolvedValueOnce(mockInputs.holder) // Holder - .mockResolvedValueOnce('./wallet.json') // Encrypted wallet path - .mockResolvedValueOnce(mockInputs.remark) // Remark - .mockResolvedValueOnce(mockInputs.encryptionKey); // Encryption key + .mockResolvedValueOnce(mockInputs.holder); // Holder + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (utils.promptRemark as any).mockResolvedValue(mockInputs.remark); const result = await promptForInputs(); @@ -95,8 +132,7 @@ describe('token-registry/mint', () => { expect(result.holder).toBe(mockInputs.holder); 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.encryptionKey).toBe(mockInputs.documentId); expect(result.maxPriorityFeePerGasScale).toBe(1); }); @@ -107,26 +143,38 @@ describe('token-registry/mint', () => { tokenId: '0xabcdef1234567890', beneficiary: '0x0987654321098765432109876543210987654321', holder: '0x1111111111111111111111111111111111111111', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', }; - (prompts.select as any) - .mockResolvedValueOnce(mockInputs.network) - .mockResolvedValueOnce('keyFile'); + const mockDocument = { + id: mockInputs.documentId, + tokenRegistry: mockInputs.address, + }; - (prompts.input as any) - .mockResolvedValueOnce(mockInputs.address) - .mockResolvedValueOnce(mockInputs.tokenId) + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: mockInputs.address, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v4', + }); + (utils.promptAddress as any) .mockResolvedValueOnce(mockInputs.beneficiary) - .mockResolvedValueOnce(mockInputs.holder) - .mockResolvedValueOnce('./private-key.txt') // keyFile - .mockResolvedValueOnce(''); // Empty remark + .mockResolvedValueOnce(mockInputs.holder); + (utils.promptWalletSelection as any).mockResolvedValue({ + keyFile: './private-key.txt', + }); + (utils.promptRemark as any).mockResolvedValue(undefined); 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(); + expect(result.encryptionKey).toBe(mockInputs.documentId); }); it('should return correct answers for valid inputs with direct private key', async () => { @@ -136,19 +184,31 @@ describe('token-registry/mint', () => { tokenId: '0xabcdef1234567890', beneficiary: '0x0987654321098765432109876543210987654321', holder: '0x1111111111111111111111111111111111111111', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', }; - (prompts.select as any) - .mockResolvedValueOnce(mockInputs.network) - .mockResolvedValueOnce('keyDirect'); + const mockDocument = { + id: mockInputs.documentId, + tokenRegistry: mockInputs.address, + }; - (prompts.input as any) - .mockResolvedValueOnce(mockInputs.address) - .mockResolvedValueOnce(mockInputs.tokenId) + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: mockInputs.address, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as any) .mockResolvedValueOnce(mockInputs.beneficiary) - .mockResolvedValueOnce(mockInputs.holder) - .mockResolvedValueOnce('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') // key - .mockResolvedValueOnce(''); // Empty remark + .mockResolvedValueOnce(mockInputs.holder); + (utils.promptWalletSelection as any).mockResolvedValue({ + key: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + }); + (utils.promptRemark as any).mockResolvedValue(undefined); const result = await promptForInputs(); @@ -169,18 +229,29 @@ describe('token-registry/mint', () => { tokenId: '0xabcdef1234567890', beneficiary: '0x0987654321098765432109876543210987654321', holder: '0x1111111111111111111111111111111111111111', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', }; - (prompts.select as any) - .mockResolvedValueOnce(mockInputs.network) - .mockResolvedValueOnce('envVariable'); + const mockDocument = { + id: mockInputs.documentId, + tokenRegistry: mockInputs.address, + }; - (prompts.input as any) - .mockResolvedValueOnce(mockInputs.address) - .mockResolvedValueOnce(mockInputs.tokenId) + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: mockInputs.address, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as any) .mockResolvedValueOnce(mockInputs.beneficiary) - .mockResolvedValueOnce(mockInputs.holder) - .mockResolvedValueOnce(''); + .mockResolvedValueOnce(mockInputs.holder); + (utils.promptWalletSelection as any).mockResolvedValue({}); + (utils.promptRemark as any).mockResolvedValue(undefined); const result = await promptForInputs(); @@ -201,15 +272,38 @@ describe('token-registry/mint', () => { const originalEnv = process.env.OA_PRIVATE_KEY; delete process.env.OA_PRIVATE_KEY; - (prompts.select as any) - .mockResolvedValueOnce(NetworkCmdName.Sepolia) - .mockResolvedValueOnce('envVariable'); + const mockInputs = { + network: NetworkCmdName.Sepolia, + address: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + beneficiary: '0x0987654321098765432109876543210987654321', + holder: '0x1111111111111111111111111111111111111111', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; - (prompts.input as any) - .mockResolvedValueOnce('0x1234567890123456789012345678901234567890') - .mockResolvedValueOnce('0xabcdef1234567890') - .mockResolvedValueOnce('0x0987654321098765432109876543210987654321') - .mockResolvedValueOnce('0x1111111111111111111111111111111111111111'); + const mockDocument = { + id: mockInputs.documentId, + tokenRegistry: mockInputs.address, + }; + + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: mockInputs.address, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as any) + .mockResolvedValueOnce(mockInputs.beneficiary) + .mockResolvedValueOnce(mockInputs.holder); + (utils.promptWalletSelection as any).mockRejectedValue( + new Error( + 'OA_PRIVATE_KEY environment variable is not set. Please set it or choose another option.', + ), + ); await expect(promptForInputs()).rejects.toThrowError( 'OA_PRIVATE_KEY environment variable is not set. Please set it or choose another option.', @@ -221,18 +315,15 @@ describe('token-registry/mint', () => { } }); - it('should validate token registry address format', async () => { - const invalidAddress = 'invalid-address'; - - (prompts.select as any).mockResolvedValueOnce(NetworkCmdName.Sepolia); - (prompts.input as any).mockResolvedValueOnce(invalidAddress); - - // The validation happens in the prompt itself, we need to simulate it - // const addressPromptCall = (prompts.input as any).mock.calls; + it('should validate document file path', async () => { + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockRejectedValue( + new Error('Failed to read document file: File does not exist'), + ); - // Since we can't directly test the validation function in the prompt, - // we'll verify that the validation logic exists by checking the structure - expect(prompts.input).toBeDefined(); + await expect(promptForInputs()).rejects.toThrowError( + 'Failed to read document file: File does not exist', + ); }); it('should handle optional remark without encryption key', async () => { @@ -242,25 +333,36 @@ describe('token-registry/mint', () => { tokenId: '0xabcdef1234567890', beneficiary: '0x0987654321098765432109876543210987654321', holder: '0x1111111111111111111111111111111111111111', - remark: '', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', }; - (prompts.select as any) - .mockResolvedValueOnce(mockInputs.network) - .mockResolvedValueOnce('encryptedWallet'); + const mockDocument = { + id: mockInputs.documentId, + tokenRegistry: mockInputs.address, + }; - (prompts.input as any) - .mockResolvedValueOnce(mockInputs.address) - .mockResolvedValueOnce(mockInputs.tokenId) + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: mockInputs.address, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as any) .mockResolvedValueOnce(mockInputs.beneficiary) - .mockResolvedValueOnce(mockInputs.holder) - .mockResolvedValueOnce('./wallet.json') - .mockResolvedValueOnce(mockInputs.remark); + .mockResolvedValueOnce(mockInputs.holder); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (utils.promptRemark as any).mockResolvedValue(undefined); const result = await promptForInputs(); expect(result.remark).toBeUndefined(); - expect(result.encryptionKey).toBeUndefined(); + expect(result.encryptionKey).toBe(mockInputs.documentId); }); it('should support all network options', async () => { @@ -281,17 +383,36 @@ describe('token-registry/mint', () => { for (const network of networks) { vi.clearAllMocks(); - (prompts.select as any) - .mockResolvedValueOnce(network) - .mockResolvedValueOnce('encryptedWallet'); - - (prompts.input as any) - .mockResolvedValueOnce('0x1234567890123456789012345678901234567890') - .mockResolvedValueOnce('0xabcdef1234567890') - .mockResolvedValueOnce('0x0987654321098765432109876543210987654321') - .mockResolvedValueOnce('0x1111111111111111111111111111111111111111') - .mockResolvedValueOnce('./wallet.json') - .mockResolvedValueOnce(''); + const mockInputs = { + address: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + beneficiary: '0x0987654321098765432109876543210987654321', + holder: '0x1111111111111111111111111111111111111111', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + const mockDocument = { + id: mockInputs.documentId, + tokenRegistry: mockInputs.address, + }; + + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: mockInputs.address, + tokenId: mockInputs.tokenId, + network: network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as any) + .mockResolvedValueOnce(mockInputs.beneficiary) + .mockResolvedValueOnce(mockInputs.holder); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (utils.promptRemark as any).mockResolvedValue(undefined); const result = await promptForInputs(); expect(result.network).toBe(network); @@ -323,6 +444,9 @@ describe('token-registry/mint', () => { if (!address) return '0x'; return address.startsWith('0x') ? address : `0x${address}`; }); + + // Re-setup performDryRunWithConfirmation to always return true (proceed) + (utils.performDryRunWithConfirmation as any).mockResolvedValue(true); }); it('should successfully mint token and display transaction details', async () => { @@ -333,7 +457,6 @@ describe('token-registry/mint', () => { beneficiary: '0x0987654321098765432109876543210987654321', holder: '0x1111111111111111111111111111111111111111', encryptedWalletPath: './wallet.json', - dryRun: false, maxPriorityFeePerGasScale: 1, }; @@ -386,7 +509,6 @@ describe('token-registry/mint', () => { remark: 'Important document', encryptionKey: 'secret-key-123', key: '0xprivatekey', - dryRun: false, maxPriorityFeePerGasScale: 1, }; @@ -440,7 +562,6 @@ describe('token-registry/mint', () => { beneficiary: '0x0987654321098765432109876543210987654321', holder: '0x1111111111111111111111111111111111111111', encryptedWalletPath: './wallet.json', - dryRun: false, maxPriorityFeePerGasScale: 1, }; @@ -461,7 +582,6 @@ describe('token-registry/mint', () => { beneficiary: '0x0987654321098765432109876543210987654321', holder: '0x1111111111111111111111111111111111111111', encryptedWalletPath: './wallet.json', - dryRun: false, maxPriorityFeePerGasScale: 1, }; @@ -480,7 +600,6 @@ describe('token-registry/mint', () => { beneficiary: '0x0987654321098765432109876543210987654321', holder: '0x1111111111111111111111111111111111111111', encryptedWalletPath: './wallet.json', - dryRun: false, maxPriorityFeePerGasScale: 1, }; @@ -531,10 +650,15 @@ describe('token-registry/mint', () => { beneficiary: '0x0987654321098765432109876543210987654321', holder: '0x1111111111111111111111111111111111111111', encryptedWalletPath: './wallet.json', - dryRun: false, + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', maxPriorityFeePerGasScale: 1, }; + const mockDocument = { + id: mockInputs.documentId, + tokenRegistry: mockInputs.address, + }; + const mockTransaction: TransactionReceipt = { transactionHash: '0xtxhash', blockNumber: 12345, @@ -554,17 +678,23 @@ describe('token-registry/mint', () => { logsBloom: '0x', }; - (prompts.select as any) - .mockResolvedValueOnce(mockInputs.network) - .mockResolvedValueOnce('encryptedWallet'); - - (prompts.input as any) - .mockResolvedValueOnce(mockInputs.address) - .mockResolvedValueOnce(mockInputs.tokenId) + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: mockInputs.address, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as any) .mockResolvedValueOnce(mockInputs.beneficiary) - .mockResolvedValueOnce(mockInputs.holder) - .mockResolvedValueOnce(mockInputs.encryptedWalletPath) - .mockResolvedValueOnce(''); + .mockResolvedValueOnce(mockInputs.holder); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: mockInputs.encryptedWalletPath, + }); + (utils.promptRemark as any).mockResolvedValue(undefined); const trustvcModule = await import('@trustvc/trustvc'); const mintMock = trustvcModule.mint as MockedFunction; @@ -586,7 +716,8 @@ describe('token-registry/mint', () => { it('should handle errors in handler', async () => { const errorMessage = 'Prompt error'; - (prompts.select as any).mockRejectedValue(new Error(errorMessage)); + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockRejectedValue(new Error(errorMessage)); await handler(); @@ -596,7 +727,8 @@ describe('token-registry/mint', () => { it('should handle non-Error exceptions in handler', async () => { const errorMessage = 'String error'; - (prompts.select as any).mockRejectedValue(errorMessage); + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockRejectedValue(errorMessage); await handler();