From b6005110c3cc3a9bc235e7ae9274ac545d1cae34 Mon Sep 17 00:00:00 2001 From: Rishabh Singh Date: Thu, 26 Feb 2026 08:42:04 +0530 Subject: [PATCH 1/4] feat: document store ownership functions --- src/commands/document-store/grant-role.ts | 192 ++++++ src/commands/document-store/issue.ts | 3 +- src/commands/document-store/revoke-role.ts | 192 ++++++ .../document-store/transfer-ownership.ts | 158 +++++ tests/commands/document-store/deploy.test.ts | 18 +- tests/commands/document-store/fixtures.ts | 80 +++ .../document-store/grant-role.test.ts | 376 ++++++++++++ tests/commands/document-store/issue.test.ts | 141 ++--- .../document-store/revoke-role.test.ts | 376 ++++++++++++ tests/commands/document-store/revoke.test.ts | 139 ++--- .../document-store/transfer-ownership.test.ts | 575 ++++++++++++++++++ 11 files changed, 2035 insertions(+), 215 deletions(-) create mode 100644 src/commands/document-store/grant-role.ts create mode 100644 src/commands/document-store/revoke-role.ts create mode 100644 src/commands/document-store/transfer-ownership.ts create mode 100644 tests/commands/document-store/fixtures.ts create mode 100644 tests/commands/document-store/grant-role.test.ts create mode 100644 tests/commands/document-store/revoke-role.test.ts create mode 100644 tests/commands/document-store/transfer-ownership.test.ts diff --git a/src/commands/document-store/grant-role.ts b/src/commands/document-store/grant-role.ts new file mode 100644 index 0000000..7ecb23a --- /dev/null +++ b/src/commands/document-store/grant-role.ts @@ -0,0 +1,192 @@ +import signale, { error, info, success } from 'signale'; +import { select } from '@inquirer/prompts'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + promptWalletSelection, + getWalletOrSigner, + canEstimateGasPrice, + getGasFees, + performDryRunWithConfirmation, + promptAddress, + promptAndReadDocument, + extractOADocumentInfo, + NetworkAndWalletSignerOption, + GasPriceScale, +} from '../../utils'; +import { connectToDocumentStore, waitForTransaction } from '../helpers'; +import { documentStoreGrantRole } from '@trustvc/trustvc'; +import { Provider, id as keccak256Hash } from 'ethers'; + +// Define the command type for grant-role +type DocumentStoreGrantRoleCommand = NetworkAndWalletSignerOption & + GasPriceScale & { + documentStoreAddress: string; + role: string; + account: string; + }; + +export const command = 'grant-role'; + +export const describe = 'Grants a role to a document store deployed on the blockchain'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + + await grantRoleToDocumentStore(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +// Prompt user for all required inputs +export const promptForInputs = async (): Promise => { + // Extract document information using utility function + const document = await promptAndReadDocument(); + + const { documentStoreAddress, network } = await extractOADocumentInfo(document); + + // Role selection + const role = await select({ + message: 'Select the role to grant:', + choices: [ + { + name: 'Issuer Role', + value: 'ISSUER_ROLE', + description: 'Allows the account to issue documents', + }, + { + name: 'Revoker Role', + value: 'REVOKER_ROLE', + description: 'Allows the account to revoke documents', + }, + { + name: 'Default Admin Role', + value: 'DEFAULT_ADMIN_ROLE', + description: 'Allows the account to manage roles', + }, + ], + default: 'ISSUER_ROLE', + }); + + // Account address to grant the role to + const account = (await promptAddress('account', 'address to grant the role to')) as string; + + // Wallet selection + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + + // Build the result object with proper typing + const baseResult = { + documentStoreAddress, + role, // This will be converted to role hash in the execution function + account, + network, + maxPriorityFeePerGasScale: 1, + }; + + // Add wallet-specific properties based on selected wallet type + if (encryptedWalletPath) { + return { + ...baseResult, + encryptedWalletPath, + } as DocumentStoreGrantRoleCommand; + } else if (keyFile) { + return { + ...baseResult, + keyFile, + } as DocumentStoreGrantRoleCommand; + } else if (key) { + return { + ...baseResult, + key, + } as DocumentStoreGrantRoleCommand; + } + + // For environment variable case (when all wallet options are undefined) + return baseResult as DocumentStoreGrantRoleCommand; +}; + +// Grant role to document store with the provided inputs +export const grantRoleToDocumentStore = async ({ + documentStoreAddress, + role, + account, + network, + ...rest +}: DocumentStoreGrantRoleCommand) => { + try { + info(`Granting ${role} to ${account} on document store ${documentStoreAddress}`); + + const wallet = await getWalletOrSigner({ network, ...rest }); + + // Get the role hash from the role name using keccak256 + // For AccessControl contracts, roles are stored as keccak256 hashes + // DEFAULT_ADMIN_ROLE is 0x00...00, others are keccak256("ROLE_NAME") + const roleHash = + role === 'DEFAULT_ADMIN_ROLE' + ? '0x0000000000000000000000000000000000000000000000000000000000000000' + : keccak256Hash(role); + + // Automatic dry run for Ethereum and Polygon networks + const shouldProceed = await performDryRunWithConfirmation({ + network, + getTransactionCallback: async () => { + const documentStore = await connectToDocumentStore({ + address: documentStoreAddress, + wallet, + }); + // Populate the transaction for gas estimation + const tx = await documentStore.grantRole.populateTransaction(roleHash, account); + + // 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 }); + // Execute grant role with EIP-1559 gas parameters + transaction = await documentStoreGrantRole(documentStoreAddress, roleHash, account, wallet, { + maxFeePerGas: gasFees.maxFeePerGas?.toString(), + maxPriorityFeePerGas: gasFees.maxPriorityFeePerGas?.toString(), + }); + } else { + // Execute grant role without gas estimation (for networks that don't support it) + transaction = await documentStoreGrantRole(documentStoreAddress, roleHash, account, wallet); + } + + signale.await(`Waiting for transaction ${transaction.hash} to be mined`); + + const receipt = await waitForTransaction(transaction, wallet.provider as Provider); + + displayTransactionPrice(receipt, network as NetworkCmdName); + const { hash: transactionHash } = transaction; + + success(`Role ${role} has been granted to ${account} on ${documentStoreAddress}`); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${transactionHash}`); + + return documentStoreAddress; + } catch (e) { + error(getErrorMessage(e)); + } +}; diff --git a/src/commands/document-store/issue.ts b/src/commands/document-store/issue.ts index e5d0f63..6af10a0 100644 --- a/src/commands/document-store/issue.ts +++ b/src/commands/document-store/issue.ts @@ -127,10 +127,11 @@ export const issueToken = async ({ // Execute mint without gas estimation (for networks that don't support it) transaction = await documentStoreIssue(documentStoreAddress, documentHash, wallet); } - const receipt = await waitForTransaction(transaction, wallet.provider as Provider); signale.await(`Waiting for transaction ${transaction.hash} to be mined`); + const receipt = await waitForTransaction(transaction, wallet.provider as Provider); + displayTransactionPrice(receipt, network as NetworkCmdName); const { hash: transactionHash } = transaction; diff --git a/src/commands/document-store/revoke-role.ts b/src/commands/document-store/revoke-role.ts new file mode 100644 index 0000000..5eb43e5 --- /dev/null +++ b/src/commands/document-store/revoke-role.ts @@ -0,0 +1,192 @@ +import signale, { error, info, success } from 'signale'; +import { select } from '@inquirer/prompts'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + promptWalletSelection, + getWalletOrSigner, + canEstimateGasPrice, + getGasFees, + performDryRunWithConfirmation, + promptAddress, + promptAndReadDocument, + extractOADocumentInfo, + NetworkAndWalletSignerOption, + GasPriceScale, +} from '../../utils'; +import { connectToDocumentStore, waitForTransaction } from '../helpers'; +import { documentStoreRevokeRole } from '@trustvc/trustvc'; +import { Provider, id as keccak256Hash } from 'ethers'; + +// Define the command type for revoke-role +type DocumentStoreRevokeRoleCommand = NetworkAndWalletSignerOption & + GasPriceScale & { + documentStoreAddress: string; + role: string; + account: string; + }; + +export const command = 'revoke-role'; + +export const describe = 'Revokes a role from a document store deployed on the blockchain'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + + await revokeRoleFromDocumentStore(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +// Prompt user for all required inputs +export const promptForInputs = async (): Promise => { + // Extract document information using utility function + const document = await promptAndReadDocument(); + + const { documentStoreAddress, network } = await extractOADocumentInfo(document); + + // Role selection + const role = await select({ + message: 'Select the role to revoke:', + choices: [ + { + name: 'Issuer Role', + value: 'ISSUER_ROLE', + description: 'Allows the account to issue documents', + }, + { + name: 'Revoker Role', + value: 'REVOKER_ROLE', + description: 'Allows the account to revoke documents', + }, + { + name: 'Default Admin Role', + value: 'DEFAULT_ADMIN_ROLE', + description: 'Allows the account to manage roles', + }, + ], + default: 'ISSUER_ROLE', + }); + + // Account address to revoke the role from + const account = (await promptAddress('account', 'address to revoke the role from')) as string; + + // Wallet selection + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + + // Build the result object with proper typing + const baseResult = { + documentStoreAddress, + role, + account, + network, + maxPriorityFeePerGasScale: 1, + }; + + // Add wallet-specific properties based on selected wallet type + if (encryptedWalletPath) { + return { + ...baseResult, + encryptedWalletPath, + } as DocumentStoreRevokeRoleCommand; + } else if (keyFile) { + return { + ...baseResult, + keyFile, + } as DocumentStoreRevokeRoleCommand; + } else if (key) { + return { + ...baseResult, + key, + } as DocumentStoreRevokeRoleCommand; + } + + // For environment variable case (when all wallet options are undefined) + return baseResult as DocumentStoreRevokeRoleCommand; +}; + +// Revoke role from document store with the provided inputs +export const revokeRoleFromDocumentStore = async ({ + documentStoreAddress, + role, + account, + network, + ...rest +}: DocumentStoreRevokeRoleCommand) => { + try { + info(`Revoking ${role} from ${account} on document store ${documentStoreAddress}`); + + const wallet = await getWalletOrSigner({ network, ...rest }); + + // Get the role hash from the role name using keccak256 + // For AccessControl contracts, roles are stored as keccak256 hashes + // DEFAULT_ADMIN_ROLE is 0x00...00, others are keccak256("ROLE_NAME") + const roleHash = + role === 'DEFAULT_ADMIN_ROLE' + ? '0x0000000000000000000000000000000000000000000000000000000000000000' + : keccak256Hash(role); + + // Automatic dry run for Ethereum and Polygon networks + const shouldProceed = await performDryRunWithConfirmation({ + network, + getTransactionCallback: async () => { + const documentStore = await connectToDocumentStore({ + address: documentStoreAddress, + wallet, + }); + // Populate the transaction for gas estimation + const tx = await documentStore.revokeRole.populateTransaction(roleHash, account); + + // 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 }); + // Execute revoke role with EIP-1559 gas parameters + transaction = await documentStoreRevokeRole(documentStoreAddress, roleHash, account, wallet, { + maxFeePerGas: gasFees.maxFeePerGas?.toString(), + maxPriorityFeePerGas: gasFees.maxPriorityFeePerGas?.toString(), + }); + } else { + // Execute revoke role without gas estimation (for networks that don't support it) + transaction = await documentStoreRevokeRole(documentStoreAddress, roleHash, account, wallet); + } + + signale.await(`Waiting for transaction ${transaction.hash} to be mined`); + + const receipt = await waitForTransaction(transaction, wallet.provider as Provider); + + displayTransactionPrice(receipt, network as NetworkCmdName); + const { hash: transactionHash } = transaction; + + success(`Role ${role} has been revoked from ${account} on ${documentStoreAddress}`); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${transactionHash}`); + + return documentStoreAddress; + } catch (e) { + error(getErrorMessage(e)); + } +}; diff --git a/src/commands/document-store/transfer-ownership.ts b/src/commands/document-store/transfer-ownership.ts new file mode 100644 index 0000000..787c95f --- /dev/null +++ b/src/commands/document-store/transfer-ownership.ts @@ -0,0 +1,158 @@ +import signale, { error, info, success } from 'signale'; +import { + displayTransactionPrice, + getErrorMessage, + getEtherscanAddress, + NetworkCmdName, + promptWalletSelection, + getWalletOrSigner, + canEstimateGasPrice, + getGasFees, + promptAddress, + promptAndReadDocument, + extractOADocumentInfo, + NetworkAndWalletSignerOption, + GasPriceScale, +} from '../../utils'; +import { waitForTransaction } from '../helpers'; +import { documentStoreTransferOwnership } from '@trustvc/trustvc'; +import { Provider } from 'ethers'; + +// Define the command type for transfer-ownership +type DocumentStoreTransferOwnershipCommand = NetworkAndWalletSignerOption & + GasPriceScale & { + documentStoreAddress: string; + newOwner: string; + }; + +export const command = 'transfer-ownership'; + +export const describe = 'Transfers ownership of a document store deployed on the blockchain'; + +export const handler = async (): Promise => { + try { + const answers = await promptForInputs(); + if (!answers) return; + + await transferOwnershipOfDocumentStore(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; + +// Prompt user for all required inputs +export const promptForInputs = async (): Promise => { + // Extract document information using utility function + const document = await promptAndReadDocument(); + + const { documentStoreAddress, network } = await extractOADocumentInfo(document); + + // New owner address to transfer ownership to + const newOwner = (await promptAddress('new owner', 'address to transfer ownership to')) as string; + + // Wallet selection + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + + // Build the result object with proper typing + const baseResult = { + documentStoreAddress, + newOwner, + network, + maxPriorityFeePerGasScale: 1, + }; + + // Add wallet-specific properties based on selected wallet type + if (encryptedWalletPath) { + return { + ...baseResult, + encryptedWalletPath, + } as DocumentStoreTransferOwnershipCommand; + } else if (keyFile) { + return { + ...baseResult, + keyFile, + } as DocumentStoreTransferOwnershipCommand; + } else if (key) { + return { + ...baseResult, + key, + } as DocumentStoreTransferOwnershipCommand; + } + + // For environment variable case (when all wallet options are undefined) + return baseResult as DocumentStoreTransferOwnershipCommand; +}; + +// Transfer ownership of document store with the provided inputs +export const transferOwnershipOfDocumentStore = async ({ + documentStoreAddress, + newOwner, + network, + ...rest +}: DocumentStoreTransferOwnershipCommand) => { + try { + info(`Transferring ownership to ${newOwner} on document store ${documentStoreAddress}`); + + const wallet = await getWalletOrSigner({ network, ...rest }); + + let grantTransaction; + let revokeTransaction; + + // 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 ownership with EIP-1559 gas parameters + ({ grantTransaction, revokeTransaction } = await documentStoreTransferOwnership( + documentStoreAddress, + newOwner, + wallet, + { + maxFeePerGas: gasFees.maxFeePerGas?.toString(), + maxPriorityFeePerGas: gasFees.maxPriorityFeePerGas?.toString(), + }, + )); + } else { + // Execute transfer ownership without gas estimation (for networks that don't support it) + ({ grantTransaction, revokeTransaction } = await documentStoreTransferOwnership( + documentStoreAddress, + newOwner, + wallet, + )); + } + + if (!grantTransaction || !revokeTransaction) { + throw new Error('Grant or revoke transaction not found'); + } + + // Wait for both transactions to be mined + signale.await( + `Waiting for grant transaction ${grantTransaction.hash} and revoke transaction ${revokeTransaction.hash} to be mined`, + ); + + const grantReceipt = await waitForTransaction(grantTransaction, wallet.provider as Provider); + const revokeReceipt = await waitForTransaction(revokeTransaction, wallet.provider as Provider); + + // Display transaction details for both transactions + info('Grant transaction:'); + displayTransactionPrice(grantReceipt, network as NetworkCmdName); + info(`Grant transaction hash: ${grantTransaction.hash}`); + + info('Revoke transaction:'); + displayTransactionPrice(revokeReceipt, network as NetworkCmdName); + info(`Revoke transaction hash: ${revokeTransaction.hash}`); + + success(`Ownership has been transferred to ${newOwner} on ${documentStoreAddress}`); + info(`Grant transaction: ${getEtherscanAddress({ network })}/tx/${grantTransaction.hash}`); + info(`Revoke transaction: ${getEtherscanAddress({ network })}/tx/${revokeTransaction.hash}`); + + return documentStoreAddress; + } catch (e) { + error(getErrorMessage(e)); + } +}; diff --git a/tests/commands/document-store/deploy.test.ts b/tests/commands/document-store/deploy.test.ts index a548c47..78e92c0 100644 --- a/tests/commands/document-store/deploy.test.ts +++ b/tests/commands/document-store/deploy.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SUPPORTED_CHAINS, CHAIN_ID } from './fixtures'; -// Mock modules first to avoid hoisting issues vi.mock('signale', async (importOriginal) => { const originalSignale = await importOriginal(); return { @@ -24,20 +24,8 @@ vi.mock('@trustvc/trustvc', async (importOriginal) => { return { ...actual, deployDocumentStore: vi.fn(), - CHAIN_ID: { - mainnet: 1, - sepolia: 11155111, - }, - SUPPORTED_CHAINS: { - 11155111: { - name: 'sepolia', - chain: 'ETH', - network: 'sepolia', - rpc: ['https://sepolia.infura.io/v3/'], - nativeCurrency: { name: 'Sepolia Ether', symbol: 'SEP', decimals: 18 }, - infoURL: 'https://sepolia.etherscan.io', - }, - }, + SUPPORTED_CHAINS, + CHAIN_ID, }; }); diff --git a/tests/commands/document-store/fixtures.ts b/tests/commands/document-store/fixtures.ts new file mode 100644 index 0000000..9de5ea0 --- /dev/null +++ b/tests/commands/document-store/fixtures.ts @@ -0,0 +1,80 @@ +/** + * Shared constants for document-store tests + * Import these in your test files to avoid duplication + */ + +export const SUPPORTED_CHAINS = { + 1: { + name: 'mainnet', + explorerUrl: 'https://etherscan.io', + rpcUrl: 'https://mainnet.infura.io/v3/test', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + }, + 11155111: { + name: 'sepolia', + explorerUrl: 'https://sepolia.etherscan.io', + rpcUrl: 'https://sepolia.infura.io/v3/test', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + }, + 137: { + name: 'matic', + explorerUrl: 'https://polygonscan.com', + rpcUrl: 'https://polygon-mainnet.infura.io/v3/test', + nativeCurrency: { symbol: 'MATIC', decimals: 18 }, + }, + 80002: { + name: 'amoy', + explorerUrl: 'https://www.oklink.com/amoy', + rpcUrl: 'https://rpc-amoy.polygon.technology', + nativeCurrency: { symbol: 'MATIC', decimals: 18 }, + }, + 50: { + name: 'xdc', + explorerUrl: 'https://xdcscan.io', + rpcUrl: 'https://rpc.ankr.com/xdc', + nativeCurrency: { symbol: 'XDC', decimals: 18 }, + }, + 51: { + name: 'xdcapothem', + explorerUrl: 'https://apothem.xdcscan.io', + rpcUrl: 'https://rpc.apothem.network', + nativeCurrency: { symbol: 'XDC', decimals: 18 }, + }, + 101010: { + name: 'stability', + explorerUrl: 'https://stability.blockscout.com', + rpcUrl: 'https://rpc.stabilityprotocol.com/zgt/tradeTrust', + nativeCurrency: { symbol: 'FREE', decimals: 18 }, + }, + 20180427: { + name: 'stabilitytestnet', + explorerUrl: 'https://stability-testnet.blockscout.com/', + rpcUrl: 'https://rpc.testnet.stabilityprotocol.com/zgt/tradeTrust', + nativeCurrency: { symbol: 'FREE', decimals: 18 }, + }, + 1338: { + name: 'astron', + explorerUrl: 'https://astronscanl2.bitfactory.cn/', + rpcUrl: 'https://astronlayer2.bitfactory.cn/query/', + nativeCurrency: { symbol: 'ASTRON', decimals: 18 }, + }, + 21002: { + name: 'astrontestnet', + explorerUrl: 'https://dev-astronscanl2.bitfactory.cn/', + rpcUrl: 'https://dev-astronlayer2.bitfactory.cn/query/', + nativeCurrency: { symbol: 'ASTRON', decimals: 18 }, + }, +}; + +export const CHAIN_ID = { + mainnet: 1, + sepolia: 11155111, + matic: 137, + amoy: 80002, + xdc: 50, + xdcapothem: 51, + stability: 101010, + stabilitytestnet: 20180427, + astron: 1338, + astrontestnet: 21002, +}; diff --git a/tests/commands/document-store/grant-role.test.ts b/tests/commands/document-store/grant-role.test.ts new file mode 100644 index 0000000..afdafb3 --- /dev/null +++ b/tests/commands/document-store/grant-role.test.ts @@ -0,0 +1,376 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + handler, + grantRoleToDocumentStore, + promptForInputs, +} from '../../../src/commands/document-store/grant-role'; +import { NetworkCmdName } from '../../../src/utils'; + +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(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + }; +}); + +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + input: vi.fn(), + confirm: vi.fn(), + password: vi.fn(), +})); + +vi.mock('@trustvc/trustvc', () => ({ + documentStoreGrantRole: vi.fn(), + v5Contracts: { + TitleEscrow__factory: {}, + TradeTrustToken__factory: {}, + }, + checkSupportsInterface: vi.fn(), + v4SupportInterfaceIds: {}, + v5SupportInterfaceIds: {}, + encrypt: vi.fn(), + DocumentStore__factory: { + connect: vi.fn(), + }, + SUPPORTED_CHAINS: { + 1: { + name: 'mainnet', + explorerUrl: 'https://etherscan.io', + rpcUrl: 'https://mainnet.infura.io/v3/test', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + }, + 11155111: { + name: 'sepolia', + explorerUrl: 'https://sepolia.etherscan.io', + rpcUrl: 'https://sepolia.infura.io/v3/test', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + }, + 137: { + name: 'matic', + explorerUrl: 'https://polygonscan.com', + rpcUrl: 'https://polygon-mainnet.infura.io/v3/test', + nativeCurrency: { symbol: 'MATIC', decimals: 18 }, + }, + }, + CHAIN_ID: { + mainnet: 1, + sepolia: 11155111, + matic: 137, + }, +})); + +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(), + promptAndReadDocument: vi.fn(), + extractOADocumentInfo: vi.fn(), + promptAddress: vi.fn(), + promptWalletSelection: vi.fn(), + performDryRunWithConfirmation: vi.fn(async () => true), + }; +}); + +vi.mock('../../../src/commands/helpers', () => ({ + connectToDocumentStore: vi.fn(async () => ({ + grantRole: { + populateTransaction: vi.fn(), + callStatic: vi.fn(), + }, + })), + waitForTransaction: vi.fn(), +})); + +describe('document-store/grant-role', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('promptForInputs', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should return correct answers for ISSUER_ROLE with encrypted wallet', async () => { + const mockInputs = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + account: '0x9876543210987654321098765432109876543210', + role: 'ISSUER_ROLE', + }; + + const mockDocument = { + data: { + issuers: [ + { + documentStore: mockInputs.documentStoreAddress, + }, + ], + }, + }; + + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractOADocumentInfo as any).mockResolvedValue({ + documentStoreAddress: mockInputs.documentStoreAddress, + network: NetworkCmdName.Sepolia, + }); + + const inquirer = await import('@inquirer/prompts'); + (inquirer.select as any).mockResolvedValue(mockInputs.role); + + (utils.promptAddress as any).mockResolvedValue(mockInputs.account); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + + const result = await promptForInputs(); + + expect(result.documentStoreAddress).toBe(mockInputs.documentStoreAddress); + expect(result.role).toBe(mockInputs.role); + expect(result.account).toBe(mockInputs.account); + expect(result.network).toBe(NetworkCmdName.Sepolia); + expect(result.maxPriorityFeePerGasScale).toBe(1); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + }); + + it('should return correct answers for REVOKER_ROLE with private key file', async () => { + const mockInputs = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + account: '0x9876543210987654321098765432109876543210', + role: 'REVOKER_ROLE', + }; + + const mockDocument = { + data: { + issuers: [ + { + documentStore: mockInputs.documentStoreAddress, + }, + ], + }, + }; + + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractOADocumentInfo as any).mockResolvedValue({ + documentStoreAddress: mockInputs.documentStoreAddress, + network: NetworkCmdName.Mainnet, + }); + + const inquirer = await import('@inquirer/prompts'); + (inquirer.select as any).mockResolvedValue(mockInputs.role); + + (utils.promptAddress as any).mockResolvedValue(mockInputs.account); + (utils.promptWalletSelection as any).mockResolvedValue({ + keyFile: './private-key.txt', + }); + + const result = await promptForInputs(); + + expect(result.role).toBe(mockInputs.role); + expect((result as any).keyFile).toBe('./private-key.txt'); + }); + + it('should return correct answers for DEFAULT_ADMIN_ROLE with direct private key', async () => { + const mockInputs = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + account: '0x9876543210987654321098765432109876543210', + role: 'DEFAULT_ADMIN_ROLE', + }; + + const mockDocument = { + data: { + issuers: [ + { + documentStore: mockInputs.documentStoreAddress, + }, + ], + }, + }; + + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractOADocumentInfo as any).mockResolvedValue({ + documentStoreAddress: mockInputs.documentStoreAddress, + network: NetworkCmdName.Matic, + }); + + const inquirer = await import('@inquirer/prompts'); + (inquirer.select as any).mockResolvedValue(mockInputs.role); + + (utils.promptAddress as any).mockResolvedValue(mockInputs.account); + (utils.promptWalletSelection as any).mockResolvedValue({ + key: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + }); + + const result = await promptForInputs(); + + expect(result.role).toBe(mockInputs.role); + expect((result as any).key).toBe( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + ); + }); + + it('should throw error when document file reading fails', async () => { + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockRejectedValue( + new Error('Failed to read document file: File does not exist'), + ); + + await expect(promptForInputs()).rejects.toThrowError( + 'Failed to read document file: File does not exist', + ); + }); + }); + + describe('grantRoleToDocumentStore', () => { + let documentStoreGrantRoleMock: MockedFunction; + let getWalletOrSignerMock: MockedFunction; + let connectToDocumentStoreMock: MockedFunction; + let waitForTransactionMock: MockedFunction; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import('@trustvc/trustvc'); + documentStoreGrantRoleMock = trustvcModule.documentStoreGrantRole as MockedFunction; + + const walletModule = await import('../../../src/utils/wallet'); + getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + + const helpersModule = await import('../../../src/commands/helpers'); + connectToDocumentStoreMock = helpersModule.connectToDocumentStore as MockedFunction; + waitForTransactionMock = helpersModule.waitForTransaction as MockedFunction; + + // Setup wallet mock + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + getAddress: vi.fn().mockResolvedValue('0xsigner'), + }); + + // Setup document store mock + connectToDocumentStoreMock.mockResolvedValue({ + grantRole: { + populateTransaction: vi.fn().mockResolvedValue({ + to: '0xdocstore', + data: '0xdata', + }), + callStatic: vi.fn().mockResolvedValue(undefined), + }, + }); + + // Re-setup performDryRunWithConfirmation to always return true (proceed) + const utils = await import('../../../src/utils'); + (utils.performDryRunWithConfirmation as any).mockResolvedValue(true); + }); + + it('should successfully grant ISSUER_ROLE and display transaction details', async () => { + const mockArgs: any = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + role: 'ISSUER_ROLE', + account: '0x9876543210987654321098765432109876543210', + network: NetworkCmdName.Sepolia, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction = { + hash: '0xtxhash123', + wait: vi.fn().mockResolvedValue({ + transactionHash: '0xtxhash123', + blockNumber: 12345, + gasUsed: { toString: () => '100000' }, + }), + }; + + documentStoreGrantRoleMock.mockResolvedValue(mockTransaction); + waitForTransactionMock.mockResolvedValue({ + transactionHash: '0xtxhash123', + blockNumber: 12345, + }); + + const result = await grantRoleToDocumentStore(mockArgs); + + expect(documentStoreGrantRoleMock).toHaveBeenCalled(); + expect(result).toBe(mockArgs.documentStoreAddress); + }); + + it('should handle errors during grant role execution', async () => { + const mockArgs: any = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + role: 'ISSUER_ROLE', + account: '0x9876543210987654321098765432109876543210', + network: NetworkCmdName.Sepolia, + maxPriorityFeePerGasScale: 1, + }; + + const errorMessage = 'Transaction failed: insufficient funds'; + documentStoreGrantRoleMock.mockRejectedValue(new Error(errorMessage)); + + const result = await grantRoleToDocumentStore(mockArgs); + + expect(result).toBeUndefined(); + expect(documentStoreGrantRoleMock).toHaveBeenCalled(); + }); + + it('should handle callStatic errors for dry run', async () => { + const mockArgs: any = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + role: 'ISSUER_ROLE', + account: '0x9876543210987654321098765432109876543210', + network: NetworkCmdName.Sepolia, + maxPriorityFeePerGasScale: 1, + }; + + connectToDocumentStoreMock.mockResolvedValue({ + grantRole: { + populateTransaction: vi.fn().mockRejectedValue(new Error('Call static failed')), + callStatic: vi.fn().mockRejectedValue(new Error('Call static failed')), + }, + }); + + const result = await grantRoleToDocumentStore(mockArgs); + + expect(result).toBeUndefined(); + }); + }); + + describe('handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should handle errors in handler', async () => { + const errorMessage = 'Prompt error'; + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockRejectedValue(new Error(errorMessage)); + + await handler(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + }); +}); diff --git a/tests/commands/document-store/issue.test.ts b/tests/commands/document-store/issue.test.ts index ad926d0..a6560dd 100644 --- a/tests/commands/document-store/issue.test.ts +++ b/tests/commands/document-store/issue.test.ts @@ -1,7 +1,7 @@ -import { TransactionReceipt } from '@ethersproject/providers'; import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; import { handler, issueToken, promptForInputs } from '../../../src/commands/document-store/issue'; import { NetworkCmdName } from '../../../src/utils'; +import { TransactionReceipt } from 'ethers'; vi.mock('signale', async (importOriginal) => { const originalSignale = await importOriginal(); @@ -33,79 +33,18 @@ vi.mock('@trustvc/trustvc', () => ({ getTokenRegistryAddress: vi.fn(), getTokenId: vi.fn(), getChainId: vi.fn(), + DocumentStore__factory: { + connect: vi.fn(), + }, SUPPORTED_CHAINS: { - 1: { - name: 'mainnet', - explorerUrl: 'https://etherscan.io', - rpcUrl: 'https://mainnet.infura.io/v3/test', - nativeCurrency: { symbol: 'ETH', decimals: 18 }, - }, - 11155111: { - name: 'sepolia', - explorerUrl: 'https://sepolia.etherscan.io', - rpcUrl: 'https://sepolia.infura.io/v3/test', - nativeCurrency: { symbol: 'ETH', decimals: 18 }, - }, - 137: { - name: 'matic', - explorerUrl: 'https://polygonscan.com', - rpcUrl: 'https://polygon-mainnet.infura.io/v3/test', - nativeCurrency: { symbol: 'MATIC', decimals: 18 }, - }, - 80002: { - name: 'amoy', - explorerUrl: 'https://www.oklink.com/amoy', - rpcUrl: 'https://rpc-amoy.polygon.technology', - nativeCurrency: { symbol: 'MATIC', decimals: 18 }, - }, - 50: { - name: 'xdc', - explorerUrl: 'https://xdcscan.io', - rpcUrl: 'https://rpc.ankr.com/xdc', - nativeCurrency: { symbol: 'XDC', decimals: 18 }, - }, - 51: { - name: 'xdcapothem', - explorerUrl: 'https://apothem.xdcscan.io', - rpcUrl: 'https://rpc.apothem.network', - nativeCurrency: { symbol: 'XDC', decimals: 18 }, - }, - 101010: { - name: 'stability', - explorerUrl: 'https://stability.blockscout.com', - rpcUrl: 'https://rpc.stabilityprotocol.com/zgt/tradeTrust', - nativeCurrency: { symbol: 'FREE', decimals: 18 }, - }, - 20180427: { - name: 'stabilitytestnet', - explorerUrl: 'https://stability-testnet.blockscout.com/', - rpcUrl: 'https://rpc.testnet.stabilityprotocol.com/zgt/tradeTrust', - nativeCurrency: { symbol: 'FREE', decimals: 18 }, - }, - 1338: { - name: 'astron', - explorerUrl: 'https://astronscanl2.bitfactory.cn/', - rpcUrl: 'https://astronlayer2.bitfactory.cn/query/', - nativeCurrency: { symbol: 'ASTRON', decimals: 18 }, - }, - 21002: { - name: 'astrontestnet', - explorerUrl: 'https://dev-astronscanl2.bitfactory.cn/', - rpcUrl: 'https://dev-astronlayer2.bitfactory.cn/query/', - nativeCurrency: { symbol: 'ASTRON', decimals: 18 }, - }, + 1: { name: 'mainnet', explorerUrl: 'https://etherscan.io', rpcUrl: 'https://mainnet.infura.io/v3/test', nativeCurrency: { symbol: 'ETH', decimals: 18 } }, + 11155111: { name: 'sepolia', explorerUrl: 'https://sepolia.etherscan.io', rpcUrl: 'https://sepolia.infura.io/v3/test', nativeCurrency: { symbol: 'ETH', decimals: 18 } }, + 137: { name: 'matic', explorerUrl: 'https://polygonscan.com', rpcUrl: 'https://polygon-mainnet.infura.io/v3/test', nativeCurrency: { symbol: 'MATIC', decimals: 18 } }, }, CHAIN_ID: { mainnet: 1, sepolia: 11155111, matic: 137, - amoy: 80002, - xdc: 50, - xdcapothem: 51, - stability: 101010, - stabilitytestnet: 20180427, - astron: 1338, - astrontestnet: 21002, }, })); @@ -131,7 +70,7 @@ vi.mock('../../../src/utils', async (importOriginal) => { promptAddress: vi.fn(), promptWalletSelection: vi.fn(), promptRemark: vi.fn(), - performDryRunWithConfirmation: vi.fn(async () => true), // Mock to always proceed + performDryRunWithConfirmation: vi.fn(async () => true), }; }); @@ -525,27 +464,28 @@ describe('document-store/issue', () => { maxPriorityFeePerGasScale: 1, }; - const mockTransaction: TransactionReceipt = { - transactionHash: '0xtxhash123', - blockNumber: 12345, - blockHash: '0xblockhash', - confirmations: 1, - from: '0xfrom', + const mockTransaction = { to: mockArgs.address, - gasUsed: { toNumber: () => 100000 } as any, - cumulativeGasUsed: { toNumber: () => 100000 } as any, - effectiveGasPrice: { toNumber: () => 1000000000 } as any, - byzantium: true, + from: '0xfrom', + contractAddress: null, + hash: '0xtxhash123', + index: 0, + blockHash: '0xblockhash', + blockNumber: 12345, + logsBloom: '0x', + gasUsed: BigInt(100000), + cumulativeGasUsed: BigInt(100000), + blobGasUsed: null, + gasPrice: BigInt(1000000000), + blobGasPrice: null, type: 2, status: 1, - contractAddress: '', - transactionIndex: 0, + root: null, logs: [], - logsBloom: '0x', - }; + } as unknown as TransactionReceipt; documentStoreIssueMock.mockResolvedValue({ - hash: mockTransaction.transactionHash, + hash: mockTransaction.hash, wait: vi.fn().mockResolvedValue(mockTransaction), }); @@ -557,7 +497,7 @@ describe('document-store/issue', () => { encryptedWalletPath: mockArgs.encryptedWalletPath, maxPriorityFeePerGasScale: mockArgs.maxPriorityFeePerGasScale, }); - expect(result.hash).toBe(mockTransaction.transactionHash); + expect(result.hash).toBe(mockTransaction.hash); }); it('should handle errors during issue', async () => { @@ -599,24 +539,25 @@ describe('document-store/issue', () => { tokenRegistry: mockInputs.documentStoreAddress, }; - const mockTransaction: TransactionReceipt = { - transactionHash: '0xtxhash', - blockNumber: 12345, - blockHash: '0xblockhash', - confirmations: 1, - from: '0xfrom', + const mockTransaction = { to: mockInputs.address, - gasUsed: { toNumber: () => 100000 } as any, - cumulativeGasUsed: { toNumber: () => 100000 } as any, - effectiveGasPrice: { toNumber: () => 1000000000 } as any, - byzantium: true, + from: '0xfrom', + contractAddress: null, + hash: '0xtxhash', + index: 0, + blockHash: '0xblockhash', + blockNumber: 12345, + logsBloom: '0x', + gasUsed: BigInt(100000), + cumulativeGasUsed: BigInt(100000), + blobGasUsed: null, + gasPrice: BigInt(1000000000), + blobGasPrice: null, type: 2, status: 1, - contractAddress: '', - transactionIndex: 0, + root: null, logs: [], - logsBloom: '0x', - }; + } as unknown as TransactionReceipt; const utils = await import('../../../src/utils'); (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); @@ -636,7 +577,7 @@ describe('document-store/issue', () => { const trustvcModule = await import('@trustvc/trustvc'); const issueMock = trustvcModule.documentStoreIssue as MockedFunction; issueMock.mockResolvedValue({ - hash: mockTransaction.transactionHash, + hash: mockTransaction.hash, wait: vi.fn().mockResolvedValue(mockTransaction), }); diff --git a/tests/commands/document-store/revoke-role.test.ts b/tests/commands/document-store/revoke-role.test.ts new file mode 100644 index 0000000..d56b0e0 --- /dev/null +++ b/tests/commands/document-store/revoke-role.test.ts @@ -0,0 +1,376 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + handler, + revokeRoleFromDocumentStore, + promptForInputs, +} from '../../../src/commands/document-store/revoke-role'; +import { NetworkCmdName } from '../../../src/utils'; + +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(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + }; +}); + +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + input: vi.fn(), + confirm: vi.fn(), + password: vi.fn(), +})); + +vi.mock('@trustvc/trustvc', () => ({ + documentStoreRevokeRole: vi.fn(), + v5Contracts: { + TitleEscrow__factory: {}, + TradeTrustToken__factory: {}, + }, + checkSupportsInterface: vi.fn(), + v4SupportInterfaceIds: {}, + v5SupportInterfaceIds: {}, + encrypt: vi.fn(), + DocumentStore__factory: { + connect: vi.fn(), + }, + SUPPORTED_CHAINS: { + 1: { + name: 'mainnet', + explorerUrl: 'https://etherscan.io', + rpcUrl: 'https://mainnet.infura.io/v3/test', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + }, + 11155111: { + name: 'sepolia', + explorerUrl: 'https://sepolia.etherscan.io', + rpcUrl: 'https://sepolia.infura.io/v3/test', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + }, + 137: { + name: 'matic', + explorerUrl: 'https://polygonscan.com', + rpcUrl: 'https://polygon-mainnet.infura.io/v3/test', + nativeCurrency: { symbol: 'MATIC', decimals: 18 }, + }, + }, + CHAIN_ID: { + mainnet: 1, + sepolia: 11155111, + matic: 137, + }, +})); + +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(), + promptAndReadDocument: vi.fn(), + extractOADocumentInfo: vi.fn(), + promptAddress: vi.fn(), + promptWalletSelection: vi.fn(), + performDryRunWithConfirmation: vi.fn(async () => true), + }; +}); + +vi.mock('../../../src/commands/helpers', () => ({ + connectToDocumentStore: vi.fn(async () => ({ + revokeRole: { + populateTransaction: vi.fn(), + callStatic: vi.fn(), + }, + })), + waitForTransaction: vi.fn(), +})); + +describe('document-store/revoke-role', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('promptForInputs', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should return correct answers for ISSUER_ROLE with encrypted wallet', async () => { + const mockInputs = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + account: '0x9876543210987654321098765432109876543210', + role: 'ISSUER_ROLE', + }; + + const mockDocument = { + data: { + issuers: [ + { + documentStore: mockInputs.documentStoreAddress, + }, + ], + }, + }; + + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractOADocumentInfo as any).mockResolvedValue({ + documentStoreAddress: mockInputs.documentStoreAddress, + network: NetworkCmdName.Sepolia, + }); + + const inquirer = await import('@inquirer/prompts'); + (inquirer.select as any).mockResolvedValue(mockInputs.role); + + (utils.promptAddress as any).mockResolvedValue(mockInputs.account); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + + const result = await promptForInputs(); + + expect(result.documentStoreAddress).toBe(mockInputs.documentStoreAddress); + expect(result.role).toBe(mockInputs.role); + expect(result.account).toBe(mockInputs.account); + expect(result.network).toBe(NetworkCmdName.Sepolia); + expect(result.maxPriorityFeePerGasScale).toBe(1); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + }); + + it('should return correct answers for REVOKER_ROLE with private key file', async () => { + const mockInputs = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + account: '0x9876543210987654321098765432109876543210', + role: 'REVOKER_ROLE', + }; + + const mockDocument = { + data: { + issuers: [ + { + documentStore: mockInputs.documentStoreAddress, + }, + ], + }, + }; + + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractOADocumentInfo as any).mockResolvedValue({ + documentStoreAddress: mockInputs.documentStoreAddress, + network: NetworkCmdName.Mainnet, + }); + + const inquirer = await import('@inquirer/prompts'); + (inquirer.select as any).mockResolvedValue(mockInputs.role); + + (utils.promptAddress as any).mockResolvedValue(mockInputs.account); + (utils.promptWalletSelection as any).mockResolvedValue({ + keyFile: './private-key.txt', + }); + + const result = await promptForInputs(); + + expect(result.role).toBe(mockInputs.role); + expect((result as any).keyFile).toBe('./private-key.txt'); + }); + + it('should return correct answers for DEFAULT_ADMIN_ROLE with direct private key', async () => { + const mockInputs = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + account: '0x9876543210987654321098765432109876543210', + role: 'DEFAULT_ADMIN_ROLE', + }; + + const mockDocument = { + data: { + issuers: [ + { + documentStore: mockInputs.documentStoreAddress, + }, + ], + }, + }; + + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractOADocumentInfo as any).mockResolvedValue({ + documentStoreAddress: mockInputs.documentStoreAddress, + network: NetworkCmdName.Matic, + }); + + const inquirer = await import('@inquirer/prompts'); + (inquirer.select as any).mockResolvedValue(mockInputs.role); + + (utils.promptAddress as any).mockResolvedValue(mockInputs.account); + (utils.promptWalletSelection as any).mockResolvedValue({ + key: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + }); + + const result = await promptForInputs(); + + expect(result.role).toBe(mockInputs.role); + expect((result as any).key).toBe( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + ); + }); + + it('should throw error when document file reading fails', async () => { + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockRejectedValue( + new Error('Failed to read document file: File does not exist'), + ); + + await expect(promptForInputs()).rejects.toThrowError( + 'Failed to read document file: File does not exist', + ); + }); + }); + + describe('revokeRoleFromDocumentStore', () => { + let documentStoreRevokeRoleMock: MockedFunction; + let getWalletOrSignerMock: MockedFunction; + let connectToDocumentStoreMock: MockedFunction; + let waitForTransactionMock: MockedFunction; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import('@trustvc/trustvc'); + documentStoreRevokeRoleMock = trustvcModule.documentStoreRevokeRole as MockedFunction; + + const walletModule = await import('../../../src/utils/wallet'); + getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + + const helpersModule = await import('../../../src/commands/helpers'); + connectToDocumentStoreMock = helpersModule.connectToDocumentStore as MockedFunction; + waitForTransactionMock = helpersModule.waitForTransaction as MockedFunction; + + // Setup wallet mock + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + getAddress: vi.fn().mockResolvedValue('0xsigner'), + }); + + // Setup document store mock + connectToDocumentStoreMock.mockResolvedValue({ + revokeRole: { + populateTransaction: vi.fn().mockResolvedValue({ + to: '0xdocstore', + data: '0xdata', + }), + callStatic: vi.fn().mockResolvedValue(undefined), + }, + }); + + // Re-setup performDryRunWithConfirmation to always return true (proceed) + const utils = await import('../../../src/utils'); + (utils.performDryRunWithConfirmation as any).mockResolvedValue(true); + }); + + it('should successfully revoke ISSUER_ROLE and display transaction details', async () => { + const mockArgs: any = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + role: 'ISSUER_ROLE', + account: '0x9876543210987654321098765432109876543210', + network: NetworkCmdName.Sepolia, + maxPriorityFeePerGasScale: 1, + }; + + const mockTransaction = { + hash: '0xtxhash123', + wait: vi.fn().mockResolvedValue({ + transactionHash: '0xtxhash123', + blockNumber: 12345, + gasUsed: { toString: () => '100000' }, + }), + }; + + documentStoreRevokeRoleMock.mockResolvedValue(mockTransaction); + waitForTransactionMock.mockResolvedValue({ + transactionHash: '0xtxhash123', + blockNumber: 12345, + }); + + const result = await revokeRoleFromDocumentStore(mockArgs); + + expect(documentStoreRevokeRoleMock).toHaveBeenCalled(); + expect(result).toBe(mockArgs.documentStoreAddress); + }); + + it('should handle errors during revoke role execution', async () => { + const mockArgs: any = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + role: 'ISSUER_ROLE', + account: '0x9876543210987654321098765432109876543210', + network: NetworkCmdName.Sepolia, + maxPriorityFeePerGasScale: 1, + }; + + const errorMessage = 'Transaction failed: insufficient funds'; + documentStoreRevokeRoleMock.mockRejectedValue(new Error(errorMessage)); + + const result = await revokeRoleFromDocumentStore(mockArgs); + + expect(result).toBeUndefined(); + expect(documentStoreRevokeRoleMock).toHaveBeenCalled(); + }); + + it('should handle callStatic errors for dry run', async () => { + const mockArgs: any = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + role: 'ISSUER_ROLE', + account: '0x9876543210987654321098765432109876543210', + network: NetworkCmdName.Sepolia, + maxPriorityFeePerGasScale: 1, + }; + + connectToDocumentStoreMock.mockResolvedValue({ + revokeRole: { + populateTransaction: vi.fn().mockRejectedValue(new Error('Call static failed')), + callStatic: vi.fn().mockRejectedValue(new Error('Call static failed')), + }, + }); + + const result = await revokeRoleFromDocumentStore(mockArgs); + + expect(result).toBeUndefined(); + }); + }); + + describe('handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should handle errors in handler', async () => { + const errorMessage = 'Prompt error'; + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockRejectedValue(new Error(errorMessage)); + + await handler(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + }); +}); diff --git a/tests/commands/document-store/revoke.test.ts b/tests/commands/document-store/revoke.test.ts index 5b54971..cac001d 100644 --- a/tests/commands/document-store/revoke.test.ts +++ b/tests/commands/document-store/revoke.test.ts @@ -1,7 +1,7 @@ -import { TransactionReceipt } from '@ethersproject/providers'; import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; import { handler, revokeToken, promptForInputs } from '../../../src/commands/document-store/revoke'; import { NetworkCmdName } from '../../../src/utils'; +import { TransactionReceipt } from 'ethers'; vi.mock('signale', async (importOriginal) => { const originalSignale = await importOriginal(); @@ -33,79 +33,18 @@ vi.mock('@trustvc/trustvc', () => ({ getTokenRegistryAddress: vi.fn(), getTokenId: vi.fn(), getChainId: vi.fn(), + DocumentStore__factory: { + connect: vi.fn(), + }, SUPPORTED_CHAINS: { - 1: { - name: 'mainnet', - explorerUrl: 'https://etherscan.io', - rpcUrl: 'https://mainnet.infura.io/v3/test', - nativeCurrency: { symbol: 'ETH', decimals: 18 }, - }, - 11155111: { - name: 'sepolia', - explorerUrl: 'https://sepolia.etherscan.io', - rpcUrl: 'https://sepolia.infura.io/v3/test', - nativeCurrency: { symbol: 'ETH', decimals: 18 }, - }, - 137: { - name: 'matic', - explorerUrl: 'https://polygonscan.com', - rpcUrl: 'https://polygon-mainnet.infura.io/v3/test', - nativeCurrency: { symbol: 'MATIC', decimals: 18 }, - }, - 80002: { - name: 'amoy', - explorerUrl: 'https://www.oklink.com/amoy', - rpcUrl: 'https://rpc-amoy.polygon.technology', - nativeCurrency: { symbol: 'MATIC', decimals: 18 }, - }, - 50: { - name: 'xdc', - explorerUrl: 'https://xdcscan.io', - rpcUrl: 'https://rpc.ankr.com/xdc', - nativeCurrency: { symbol: 'XDC', decimals: 18 }, - }, - 51: { - name: 'xdcapothem', - explorerUrl: 'https://apothem.xdcscan.io', - rpcUrl: 'https://rpc.apothem.network', - nativeCurrency: { symbol: 'XDC', decimals: 18 }, - }, - 101010: { - name: 'stability', - explorerUrl: 'https://stability.blockscout.com', - rpcUrl: 'https://rpc.stabilityprotocol.com/zgt/tradeTrust', - nativeCurrency: { symbol: 'FREE', decimals: 18 }, - }, - 20180427: { - name: 'stabilitytestnet', - explorerUrl: 'https://stability-testnet.blockscout.com/', - rpcUrl: 'https://rpc.testnet.stabilityprotocol.com/zgt/tradeTrust', - nativeCurrency: { symbol: 'FREE', decimals: 18 }, - }, - 1338: { - name: 'astron', - explorerUrl: 'https://astronscanl2.bitfactory.cn/', - rpcUrl: 'https://astronlayer2.bitfactory.cn/query/', - nativeCurrency: { symbol: 'ASTRON', decimals: 18 }, - }, - 21002: { - name: 'astrontestnet', - explorerUrl: 'https://dev-astronscanl2.bitfactory.cn/', - rpcUrl: 'https://dev-astronlayer2.bitfactory.cn/query/', - nativeCurrency: { symbol: 'ASTRON', decimals: 18 }, - }, + 1: { name: 'mainnet', explorerUrl: 'https://etherscan.io', rpcUrl: 'https://mainnet.infura.io/v3/test', nativeCurrency: { symbol: 'ETH', decimals: 18 } }, + 11155111: { name: 'sepolia', explorerUrl: 'https://sepolia.etherscan.io', rpcUrl: 'https://sepolia.infura.io/v3/test', nativeCurrency: { symbol: 'ETH', decimals: 18 } }, + 137: { name: 'matic', explorerUrl: 'https://polygonscan.com', rpcUrl: 'https://polygon-mainnet.infura.io/v3/test', nativeCurrency: { symbol: 'MATIC', decimals: 18 } }, }, CHAIN_ID: { mainnet: 1, sepolia: 11155111, matic: 137, - amoy: 80002, - xdc: 50, - xdcapothem: 51, - stability: 101010, - stabilitytestnet: 20180427, - astron: 1338, - astrontestnet: 21002, }, })); @@ -525,27 +464,28 @@ describe('document-store/revoke', () => { maxPriorityFeePerGasScale: 1, }; - const mockTransaction: TransactionReceipt = { - transactionHash: '0xtxhash123', - blockNumber: 12345, - blockHash: '0xblockhash', - confirmations: 1, - from: '0xfrom', + const mockTransaction = { to: mockArgs.address, - gasUsed: { toNumber: () => 100000 } as any, - cumulativeGasUsed: { toNumber: () => 100000 } as any, - effectiveGasPrice: { toNumber: () => 1000000000 } as any, - byzantium: true, + from: '0xfrom', + contractAddress: null, + hash: '0xtxhash123', + index: 0, + blockHash: '0xblockhash', + blockNumber: 12345, + logsBloom: '0x', + gasUsed: BigInt(100000), + cumulativeGasUsed: BigInt(100000), + blobGasUsed: null, + gasPrice: BigInt(1000000000), + blobGasPrice: null, type: 2, status: 1, - contractAddress: '', - transactionIndex: 0, + root: null, logs: [], - logsBloom: '0x', - }; + } as unknown as TransactionReceipt; documentStoreRevokeMock.mockResolvedValue({ - hash: mockTransaction.transactionHash, + hash: mockTransaction.hash, wait: vi.fn().mockResolvedValue(mockTransaction), }); @@ -557,7 +497,7 @@ describe('document-store/revoke', () => { encryptedWalletPath: mockArgs.encryptedWalletPath, maxPriorityFeePerGasScale: mockArgs.maxPriorityFeePerGasScale, }); - expect(result.hash).toBe(mockTransaction.transactionHash); + expect(result.hash).toBe(mockTransaction.hash); }); it('should handle errors during revoke', async () => { @@ -599,24 +539,25 @@ describe('document-store/revoke', () => { tokenRegistry: mockInputs.documentStoreAddress, }; - const mockTransaction: TransactionReceipt = { - transactionHash: '0xtxhash', - blockNumber: 12345, - blockHash: '0xblockhash', - confirmations: 1, - from: '0xfrom', + const mockTransaction = { to: mockInputs.address, - gasUsed: { toNumber: () => 100000 } as any, - cumulativeGasUsed: { toNumber: () => 100000 } as any, - effectiveGasPrice: { toNumber: () => 1000000000 } as any, - byzantium: true, + from: '0xfrom', + contractAddress: null, + hash: '0xtxhash', + index: 0, + blockHash: '0xblockhash', + blockNumber: 12345, + logsBloom: '0x', + gasUsed: BigInt(100000), + cumulativeGasUsed: BigInt(100000), + blobGasUsed: null, + gasPrice: BigInt(1000000000), + blobGasPrice: null, type: 2, status: 1, - contractAddress: '', - transactionIndex: 0, + root: null, logs: [], - logsBloom: '0x', - }; + } as unknown as TransactionReceipt; const utils = await import('../../../src/utils'); (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); @@ -636,7 +577,7 @@ describe('document-store/revoke', () => { const trustvcModule = await import('@trustvc/trustvc'); const revokeMock = trustvcModule.documentStoreRevoke as MockedFunction; revokeMock.mockResolvedValue({ - hash: mockTransaction.transactionHash, + hash: mockTransaction.hash, wait: vi.fn().mockResolvedValue(mockTransaction), }); diff --git a/tests/commands/document-store/transfer-ownership.test.ts b/tests/commands/document-store/transfer-ownership.test.ts new file mode 100644 index 0000000..440290c --- /dev/null +++ b/tests/commands/document-store/transfer-ownership.test.ts @@ -0,0 +1,575 @@ +import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + handler, + transferOwnershipOfDocumentStore, + promptForInputs, +} from '../../../src/commands/document-store/transfer-ownership'; +import { NetworkCmdName } from '../../../src/utils'; + +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(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + }; +}); + +vi.mock('@trustvc/trustvc', () => ({ + documentStoreTransferOwnership: vi.fn(), + v5Contracts: { + TitleEscrow__factory: {}, + TradeTrustToken__factory: {}, + }, + checkSupportsInterface: vi.fn(), + v4SupportInterfaceIds: {}, + v5SupportInterfaceIds: {}, + encrypt: vi.fn(), + DocumentStore__factory: { + connect: vi.fn(), + }, + SUPPORTED_CHAINS: { + 1: { + name: 'mainnet', + explorerUrl: 'https://etherscan.io', + rpcUrl: 'https://mainnet.infura.io/v3/test', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + }, + 11155111: { + name: 'sepolia', + explorerUrl: 'https://sepolia.etherscan.io', + rpcUrl: 'https://sepolia.infura.io/v3/test', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + }, + 137: { + name: 'matic', + explorerUrl: 'https://polygonscan.com', + rpcUrl: 'https://polygon-mainnet.infura.io/v3/test', + nativeCurrency: { symbol: 'MATIC', decimals: 18 }, + }, + }, + CHAIN_ID: { + mainnet: 1, + sepolia: 11155111, + matic: 137, + }, +})); + +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(), + promptAndReadDocument: vi.fn(), + extractOADocumentInfo: vi.fn(), + promptAddress: vi.fn(), + promptWalletSelection: vi.fn(), + performDryRunWithConfirmation: vi.fn(async () => true), + }; +}); + +vi.mock('../../../src/commands/helpers', () => ({ + connectToDocumentStore: vi.fn(async () => ({ + grantRole: { + populateTransaction: vi.fn(), + callStatic: vi.fn(), + }, + revokeRole: { + populateTransaction: vi.fn(), + callStatic: vi.fn(), + }, + })), + waitForTransaction: vi.fn(), +})); + +describe('document-store/transfer-ownership', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('promptForInputs', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should return correct answers with encrypted wallet', async () => { + const mockInputs = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + newOwner: '0x9876543210987654321098765432109876543210', + }; + + const mockDocument = { + data: { + issuers: [ + { + documentStore: mockInputs.documentStoreAddress, + }, + ], + }, + }; + + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractOADocumentInfo as any).mockResolvedValue({ + documentStoreAddress: mockInputs.documentStoreAddress, + network: NetworkCmdName.Sepolia, + }); + + (utils.promptAddress as any).mockResolvedValue(mockInputs.newOwner); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + + const result = await promptForInputs(); + + expect(result.documentStoreAddress).toBe(mockInputs.documentStoreAddress); + expect(result.newOwner).toBe(mockInputs.newOwner); + expect(result.network).toBe(NetworkCmdName.Sepolia); + expect(result.maxPriorityFeePerGasScale).toBe(1); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + }); + + it('should return correct answers with private key file', async () => { + const mockInputs = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + newOwner: '0x9876543210987654321098765432109876543210', + }; + + const mockDocument = { + data: { + issuers: [ + { + documentStore: mockInputs.documentStoreAddress, + }, + ], + }, + }; + + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractOADocumentInfo as any).mockResolvedValue({ + documentStoreAddress: mockInputs.documentStoreAddress, + network: NetworkCmdName.Mainnet, + }); + + (utils.promptAddress as any).mockResolvedValue(mockInputs.newOwner); + (utils.promptWalletSelection as any).mockResolvedValue({ + keyFile: './private-key.txt', + }); + + const result = await promptForInputs(); + + expect(result.newOwner).toBe(mockInputs.newOwner); + expect((result as any).keyFile).toBe('./private-key.txt'); + }); + + it('should return correct answers with direct private key', async () => { + const mockInputs = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + newOwner: '0x9876543210987654321098765432109876543210', + }; + + const mockDocument = { + data: { + issuers: [ + { + documentStore: mockInputs.documentStoreAddress, + }, + ], + }, + }; + + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractOADocumentInfo as any).mockResolvedValue({ + documentStoreAddress: mockInputs.documentStoreAddress, + network: NetworkCmdName.Matic, + }); + + (utils.promptAddress as any).mockResolvedValue(mockInputs.newOwner); + (utils.promptWalletSelection as any).mockResolvedValue({ + key: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + }); + + const result = await promptForInputs(); + + expect(result.newOwner).toBe(mockInputs.newOwner); + expect((result as any).key).toBe( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + ); + }); + + it('should throw error when document file reading fails', async () => { + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockRejectedValue( + new Error('Failed to read document file: File does not exist'), + ); + + await expect(promptForInputs()).rejects.toThrowError( + 'Failed to read document file: File does not exist', + ); + }); + + it('should throw error when document extraction fails', async () => { + const mockDocument = { data: {} }; + + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractOADocumentInfo as any).mockRejectedValue( + new Error('Invalid document format: missing document store address'), + ); + + await expect(promptForInputs()).rejects.toThrowError( + 'Invalid document format: missing document store address', + ); + }); + }); + + describe('transferOwnershipOfDocumentStore', () => { + let documentStoreTransferOwnershipMock: MockedFunction; + let getWalletOrSignerMock: MockedFunction; + let waitForTransactionMock: MockedFunction; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import('@trustvc/trustvc'); + documentStoreTransferOwnershipMock = + trustvcModule.documentStoreTransferOwnership as MockedFunction; + + const walletModule = await import('../../../src/utils/wallet'); + getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + + const helpersModule = await import('../../../src/commands/helpers'); + waitForTransactionMock = helpersModule.waitForTransaction as MockedFunction; + + // Setup wallet mock + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + getAddress: vi.fn().mockResolvedValue('0xsigner'), + }); + + // Re-setup performDryRunWithConfirmation to always return true (proceed) + const utils = await import('../../../src/utils'); + (utils.performDryRunWithConfirmation as any).mockResolvedValue(true); + }); + + it('should successfully transfer ownership and display both transaction details', async () => { + const mockArgs: any = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + newOwner: '0x9876543210987654321098765432109876543210', + network: NetworkCmdName.Sepolia, + maxPriorityFeePerGasScale: 1, + }; + + const mockGrantTransaction = { + hash: '0xgranttxhash123', + wait: vi.fn().mockResolvedValue({ + hash: '0xgranttxhash123', + blockNumber: 12345, + gasUsed: BigInt(100000), + }), + }; + + const mockRevokeTransaction = { + hash: '0xrevoketxhash456', + wait: vi.fn().mockResolvedValue({ + hash: '0xrevoketxhash456', + blockNumber: 12346, + gasUsed: BigInt(100000), + }), + }; + + documentStoreTransferOwnershipMock.mockResolvedValue({ + grantTransaction: mockGrantTransaction, + revokeTransaction: mockRevokeTransaction, + }); + + waitForTransactionMock + .mockResolvedValueOnce({ + hash: '0xgranttxhash123', + blockNumber: 12345, + }) + .mockResolvedValueOnce({ + hash: '0xrevoketxhash456', + blockNumber: 12346, + }); + + const result = await transferOwnershipOfDocumentStore(mockArgs); + + expect(documentStoreTransferOwnershipMock).toHaveBeenCalled(); + expect(waitForTransactionMock).toHaveBeenCalledTimes(2); + expect(result).toBe(mockArgs.documentStoreAddress); + }); + + it('should handle gas estimation for networks that support it', async () => { + const mockArgs: any = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + newOwner: '0x9876543210987654321098765432109876543210', + network: NetworkCmdName.Sepolia, + maxPriorityFeePerGasScale: 1, + }; + + const utils = await import('../../../src/utils'); + (utils.canEstimateGasPrice as any).mockReturnValue(true); + (utils.getGasFees as any).mockResolvedValue({ + maxFeePerGas: BigInt(1000000000), + maxPriorityFeePerGas: BigInt(1000000000), + }); + + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + getAddress: vi.fn().mockResolvedValue('0xsigner'), + }); + + const mockGrantTransaction = { + hash: '0xgranttxhash', + wait: vi.fn().mockResolvedValue({}), + }; + + const mockRevokeTransaction = { + hash: '0xrevoketxhash', + wait: vi.fn().mockResolvedValue({}), + }; + + documentStoreTransferOwnershipMock.mockResolvedValue({ + grantTransaction: mockGrantTransaction, + revokeTransaction: mockRevokeTransaction, + }); + + waitForTransactionMock.mockResolvedValue({}); + + await transferOwnershipOfDocumentStore(mockArgs); + + expect(utils.getGasFees).toHaveBeenCalled(); + expect(documentStoreTransferOwnershipMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ + maxFeePerGas: expect.any(String), + maxPriorityFeePerGas: expect.any(String), + }), + ); + }); + + it('should handle errors during transfer ownership execution', async () => { + const mockArgs: any = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + newOwner: '0x9876543210987654321098765432109876543210', + network: NetworkCmdName.Sepolia, + maxPriorityFeePerGasScale: 1, + }; + + const errorMessage = 'Transaction failed: insufficient funds'; + documentStoreTransferOwnershipMock.mockRejectedValue(new Error(errorMessage)); + + const result = await transferOwnershipOfDocumentStore(mockArgs); + + expect(result).toBeUndefined(); + expect(documentStoreTransferOwnershipMock).toHaveBeenCalled(); + }); + + it('should handle errors when provider is not available for gas estimation', async () => { + const mockArgs: any = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + newOwner: '0x9876543210987654321098765432109876543210', + network: NetworkCmdName.Sepolia, + maxPriorityFeePerGasScale: 1, + }; + + const utils = await import('../../../src/utils'); + (utils.canEstimateGasPrice as any).mockReturnValue(true); + + getWalletOrSignerMock.mockResolvedValue({ + provider: null, + getAddress: vi.fn().mockResolvedValue('0xsigner'), + }); + + const result = await transferOwnershipOfDocumentStore(mockArgs); + + expect(result).toBeUndefined(); + }); + + it('should handle missing grant or revoke transaction', async () => { + const mockArgs: any = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + newOwner: '0x9876543210987654321098765432109876543210', + network: NetworkCmdName.Sepolia, + maxPriorityFeePerGasScale: 1, + }; + + documentStoreTransferOwnershipMock.mockResolvedValue({ + grantTransaction: null, + revokeTransaction: null, + }); + + const result = await transferOwnershipOfDocumentStore(mockArgs); + + expect(result).toBeUndefined(); + }); + + it('should handle transaction wait errors', async () => { + const mockArgs: any = { + documentStoreAddress: '0x1234567890123456789012345678901234567890', + newOwner: '0x9876543210987654321098765432109876543210', + network: NetworkCmdName.Sepolia, + maxPriorityFeePerGasScale: 1, + }; + + const mockGrantTransaction = { + hash: '0xgranttxhash', + wait: vi.fn().mockRejectedValue(new Error('Transaction reverted')), + }; + + const mockRevokeTransaction = { + hash: '0xrevoketxhash', + wait: vi.fn().mockResolvedValue({}), + }; + + documentStoreTransferOwnershipMock.mockResolvedValue({ + grantTransaction: mockGrantTransaction, + revokeTransaction: mockRevokeTransaction, + }); + + waitForTransactionMock.mockRejectedValue(new Error('Transaction reverted')); + + const result = await transferOwnershipOfDocumentStore(mockArgs); + + expect(result).toBeUndefined(); + }); + }); + + describe('handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should successfully execute the complete transfer ownership flow', async () => { + const mockInputs: any = { + network: NetworkCmdName.Sepolia, + documentStoreAddress: '0x1234567890123456789012345678901234567890', + newOwner: '0x9876543210987654321098765432109876543210', + encryptedWalletPath: './wallet.json', + maxPriorityFeePerGasScale: 1, + }; + + const mockDocument = { + data: { + issuers: [ + { + documentStore: mockInputs.documentStoreAddress, + }, + ], + }, + }; + + const mockGrantTransaction = { + hash: '0xgranttxhash', + wait: vi.fn().mockResolvedValue({ + hash: '0xgranttxhash', + blockNumber: 12345, + }), + }; + + const mockRevokeTransaction = { + hash: '0xrevoketxhash', + wait: vi.fn().mockResolvedValue({ + hash: '0xrevoketxhash', + blockNumber: 12346, + }), + }; + + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.extractOADocumentInfo as any).mockResolvedValue({ + documentStoreAddress: mockInputs.documentStoreAddress, + network: mockInputs.network, + }); + + (utils.promptAddress as any).mockResolvedValue(mockInputs.newOwner); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: mockInputs.encryptedWalletPath, + }); + + const trustvcModule = await import('@trustvc/trustvc'); + const transferOwnershipMock = + trustvcModule.documentStoreTransferOwnership as MockedFunction; + transferOwnershipMock.mockResolvedValue({ + grantTransaction: mockGrantTransaction, + revokeTransaction: mockRevokeTransaction, + }); + + const walletModule = await import('../../../src/utils/wallet'); + const getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + getWalletOrSignerMock.mockResolvedValue({ + provider: {}, + getAddress: vi.fn().mockResolvedValue('0xsigner'), + }); + + const helpersModule = await import('../../../src/commands/helpers'); + const waitForTransactionMock = helpersModule.waitForTransaction as MockedFunction; + waitForTransactionMock.mockResolvedValue({ + hash: '0xtxhash', + }); + + const result = await handler(); + + expect(result).toBeUndefined(); + }); + + it('should handle errors in handler', async () => { + const errorMessage = 'Prompt error'; + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument 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'; + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockRejectedValue(errorMessage); + + await handler(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith(errorMessage); + }); + + it('should return early if promptForInputs returns falsy value', async () => { + const utils = await import('../../../src/utils'); + (utils.promptAndReadDocument as any).mockResolvedValue(null); + + const trustvcModule = await import('@trustvc/trustvc'); + const transferOwnershipMock = + trustvcModule.documentStoreTransferOwnership as MockedFunction; + + await handler(); + + expect(transferOwnershipMock).not.toHaveBeenCalled(); + }); + }); +}); From 518f179b19af57ae4addae37bc1f4138b0739d7c Mon Sep 17 00:00:00 2001 From: Rishabh Singh Date: Thu, 26 Feb 2026 09:22:36 +0530 Subject: [PATCH 2/4] fix: readme --- README.md | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 425c11c..7fe9033 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,15 @@ trustvc document-store issue # Revoke a document hash from the store trustvc document-store revoke + +# Grant a role to an account +trustvc document-store grant-role + +# Revoke a role from an account +trustvc document-store revoke-role + +# Transfer ownership of the document store +trustvc document-store transfer-ownership ``` ### Token Registry & Title Escrow @@ -206,6 +215,9 @@ trustvc title-escrow reject-transfer-owner-holder | **Document Store** | [`document-store deploy`](#document-store-deploy) | Deploy document store contracts | | | [`document-store issue`](#document-store-issue) | Issue document hashes | | | [`document-store revoke`](#document-store-revoke) | Revoke document hashes | +| | [`document-store grant-role`](#document-store-grant-role) | Grant roles to accounts | +| | [`document-store revoke-role`](#document-store-revoke-role) | Revoke roles from accounts | +| | [`document-store transfer-ownership`](#document-store-transfer-ownership) | Transfer document store ownership | | **Wallet** | [`wallet create`](#wallet-create) | Create a new encrypted wallet file | | | [`wallet encrypt`](#wallet-encrypt) | Encrypt a wallet using a private key | | | [`wallet decrypt`](#wallet-decrypt) | Decrypt an encrypted wallet file | @@ -634,6 +646,100 @@ Transaction receipt confirming the hash revocation. +
+

document-store grant-role

+ +Grants a role (ISSUER_ROLE, REVOKER_ROLE, or DEFAULT_ADMIN_ROLE) to an account in a deployed document store. + +**Usage:** + +```sh +trustvc document-store grant-role +``` + +**Interactive Prompts:** + +- Path to document file (or manual input for document store address) +- Role to grant (ISSUER_ROLE, REVOKER_ROLE, DEFAULT_ADMIN_ROLE) +- Account address to grant the role to +- Wallet/private key option +- Dry-run option (estimate gas before execution) + +**Output:** +Transaction receipt with hash, block number, gas used, and explorer link. + +**Supported Networks:** + +- Ethereum (Mainnet, Sepolia) +- Polygon (Mainnet, Amoy Testnet) +- XDC Network (Mainnet, Apothem Testnet) +- Stability (Mainnet, Testnet) +- Astron (Mainnet, Testnet) + +
+ +
+

document-store revoke-role

+ +Revokes a role (ISSUER_ROLE, REVOKER_ROLE, or DEFAULT_ADMIN_ROLE) from an account in a deployed document store. + +**Usage:** + +```sh +trustvc document-store revoke-role +``` + +**Interactive Prompts:** + +- Path to document file (or manual input for document store address) +- Role to revoke (ISSUER_ROLE, REVOKER_ROLE, DEFAULT_ADMIN_ROLE) +- Account address to revoke the role from +- Wallet/private key option +- Dry-run option (estimate gas before execution) + +**Output:** +Transaction receipt with hash, block number, gas used, and explorer link. + +**Supported Networks:** + +- Ethereum (Mainnet, Sepolia) +- Polygon (Mainnet, Amoy Testnet) +- XDC Network (Mainnet, Apothem Testnet) +- Stability (Mainnet, Testnet) +- Astron (Mainnet, Testnet) + +
+ +
+

document-store transfer-ownership

+ +Transfers ownership of a document store contract to a new owner. This grants DEFAULT_ADMIN_ROLE to the new owner and revokes it from the current owner. + +**Usage:** + +```sh +trustvc document-store transfer-ownership +``` + +**Interactive Prompts:** + +- Path to document file (or manual input for document store address) +- New owner address +- Wallet/private key option + +**Output:** +Transaction receipts for both grant and revoke operations with hashes, block numbers, gas used, and explorer links. + +**Supported Networks:** + +- Ethereum (Mainnet, Sepolia) +- Polygon (Mainnet, Amoy Testnet) +- XDC Network (Mainnet, Apothem Testnet) +- Stability (Mainnet, Testnet) +- Astron (Mainnet, Testnet) + +
+

wallet create

@@ -1167,7 +1273,10 @@ src/commands/ ├── document-store/ │ ├── deploy.ts # Deploy document store contracts │ ├── issue.ts # Issue document hashes -│ └── revoke.ts # Revoke document hashes +│ ├── revoke.ts # Revoke document hashes +│ ├── grant-role.ts # Grant roles to accounts +│ ├── revoke-role.ts # Revoke roles from accounts +│ └── transfer-ownership.ts # Transfer document store ownership ├── title-escrow/ │ ├── transfer-holder.ts # Transfer holder │ ├── nominate-transfer-owner.ts # Nominate beneficiary From e5a05ab3be050400b0917994a57b934b003a4722 Mon Sep 17 00:00:00 2001 From: Rishabh Singh Date: Thu, 26 Feb 2026 09:53:48 +0530 Subject: [PATCH 3/4] fix: formating --- tests/commands/document-store/issue.test.ts | 21 +++++++++++++++++--- tests/commands/document-store/revoke.test.ts | 21 +++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/tests/commands/document-store/issue.test.ts b/tests/commands/document-store/issue.test.ts index a6560dd..3bb6ffb 100644 --- a/tests/commands/document-store/issue.test.ts +++ b/tests/commands/document-store/issue.test.ts @@ -37,9 +37,24 @@ vi.mock('@trustvc/trustvc', () => ({ connect: vi.fn(), }, SUPPORTED_CHAINS: { - 1: { name: 'mainnet', explorerUrl: 'https://etherscan.io', rpcUrl: 'https://mainnet.infura.io/v3/test', nativeCurrency: { symbol: 'ETH', decimals: 18 } }, - 11155111: { name: 'sepolia', explorerUrl: 'https://sepolia.etherscan.io', rpcUrl: 'https://sepolia.infura.io/v3/test', nativeCurrency: { symbol: 'ETH', decimals: 18 } }, - 137: { name: 'matic', explorerUrl: 'https://polygonscan.com', rpcUrl: 'https://polygon-mainnet.infura.io/v3/test', nativeCurrency: { symbol: 'MATIC', decimals: 18 } }, + 1: { + name: 'mainnet', + explorerUrl: 'https://etherscan.io', + rpcUrl: 'https://mainnet.infura.io/v3/test', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + }, + 11155111: { + name: 'sepolia', + explorerUrl: 'https://sepolia.etherscan.io', + rpcUrl: 'https://sepolia.infura.io/v3/test', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + }, + 137: { + name: 'matic', + explorerUrl: 'https://polygonscan.com', + rpcUrl: 'https://polygon-mainnet.infura.io/v3/test', + nativeCurrency: { symbol: 'MATIC', decimals: 18 }, + }, }, CHAIN_ID: { mainnet: 1, diff --git a/tests/commands/document-store/revoke.test.ts b/tests/commands/document-store/revoke.test.ts index cac001d..a174a40 100644 --- a/tests/commands/document-store/revoke.test.ts +++ b/tests/commands/document-store/revoke.test.ts @@ -37,9 +37,24 @@ vi.mock('@trustvc/trustvc', () => ({ connect: vi.fn(), }, SUPPORTED_CHAINS: { - 1: { name: 'mainnet', explorerUrl: 'https://etherscan.io', rpcUrl: 'https://mainnet.infura.io/v3/test', nativeCurrency: { symbol: 'ETH', decimals: 18 } }, - 11155111: { name: 'sepolia', explorerUrl: 'https://sepolia.etherscan.io', rpcUrl: 'https://sepolia.infura.io/v3/test', nativeCurrency: { symbol: 'ETH', decimals: 18 } }, - 137: { name: 'matic', explorerUrl: 'https://polygonscan.com', rpcUrl: 'https://polygon-mainnet.infura.io/v3/test', nativeCurrency: { symbol: 'MATIC', decimals: 18 } }, + 1: { + name: 'mainnet', + explorerUrl: 'https://etherscan.io', + rpcUrl: 'https://mainnet.infura.io/v3/test', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + }, + 11155111: { + name: 'sepolia', + explorerUrl: 'https://sepolia.etherscan.io', + rpcUrl: 'https://sepolia.infura.io/v3/test', + nativeCurrency: { symbol: 'ETH', decimals: 18 }, + }, + 137: { + name: 'matic', + explorerUrl: 'https://polygonscan.com', + rpcUrl: 'https://polygon-mainnet.infura.io/v3/test', + nativeCurrency: { symbol: 'MATIC', decimals: 18 }, + }, }, CHAIN_ID: { mainnet: 1, From 7c0acca8215a5512130e6bd5828b7e7341f58b6a Mon Sep 17 00:00:00 2001 From: Rishabh Singh Date: Thu, 26 Feb 2026 11:17:23 +0530 Subject: [PATCH 4/4] fix: fixtures --- tests/commands/document-store/deploy.test.ts | 3 - tests/commands/document-store/fixtures.ts | 80 -------------------- 2 files changed, 83 deletions(-) delete mode 100644 tests/commands/document-store/fixtures.ts diff --git a/tests/commands/document-store/deploy.test.ts b/tests/commands/document-store/deploy.test.ts index 78e92c0..79b5cd3 100644 --- a/tests/commands/document-store/deploy.test.ts +++ b/tests/commands/document-store/deploy.test.ts @@ -1,6 +1,5 @@ // @vitest-environment node import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SUPPORTED_CHAINS, CHAIN_ID } from './fixtures'; vi.mock('signale', async (importOriginal) => { const originalSignale = await importOriginal(); @@ -24,8 +23,6 @@ vi.mock('@trustvc/trustvc', async (importOriginal) => { return { ...actual, deployDocumentStore: vi.fn(), - SUPPORTED_CHAINS, - CHAIN_ID, }; }); diff --git a/tests/commands/document-store/fixtures.ts b/tests/commands/document-store/fixtures.ts deleted file mode 100644 index 9de5ea0..0000000 --- a/tests/commands/document-store/fixtures.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Shared constants for document-store tests - * Import these in your test files to avoid duplication - */ - -export const SUPPORTED_CHAINS = { - 1: { - name: 'mainnet', - explorerUrl: 'https://etherscan.io', - rpcUrl: 'https://mainnet.infura.io/v3/test', - nativeCurrency: { symbol: 'ETH', decimals: 18 }, - }, - 11155111: { - name: 'sepolia', - explorerUrl: 'https://sepolia.etherscan.io', - rpcUrl: 'https://sepolia.infura.io/v3/test', - nativeCurrency: { symbol: 'ETH', decimals: 18 }, - }, - 137: { - name: 'matic', - explorerUrl: 'https://polygonscan.com', - rpcUrl: 'https://polygon-mainnet.infura.io/v3/test', - nativeCurrency: { symbol: 'MATIC', decimals: 18 }, - }, - 80002: { - name: 'amoy', - explorerUrl: 'https://www.oklink.com/amoy', - rpcUrl: 'https://rpc-amoy.polygon.technology', - nativeCurrency: { symbol: 'MATIC', decimals: 18 }, - }, - 50: { - name: 'xdc', - explorerUrl: 'https://xdcscan.io', - rpcUrl: 'https://rpc.ankr.com/xdc', - nativeCurrency: { symbol: 'XDC', decimals: 18 }, - }, - 51: { - name: 'xdcapothem', - explorerUrl: 'https://apothem.xdcscan.io', - rpcUrl: 'https://rpc.apothem.network', - nativeCurrency: { symbol: 'XDC', decimals: 18 }, - }, - 101010: { - name: 'stability', - explorerUrl: 'https://stability.blockscout.com', - rpcUrl: 'https://rpc.stabilityprotocol.com/zgt/tradeTrust', - nativeCurrency: { symbol: 'FREE', decimals: 18 }, - }, - 20180427: { - name: 'stabilitytestnet', - explorerUrl: 'https://stability-testnet.blockscout.com/', - rpcUrl: 'https://rpc.testnet.stabilityprotocol.com/zgt/tradeTrust', - nativeCurrency: { symbol: 'FREE', decimals: 18 }, - }, - 1338: { - name: 'astron', - explorerUrl: 'https://astronscanl2.bitfactory.cn/', - rpcUrl: 'https://astronlayer2.bitfactory.cn/query/', - nativeCurrency: { symbol: 'ASTRON', decimals: 18 }, - }, - 21002: { - name: 'astrontestnet', - explorerUrl: 'https://dev-astronscanl2.bitfactory.cn/', - rpcUrl: 'https://dev-astronlayer2.bitfactory.cn/query/', - nativeCurrency: { symbol: 'ASTRON', decimals: 18 }, - }, -}; - -export const CHAIN_ID = { - mainnet: 1, - sepolia: 11155111, - matic: 137, - amoy: 80002, - xdc: 50, - xdcapothem: 51, - stability: 101010, - stabilitytestnet: 20180427, - astron: 1338, - astrontestnet: 21002, -};