diff --git a/README.md b/README.md index d088db0..b035c80 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,17 @@ trustvc document-store revoke-role trustvc document-store transfer-ownership ``` +### Transaction + +```sh +# Cancel a pending transaction (replace-by-fee) — interactive prompts +trustvc transaction cancel + +# Or with options (non-interactive) +trustvc transaction cancel [options] +# e.g. trustvc transaction cancel --transaction-hash 0x... --network sepolia --encrypted-wallet-path ./wallet.json +``` + ### Token Registry & Title Escrow ```sh @@ -199,6 +210,8 @@ trustvc title-escrow reject-transfer-owner-holder - **Document Store**: Deploy document store contracts and use `documentStoreIssue` and `documentStoreRevoke` to issue and revoke document hashes in deployed contracts. +- **Transaction Cancel**: Cancel a pending transaction by replacing it with a 0-value transaction to yourself (same nonce, higher gas price). Supports specifying by transaction hash or by nonce and gas price. + - **Title Escrow**: Provides comprehensive transferable records management including holder transfers, beneficiary nominations, endorsements, returns, and rejections using smart contracts. ## Commands @@ -229,6 +242,7 @@ trustvc title-escrow reject-transfer-owner-holder | | [`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 | +| **Transaction** | [`transaction cancel`](#transaction-cancel) | Cancel a pending transaction | | **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 | @@ -256,7 +270,7 @@ trustvc title-escrow reject-transfer-owner-holder ### Wallet/Private Key Options -All title-escrow, token registry, and document-store commands require a wallet or private key to sign transactions. You can provide your private key in one of the following ways: +All title-escrow, token registry, document-store, and transaction commands require a wallet or private key to sign transactions. You can provide your private key in one of the following ways: **Select wallet/private key option:** @@ -269,6 +283,58 @@ All title-escrow, token registry, and document-store commands require a wallet o ### Detailed Command Reference +
+

transaction cancel

+ +Cancels a pending transaction by replacing it with a 0-value transaction to yourself using the same nonce and a higher gas price (replace-by-fee). This action is irreversible. + +**Interactive Usage (recommended):** + +```sh +trustvc transaction cancel +``` + +You will be prompted for: + +1. **How to specify the pending transaction** + - **By transaction hash (recommended)** – Enter the pending transaction hash (0x...). Nonce and gas price are fetched from the network and the gas price is increased by 100% for the replacement. + - **By nonce and gas price** – Enter the pending transaction nonce and a higher gas price (wei) for the replacement. Use this when the pending transaction uses EIP-1559 (no legacy `gasPrice`) or when you prefer to set the replacement gas manually. + +2. **Network** – Select the network (e.g. Sepolia, Mainnet). + +3. **Wallet / private key** – Choose encrypted wallet file, environment variable (OA_PRIVATE_KEY), key file, or enter the private key. + +**With options (non-interactive):** + +```sh +# Cancel by nonce and gas price +trustvc transaction cancel \ + --nonce 205 \ + --gas-price 25000000000 \ + --network sepolia \ + --encrypted-wallet-path ./wallet.json + +# Cancel by transaction hash +trustvc transaction cancel \ + --transaction-hash 0x... \ + --network sepolia \ + --encrypted-wallet-path ./wallet.json +``` + +**Options:** `--transaction-hash` (or `-th`), `--nonce`, `--gas-price`, `--network`, `--encrypted-wallet-path`, `--key`, `--key-file`, `--rpc-url`. Wallet can also be provided via `OA_PRIVATE_KEY`. + +- `--nonce` and `--gas-price` must be provided together and must not be combined with `--transaction-hash`. +- `--transaction-hash` can be used alone; gas price is fetched and increased by 100% automatically. + +**Output:** + +- The replacement transaction hash. +- A link to view the replacement transaction on the network’s block explorer (e.g. Etherscan). + +**Note:** If the pending transaction uses EIP-1559 (maxFeePerGas / maxPriorityFeePerGas), it has no legacy `gasPrice`. In that case, specify the transaction by **nonce and gas price** and set a gas price (in wei) for the replacement. + +
+

key-pair-generation

@@ -1314,7 +1380,7 @@ npm install # Build the project npm run build -# Link for local development +# Link for local development (global `trustvc` will use this package) npm link # Run tests @@ -1352,6 +1418,8 @@ src/commands/ │ ├── reject-transfer-holder.ts # Reject holder transfer │ ├── reject-transfer-owner.ts # Reject owner transfer │ └── reject-transfer-owner-holder.ts # Reject full transfer +├── transaction/ +│ └── cancel.ts # Cancel a pending transaction ├── wallet/ │ ├── create.ts # Create encrypted wallet │ ├── encrypt.ts # Encrypt private key to wallet diff --git a/package-lock.json b/package-lock.json index 5d05b0e..56541cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -849,18 +849,6 @@ "node": ">=18" } }, - "node_modules/@digitalbazaar/di-sd-primitives/node_modules/ky": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", - "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/ky?sponsor=1" - } - }, "node_modules/@digitalbazaar/di-sd-primitives/node_modules/rdf-canonize": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/rdf-canonize/-/rdf-canonize-5.0.0.tgz", @@ -873,15 +861,6 @@ "node": ">=18" } }, - "node_modules/@digitalbazaar/di-sd-primitives/node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/@digitalbazaar/di-sd-primitives/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -939,6 +918,30 @@ "node": ">=14.0" } }, + "node_modules/@digitalbazaar/http-client/node_modules/ky": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/ky/-/ky-0.33.3.tgz", + "integrity": "sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, + "node_modules/@digitalbazaar/http-client/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/@digitalbazaar/security-context": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@digitalbazaar/security-context/-/security-context-1.0.1.tgz", @@ -1811,12 +1814,6 @@ "js-sha3": "0.8.0" } }, - "node_modules/@ethersproject/keccak256/node_modules/js-sha3": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", - "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", - "license": "MIT" - }, "node_modules/@ethersproject/logger": { "version": "5.8.0", "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.8.0.tgz", @@ -1929,6 +1926,27 @@ "ws": "8.18.0" } }, + "node_modules/@ethersproject/providers/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@ethersproject/random": { "version": "5.8.0", "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.8.0.tgz", @@ -4108,6 +4126,12 @@ "@ethersproject/wordlists": "5.8.0" } }, + "node_modules/@tradetrust-tt/tradetrust/node_modules/js-sha3": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.9.3.tgz", + "integrity": "sha512-BcJPCQeLg6WjEx3FE591wVAevlli8lxsxm9/FzV4HXkV49TmBH38Yvrpce6fjbADGMKFrBMGTqrVz3qPIZ88Gg==", + "license": "MIT" + }, "node_modules/@tradetrust-tt/tradetrust/node_modules/jsonld-signatures": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/jsonld-signatures/-/jsonld-signatures-7.0.0.tgz", @@ -4363,6 +4387,12 @@ "@ethersproject/wordlists": "5.8.0" } }, + "node_modules/@trustvc/trustvc/node_modules/js-sha3": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.9.3.tgz", + "integrity": "sha512-BcJPCQeLg6WjEx3FE591wVAevlli8lxsxm9/FzV4HXkV49TmBH38Yvrpce6fjbADGMKFrBMGTqrVz3qPIZ88Gg==", + "license": "MIT" + }, "node_modules/@trustvc/trustvc/node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -6671,27 +6701,6 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, - "node_modules/ethers/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/ethersV6": { "name": "ethers", "version": "6.16.0", @@ -6766,27 +6775,6 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, - "node_modules/ethersV6/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/ethr-did-registry": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/ethr-did-registry/-/ethr-did-registry-0.0.3.tgz", @@ -8665,9 +8653,9 @@ "license": "BSD-3-Clause" }, "node_modules/js-sha3": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.9.3.tgz", - "integrity": "sha512-BcJPCQeLg6WjEx3FE591wVAevlli8lxsxm9/FzV4HXkV49TmBH38Yvrpce6fjbADGMKFrBMGTqrVz3qPIZ88Gg==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", "license": "MIT" }, "node_modules/js-tokens": { @@ -8898,18 +8886,6 @@ "node": ">=18" } }, - "node_modules/jsonld-signatures/node_modules/ky": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", - "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/ky?sponsor=1" - } - }, "node_modules/jsonld-signatures/node_modules/rdf-canonize": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/rdf-canonize/-/rdf-canonize-5.0.0.tgz", @@ -8922,15 +8898,6 @@ "node": ">=18" } }, - "node_modules/jsonld-signatures/node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/jsprim": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", @@ -8965,12 +8932,12 @@ } }, "node_modules/ky": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/ky/-/ky-0.33.3.tgz", - "integrity": "sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw==", + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", + "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", "license": "MIT", "engines": { - "node": ">=14.16" + "node": ">=18" }, "funding": { "url": "https://github.com/sindresorhus/ky?sponsor=1" @@ -11716,13 +11683,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/typechain/node_modules/js-sha3": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", - "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", - "license": "MIT", - "peer": true - }, "node_modules/typechain/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -11885,15 +11845,12 @@ } }, "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, "engines": { - "node": ">=14.0" + "node": ">=18.17" } }, "node_modules/undici-types": { @@ -12770,9 +12727,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/src/commands/transaction/cancel.ts b/src/commands/transaction/cancel.ts new file mode 100644 index 0000000..5554723 --- /dev/null +++ b/src/commands/transaction/cancel.ts @@ -0,0 +1,275 @@ +import { Argv } from 'yargs'; +import { input, select } from '@inquirer/prompts'; +import { error, info, success } from 'signale'; +import { cancelTransaction } from '@trustvc/trustvc'; +import { + getWalletOrSigner, + getErrorMessage, + getEtherscanAddress, + promptNetworkSelection, + promptWalletSelection, + supportedNetwork, + withNetworkAndWalletSignerOption, +} from '../../utils'; + +export type TransactionCancelCommand = { + network: string; + nonce?: string; + gasPrice?: string; + transactionHash?: string; + encryptedWalletPath?: string; + key?: string; + keyFile?: string; + rpcUrl?: string; +}; + +export const command = 'cancel'; + +export const describe = 'Cancel a pending transaction on the blockchain'; + +export const builder = (yargs: Argv): Argv => + withNetworkAndWalletSignerOption( + yargs + .option('nonce', { + description: 'Pending transaction nonce', + type: 'string', + implies: 'gas-price', + conflicts: 'transaction-hash', + }) + .option('gas-price', { + description: + 'Gas price (wei) for the replacement transaction (must be higher than the pending tx)', + type: 'string', + implies: 'nonce', + conflicts: 'transaction-hash', + }) + .option('transaction-hash', { + alias: 'th', + description: 'Pending transaction hash (0x...)', + type: 'string', + }) + .option('wallet-path', { + description: 'Alias for encrypted-wallet-path (path to wallet.json)', + type: 'string', + }), + ).option('network', { + choices: Object.keys(supportedNetwork), + default: undefined, + description: 'Ethereum network (prompted if not provided)', + }); + +/** Prompt for how to specify the pending transaction and collect nonce/gas or hash */ +async function promptTransactionSpec(): Promise<{ + nonce?: string; + gasPrice?: string; + transactionHash?: string; +}> { + const method = await select({ + message: 'How do you want to specify the pending transaction?', + choices: [ + { + name: 'By transaction hash (recommended – gas price will be increased by 100% automatically)', + value: 'hash', + description: 'Enter the pending transaction hash (0x...)', + }, + { + name: 'By nonce and gas price', + value: 'nonceGas', + description: 'Enter the nonce and a higher gas price in wei', + }, + ], + default: 'hash', + }); + + if (method === 'hash') { + const transactionHash = await input({ + message: 'Enter the pending transaction hash (0x...):', + required: true, + validate: (value: string) => { + const v = value.trim(); + if (!v) return 'Transaction hash is required'; + if (!/^0x[a-fA-F0-9]{64}$/.test(v)) + return 'Invalid transaction hash (expected 0x followed by 64 hex characters)'; + return true; + }, + }); + return { transactionHash: transactionHash.trim() }; + } + + const nonce = await input({ + message: 'Enter the pending transaction nonce:', + required: true, + validate: (value: string) => { + const n = value.trim(); + if (!n) return 'Nonce is required'; + if (!/^\d+$/.test(n)) return 'Nonce must be a non-negative integer'; + return true; + }, + }); + + const gasPrice = await input({ + message: + 'Enter the gas price (wei) for the replacement transaction (must be higher than the pending transaction):', + required: true, + validate: (value: string) => { + const v = value.trim(); + if (!v) return 'Gas price is required'; + if (!/^\d+$/.test(v)) return 'Gas price must be a non-negative integer (wei)'; + return true; + }, + }); + + return { nonce: nonce.trim(), gasPrice: gasPrice.trim() }; +} + +/** Collect inputs via prompts; only prompts for missing fields when partial is provided */ +export const promptForInputs = async ( + partial?: Partial, +): Promise => { + const hasTxSpec = !!partial?.transactionHash || (!!partial?.nonce && !!partial?.gasPrice); + const txSpec = hasTxSpec + ? { + nonce: partial?.nonce, + gasPrice: partial?.gasPrice, + transactionHash: partial?.transactionHash, + } + : await promptTransactionSpec(); + const network = + partial?.network && partial.network.length > 0 + ? partial.network + : await promptNetworkSelection(); + const hasWallet = !!partial?.encryptedWalletPath || !!partial?.key || !!partial?.keyFile; + const walletSelection = hasWallet + ? { + encryptedWalletPath: partial?.encryptedWalletPath, + key: partial?.key, + keyFile: partial?.keyFile, + } + : await promptWalletSelection(); + + return { + ...txSpec, + network, + ...walletSelection, + }; +}; + +/** + * Run cancel transaction with pre-filled answers (no prompts). Used for scripting/tests. + */ +export const runCancelTransaction = async ( + answers: TransactionCancelCommand, +): Promise => { + const { network, nonce, gasPrice, transactionHash, encryptedWalletPath, key, keyFile, rpcUrl } = + answers; + + if (transactionHash) { + info('Fetching transaction to get nonce and gas price; replacement will use 2x gas price.'); + } + + const wallet = await getWalletOrSigner({ + network, + encryptedWalletPath, + key, + keyFile, + rpcUrl, + }); + + // Wallet from getWalletOrSigner satisfies CancelTransactionSigner (ethers v5/v6 compatible) + const replacementHash = await cancelTransaction( + wallet as Parameters[0], + { + nonce, + gasPrice, // already a string (wei) + transactionHash, + }, + ); + + success('Transaction has been cancelled'); + if (replacementHash) { + info(`Replacement transaction hash: ${replacementHash}`); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${replacementHash}`); + } + return replacementHash; +}; + +type RawCliArgs = TransactionCancelCommand & { + // CLI flags as they come from yargs + nonce?: string; + 'gas-price'?: string; + 'transaction-hash'?: string; + 'wallet-path'?: string; +}; + +/** Normalise yargs CLI arguments into a single internal shape. */ +const normaliseCliArgs = (argv: RawCliArgs): TransactionCancelCommand => { + const a = argv as Record; + + const cliNonce: string | undefined = (argv.nonce ?? (a.nonce as string)) as string | undefined; + const cliGasPrice = (a['gas-price'] ?? a.gasPrice) as string | undefined; + const cliTxHash = (a['transaction-hash'] ?? a.transactionHash) as string | undefined; + const cliEncryptedWalletPath = (a['encrypted-wallet-path'] ?? + a.encryptedWalletPath ?? + a['wallet-path'] ?? + a.walletPath) as string | undefined; + + return { + network: (argv.network ?? a.network) as string, + nonce: cliNonce, + gasPrice: cliGasPrice, + transactionHash: cliTxHash, + encryptedWalletPath: cliEncryptedWalletPath, + key: argv.key, + keyFile: argv.keyFile, + rpcUrl: argv.rpcUrl, + }; +}; + +/** Decide if we have enough input to run non-interactively (no prompts). */ +const hasCompleteNonInteractiveInput = (base: TransactionCancelCommand): boolean => { + const hasTxSpec = !!base.transactionHash || (!!base.nonce && !!base.gasPrice); + const hasNetwork = typeof base.network === 'string' && base.network.length > 0; + const hasWalletInput = + !!base.encryptedWalletPath || !!base.key || !!base.keyFile || !!process.env.OA_PRIVATE_KEY; + + return hasTxSpec && hasNetwork && hasWalletInput; +}; + +/** + * Prompt only for missing inputs, then merge them with any CLI-provided values. + * CLI inputs always win over prompted values. + */ +const collectFinalAnswers = async ( + baseFromCli: TransactionCancelCommand, +): Promise => { + const prompted = await promptForInputs(baseFromCli); + + return { + ...prompted, + ...baseFromCli, + nonce: baseFromCli.nonce ?? prompted.nonce, + gasPrice: baseFromCli.gasPrice ?? prompted.gasPrice, + transactionHash: baseFromCli.transactionHash ?? prompted.transactionHash, + network: baseFromCli.network ?? prompted.network, + encryptedWalletPath: baseFromCli.encryptedWalletPath ?? prompted.encryptedWalletPath, + key: baseFromCli.key ?? prompted.key, + keyFile: baseFromCli.keyFile ?? prompted.keyFile, + rpcUrl: baseFromCli.rpcUrl ?? prompted.rpcUrl, + }; +}; + +export const handler = async (argv: RawCliArgs): Promise => { + try { + const baseFromCli = normaliseCliArgs(argv); + + if (hasCompleteNonInteractiveInput(baseFromCli)) { + await runCancelTransaction(baseFromCli); + return; + } + + const finalAnswers = await collectFinalAnswers(baseFromCli); + await runCancelTransaction(finalAnswers); + } catch (e) { + error(getErrorMessage(e)); + } +}; diff --git a/src/commands/transaction/index.ts b/src/commands/transaction/index.ts new file mode 100644 index 0000000..2d06815 --- /dev/null +++ b/src/commands/transaction/index.ts @@ -0,0 +1,10 @@ +import { Argv } from 'yargs'; + +export const command = 'transaction '; + +export const describe = 'Invoke a function over a transaction on the blockchain'; + +export const builder = (yargs: Argv): Argv => + yargs.commandDir(__dirname, { extensions: ['ts', 'js'] }); + +export const handler = (): void => {}; diff --git a/tests/commands/transaction/cancel.test.ts b/tests/commands/transaction/cancel.test.ts new file mode 100644 index 0000000..cc0b656 --- /dev/null +++ b/tests/commands/transaction/cancel.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { runCancelTransaction } from '../../../src/commands/transaction/cancel'; + +vi.mock('@trustvc/trustvc', () => ({ + cancelTransaction: vi.fn().mockResolvedValue('0xreplacementHash'), +})); + +vi.mock('../../../src/utils', () => ({ + getWalletOrSigner: vi.fn().mockResolvedValue({ + getAddress: vi.fn().mockResolvedValue('0x1234'), + provider: {}, + }), + getEtherscanAddress: vi.fn().mockReturnValue('https://sepolia.etherscan.io'), + getErrorMessage: vi.fn((e: Error) => e.message), + promptNetworkSelection: vi.fn().mockResolvedValue('sepolia'), + promptWalletSelection: vi.fn().mockResolvedValue({ key: '0xkey' }), +})); + +vi.mock('signale', () => ({ + default: { + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), +})); + +describe('transaction cancel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('runCancelTransaction', () => { + it('calls cancelTransaction and returns replacement hash', async () => { + const { cancelTransaction } = await import('@trustvc/trustvc'); + const answers = { + network: 'sepolia', + nonce: '0', + gasPrice: '25000000000', + key: '0xabc', + }; + + const hash = await runCancelTransaction(answers); + + expect(cancelTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ nonce: '0', gasPrice: '25000000000' }), + ); + expect(hash).toBe('0xreplacementHash'); + }); + + it('passes transactionHash when provided', async () => { + const { cancelTransaction } = await import('@trustvc/trustvc'); + const answers = { + network: 'sepolia', + transactionHash: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + key: '0xkey', + }; + + await runCancelTransaction(answers); + + expect(cancelTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ transactionHash: answers.transactionHash }), + ); + }); + }); +});