Skip to content

Commit 33d73f2

Browse files
committed
feat: added functionality for transaction cancel
1 parent a3906da commit 33d73f2

4 files changed

Lines changed: 243 additions & 1 deletion

File tree

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ trustvc document-store revoke-role
133133
trustvc document-store transfer-ownership
134134
```
135135

136+
### Transaction
137+
138+
```sh
139+
# Cancel a pending transaction (replace-by-fee)
140+
trustvc transaction cancel
141+
```
142+
136143
### Token Registry & Title Escrow
137144

138145
```sh
@@ -199,6 +206,8 @@ trustvc title-escrow reject-transfer-owner-holder
199206

200207
- **Document Store**: Deploy document store contracts and use `documentStoreIssue` and `documentStoreRevoke` to issue and revoke document hashes in deployed contracts.
201208

209+
- **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.
210+
202211
- **Title Escrow**: Provides comprehensive transferable records management including holder transfers, beneficiary nominations, endorsements, returns, and rejections using smart contracts.
203212

204213
## Commands
@@ -226,9 +235,13 @@ trustvc title-escrow reject-transfer-owner-holder
226235
| **Document Store** | [`document-store deploy`](#document-store-deploy) | Deploy document store contracts |
227236
| | [`document-store issue`](#document-store-issue) | Issue document hashes |
228237
| | [`document-store revoke`](#document-store-revoke) | Revoke document hashes |
238+
<<<<<<< Updated upstream
229239
| | [`document-store grant-role`](#document-store-grant-role) | Grant roles to accounts |
230240
| | [`document-store revoke-role`](#document-store-revoke-role) | Revoke roles from accounts |
231241
| | [`document-store transfer-ownership`](#document-store-transfer-ownership) | Transfer document store ownership |
242+
=======
243+
| **Transaction** | [`transaction cancel`](#transaction-cancel) | Cancel a pending transaction |
244+
>>>>>>> Stashed changes
232245
| **Wallet** | [`wallet create`](#wallet-create) | Create a new encrypted wallet file |
233246
| | [`wallet encrypt`](#wallet-encrypt) | Encrypt a wallet using a private key |
234247
| | [`wallet decrypt`](#wallet-decrypt) | Decrypt an encrypted wallet file |
@@ -256,7 +269,7 @@ trustvc title-escrow reject-transfer-owner-holder
256269

257270
### Wallet/Private Key Options
258271

259-
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:
272+
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:
260273

261274
**Select wallet/private key option:**
262275

src/commands/transaction/cancel.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { Argv } from 'yargs';
2+
import { input, select } from '@inquirer/prompts';
3+
import { error, info, success } from 'signale';
4+
import { cancelTransaction } from '@trustvc/trustvc';
5+
import {
6+
getWalletOrSigner,
7+
getErrorMessage,
8+
getEtherscanAddress,
9+
promptNetworkSelection,
10+
promptWalletSelection,
11+
} from '../../utils';
12+
13+
export type TransactionCancelCommand = {
14+
network: string;
15+
nonce?: string;
16+
gasPrice?: string;
17+
transactionHash?: string;
18+
encryptedWalletPath?: string;
19+
key?: string;
20+
keyFile?: string;
21+
rpcUrl?: string;
22+
};
23+
24+
export const command = 'cancel';
25+
26+
export const describe = 'Cancel a pending transaction on the blockchain';
27+
28+
export const builder = (yargs: Argv): Argv => yargs;
29+
30+
/** Prompt for how to specify the pending transaction and collect nonce/gas or hash */
31+
async function promptTransactionSpec(): Promise<{
32+
nonce?: string;
33+
gasPrice?: string;
34+
transactionHash?: string;
35+
}> {
36+
const method = await select({
37+
message: 'How do you want to specify the pending transaction?',
38+
choices: [
39+
{
40+
name: 'By transaction hash (recommended – gas price will be increased by 100% automatically)',
41+
value: 'hash',
42+
description: 'Enter the pending transaction hash (0x...)',
43+
},
44+
{
45+
name: 'By nonce and gas price',
46+
value: 'nonceGas',
47+
description: 'Enter the nonce and a higher gas price in wei',
48+
},
49+
],
50+
default: 'hash',
51+
});
52+
53+
if (method === 'hash') {
54+
const transactionHash = await input({
55+
message: 'Enter the pending transaction hash (0x...):',
56+
required: true,
57+
validate: (value: string) => {
58+
const v = value.trim();
59+
if (!v) return 'Transaction hash is required';
60+
if (!/^0x[a-fA-F0-9]{64}$/.test(v))
61+
return 'Invalid transaction hash (expected 0x followed by 64 hex characters)';
62+
return true;
63+
},
64+
});
65+
return { transactionHash: transactionHash.trim() };
66+
}
67+
68+
const nonce = await input({
69+
message: 'Enter the pending transaction nonce:',
70+
required: true,
71+
validate: (value: string) => {
72+
const n = value.trim();
73+
if (!n) return 'Nonce is required';
74+
if (!/^\d+$/.test(n)) return 'Nonce must be a non-negative integer';
75+
return true;
76+
},
77+
});
78+
79+
const gasPrice = await input({
80+
message:
81+
'Enter the gas price (wei) for the replacement transaction (must be higher than the pending transaction):',
82+
required: true,
83+
validate: (value: string) => {
84+
const v = value.trim();
85+
if (!v) return 'Gas price is required';
86+
if (!/^\d+$/.test(v)) return 'Gas price must be a non-negative integer (wei)';
87+
return true;
88+
},
89+
});
90+
91+
return { nonce: nonce.trim(), gasPrice: gasPrice.trim() };
92+
}
93+
94+
/** Collect all inputs via prompts */
95+
export const promptForInputs = async (): Promise<TransactionCancelCommand> => {
96+
const txSpec = await promptTransactionSpec();
97+
const network = await promptNetworkSelection();
98+
const walletSelection = await promptWalletSelection();
99+
100+
return {
101+
...txSpec,
102+
network,
103+
...walletSelection,
104+
};
105+
};
106+
107+
/**
108+
* Run cancel transaction with pre-filled answers (no prompts). Used for scripting/tests.
109+
*/
110+
export const runCancelTransaction = async (
111+
answers: TransactionCancelCommand,
112+
): Promise<string | undefined> => {
113+
const { network, nonce, gasPrice, transactionHash, encryptedWalletPath, key, keyFile, rpcUrl } =
114+
answers;
115+
116+
if (transactionHash) {
117+
info('Fetching transaction to get nonce and gas price; replacement will use 2x gas price.');
118+
}
119+
120+
const wallet = await getWalletOrSigner({
121+
network,
122+
encryptedWalletPath,
123+
key,
124+
keyFile,
125+
rpcUrl,
126+
});
127+
128+
const replacementHash = await cancelTransaction(wallet as any, {
129+
nonce,
130+
gasPrice,
131+
transactionHash,
132+
});
133+
134+
success('Transaction has been cancelled');
135+
if (replacementHash) {
136+
info(`Replacement transaction hash: ${replacementHash}`);
137+
info(`Find more details at ${getEtherscanAddress({ network })}/tx/${replacementHash}`);
138+
}
139+
return replacementHash;
140+
};
141+
142+
export const handler = async (): Promise<void> => {
143+
try {
144+
const answers = await promptForInputs();
145+
await runCancelTransaction(answers);
146+
} catch (e) {
147+
error(getErrorMessage(e));
148+
}
149+
};

src/commands/transaction/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Argv } from 'yargs';
2+
3+
export const command = 'transaction <method>';
4+
5+
export const describe = 'Invoke a function over a transaction on the blockchain';
6+
7+
export const builder = (yargs: Argv): Argv =>
8+
yargs.commandDir(__dirname, { extensions: ['ts', 'js'] });
9+
10+
export const handler = (): void => {};
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { runCancelTransaction } from '../../../src/commands/transaction/cancel';
3+
4+
vi.mock('@trustvc/trustvc', () => ({
5+
cancelTransaction: vi.fn().mockResolvedValue('0xreplacementHash'),
6+
}));
7+
8+
vi.mock('../../../src/utils', () => ({
9+
getWalletOrSigner: vi.fn().mockResolvedValue({
10+
getAddress: vi.fn().mockResolvedValue('0x1234'),
11+
provider: {},
12+
}),
13+
getEtherscanAddress: vi.fn().mockReturnValue('https://sepolia.etherscan.io'),
14+
getErrorMessage: vi.fn((e: Error) => e.message),
15+
promptNetworkSelection: vi.fn().mockResolvedValue('sepolia'),
16+
promptWalletSelection: vi.fn().mockResolvedValue({ key: '0xkey' }),
17+
}));
18+
19+
vi.mock('signale', () => ({
20+
default: {
21+
info: vi.fn(),
22+
success: vi.fn(),
23+
error: vi.fn(),
24+
},
25+
error: vi.fn(),
26+
info: vi.fn(),
27+
success: vi.fn(),
28+
}));
29+
30+
describe('transaction cancel', () => {
31+
beforeEach(() => {
32+
vi.clearAllMocks();
33+
});
34+
35+
describe('runCancelTransaction', () => {
36+
it('calls cancelTransaction and returns replacement hash', async () => {
37+
const { cancelTransaction } = await import('@trustvc/trustvc');
38+
const answers = {
39+
network: 'sepolia',
40+
nonce: '0',
41+
gasPrice: '25000000000',
42+
key: '0xabc',
43+
};
44+
45+
const hash = await runCancelTransaction(answers);
46+
47+
expect(cancelTransaction).toHaveBeenCalledWith(
48+
expect.anything(),
49+
expect.objectContaining({ nonce: '0', gasPrice: '25000000000' }),
50+
);
51+
expect(hash).toBe('0xreplacementHash');
52+
});
53+
54+
it('passes transactionHash when provided', async () => {
55+
const { cancelTransaction } = await import('@trustvc/trustvc');
56+
const answers = {
57+
network: 'sepolia',
58+
transactionHash: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
59+
key: '0xkey',
60+
};
61+
62+
await runCancelTransaction(answers);
63+
64+
expect(cancelTransaction).toHaveBeenCalledWith(
65+
expect.anything(),
66+
expect.objectContaining({ transactionHash: answers.transactionHash }),
67+
);
68+
});
69+
});
70+
});

0 commit comments

Comments
 (0)