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
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..79b5cd3 100644
--- a/tests/commands/document-store/deploy.test.ts
+++ b/tests/commands/document-store/deploy.test.ts
@@ -1,7 +1,6 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from 'vitest';
-// Mock modules first to avoid hoisting issues
vi.mock('signale', async (importOriginal) => {
const originalSignale = await importOriginal();
return {
@@ -24,20 +23,6 @@ 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',
- },
- },
};
});
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..3bb6ffb 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,6 +33,9 @@ vi.mock('@trustvc/trustvc', () => ({
getTokenRegistryAddress: vi.fn(),
getTokenId: vi.fn(),
getChainId: vi.fn(),
+ DocumentStore__factory: {
+ connect: vi.fn(),
+ },
SUPPORTED_CHAINS: {
1: {
name: 'mainnet',
@@ -52,60 +55,11 @@ vi.mock('@trustvc/trustvc', () => ({
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 },
- },
},
CHAIN_ID: {
mainnet: 1,
sepolia: 11155111,
matic: 137,
- amoy: 80002,
- xdc: 50,
- xdcapothem: 51,
- stability: 101010,
- stabilitytestnet: 20180427,
- astron: 1338,
- astrontestnet: 21002,
},
}));
@@ -131,7 +85,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 +479,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 +512,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 +554,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 +592,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..a174a40 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,6 +33,9 @@ vi.mock('@trustvc/trustvc', () => ({
getTokenRegistryAddress: vi.fn(),
getTokenId: vi.fn(),
getChainId: vi.fn(),
+ DocumentStore__factory: {
+ connect: vi.fn(),
+ },
SUPPORTED_CHAINS: {
1: {
name: 'mainnet',
@@ -52,60 +55,11 @@ vi.mock('@trustvc/trustvc', () => ({
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 },
- },
},
CHAIN_ID: {
mainnet: 1,
sepolia: 11155111,
matic: 137,
- amoy: 80002,
- xdc: 50,
- xdcapothem: 51,
- stability: 101010,
- stabilitytestnet: 20180427,
- astron: 1338,
- astrontestnet: 21002,
},
}));
@@ -525,27 +479,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 +512,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 +554,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 +592,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();
+ });
+ });
+});