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 }),
+ );
+ });
+ });
+});