ethnotary 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,841 @@
1
+ const { Command } = require('commander');
2
+ const { ethers } = require('ethers');
3
+ const { createOutput } = require('../../utils/output');
4
+ const { getWallet } = require('../../utils/auth');
5
+ const { getNetwork, getRpcUrl, validateNetwork } = require('../../utils/networks');
6
+ const { resolveAddress, getContractNetworks } = require('../../utils/contracts');
7
+ const { buildNotificationPayload, generateApprovalUrl, formatApprovalMessage } = require('../../utils/notifications');
8
+
9
+ /**
10
+ * Get the network for a transaction command
11
+ * If --network is specified, use that
12
+ * Otherwise, prompt user to select from contract's configured networks
13
+ */
14
+ async function getTransactionNetwork(aliasOrAddress, specifiedNetwork, globalOpts, out) {
15
+ const contractNetworks = getContractNetworks(aliasOrAddress);
16
+
17
+ if (specifiedNetwork) {
18
+ // Use specified network
19
+ const resolved = validateNetwork(specifiedNetwork);
20
+ if (!resolved) {
21
+ return null;
22
+ }
23
+
24
+ // Warn if not in contract's configured networks
25
+ if (contractNetworks.length > 0 && !contractNetworks.includes(resolved.key)) {
26
+ out.warn(`Contract is not configured for network: ${specifiedNetwork}`);
27
+ out.info(`Configured networks: ${contractNetworks.join(', ')}`);
28
+ }
29
+
30
+ const rpc = getRpcUrl(resolved.key);
31
+ if (!rpc) {
32
+ out.error(`No RPC configured for ${resolved.key}. Run: ethnotary config rpc ${resolved.key}`);
33
+ return null;
34
+ }
35
+
36
+ return { key: resolved.key, config: resolved.config, rpc };
37
+ }
38
+
39
+ // No network specified - need to prompt or error
40
+ if (contractNetworks.length === 0) {
41
+ out.error('No network specified and contract has no configured networks.');
42
+ out.info('Use --network <network> to specify the target network.');
43
+ return null;
44
+ }
45
+
46
+ if (contractNetworks.length === 1) {
47
+ // Only one network - use it
48
+ const key = contractNetworks[0];
49
+ const resolved = validateNetwork(key);
50
+ const rpc = getRpcUrl(key);
51
+ if (!rpc) {
52
+ out.error(`No RPC configured for ${key}. Run: ethnotary config rpc ${key}`);
53
+ return null;
54
+ }
55
+ return { key, config: resolved.config, rpc };
56
+ }
57
+
58
+ // Multiple networks - prompt user to select (unless in JSON mode)
59
+ if (globalOpts.json) {
60
+ out.error('Multiple networks available. Use --network to specify which one.');
61
+ out.info(`Available: ${contractNetworks.join(', ')}`);
62
+ return null;
63
+ }
64
+
65
+ const inquirer = require('inquirer');
66
+ const { selectedNetwork } = await inquirer.prompt([{
67
+ type: 'list',
68
+ name: 'selectedNetwork',
69
+ message: 'Select target network:',
70
+ choices: contractNetworks.map(n => ({ name: n, value: n }))
71
+ }]);
72
+
73
+ const resolved = validateNetwork(selectedNetwork);
74
+ const rpc = getRpcUrl(selectedNetwork);
75
+ if (!rpc) {
76
+ out.error(`No RPC configured for ${selectedNetwork}. Run: ethnotary config rpc ${selectedNetwork}`);
77
+ return null;
78
+ }
79
+
80
+ return { key: selectedNetwork, config: resolved.config, rpc };
81
+ }
82
+
83
+ // MultiSig ABI - transaction functions
84
+ const MULTISIG_ABI = [
85
+ "function submitTransaction(address dest, uint256 value, bytes memory func) public returns (uint transactionId)",
86
+ "function confirmTransaction(uint transactionId) public",
87
+ "function execute(uint transactionId) public",
88
+ "function revokeConfirmation(uint transactionId) public",
89
+ "function isOwner(address) view returns (bool)",
90
+ "function getOwners() view returns (address[])",
91
+ "function transactions(uint) view returns (address dest, uint value, bytes func, bool executed, uint id)",
92
+ "function confirmations(uint, address) view returns (bool)",
93
+ "function getConfirmationCount(uint transactionId) view returns (uint count)",
94
+ "function getConfirmations(uint transactionId) view returns (address[])",
95
+ "function required() view returns (uint)",
96
+ "function transactionCount() view returns (uint)",
97
+ "function isConfirmed(uint transactionId) view returns (bool)",
98
+ "event Submission(uint indexed transactionId, address dest, uint256 value, bytes func)",
99
+ "event Confirmation(address indexed sender, uint indexed transactionId)",
100
+ "event Execution(uint indexed transactionId)"
101
+ ];
102
+
103
+ // ERC20 ABI for transfer
104
+ const ERC20_ABI = [
105
+ "function transfer(address to, uint256 amount) returns (bool)",
106
+ "function decimals() view returns (uint8)",
107
+ "function symbol() view returns (string)",
108
+ "function balanceOf(address) view returns (uint256)"
109
+ ];
110
+
111
+ // ERC721 ABI for transfer
112
+ const ERC721_ABI = [
113
+ "function safeTransferFrom(address from, address to, uint256 tokenId)",
114
+ "function ownerOf(uint256 tokenId) view returns (address)"
115
+ ];
116
+
117
+ const tx = new Command('tx')
118
+ .description('Transaction management commands');
119
+
120
+ // tx submit - Submit a new transaction
121
+ tx
122
+ .command('submit')
123
+ .description('Submit a new transaction to the MultiSig')
124
+ .option('--address <address>', 'MultiSig address or alias')
125
+ .option('--network <network>', 'Target network (prompts if not specified)')
126
+ .requiredOption('--dest <destination>', 'Destination address')
127
+ .option('--value <ethAmount>', 'ETH value to send', '0')
128
+ .option('--data <hexData>', 'Transaction data (hex)', '0x')
129
+ .action(async (options, command) => {
130
+ const globalOpts = command.parent.parent.opts();
131
+ const out = createOutput(globalOpts);
132
+
133
+ try {
134
+ if (!ethers.isAddress(options.dest)) {
135
+ out.error(`Invalid destination address: ${options.dest}`);
136
+ return;
137
+ }
138
+
139
+ const address = resolveAddress(options.address);
140
+ const wallet = await getWallet(globalOpts);
141
+
142
+ // Get network - prompts if multiple networks and none specified
143
+ const network = await getTransactionNetwork(options.address, options.network || globalOpts.network, globalOpts, out);
144
+ if (!network) {
145
+ return;
146
+ }
147
+
148
+ const provider = new ethers.JsonRpcProvider(network.rpc);
149
+ const signer = wallet.connect(provider);
150
+
151
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, signer);
152
+
153
+ // Verify caller is owner
154
+ const isOwner = await multisig.isOwner(wallet.address);
155
+ if (!isOwner) {
156
+ out.error(`Address ${wallet.address} is not an owner of this MultiSig`);
157
+ return;
158
+ }
159
+
160
+ const valueWei = ethers.parseEther(options.value);
161
+
162
+ if (globalOpts.dryRun) {
163
+ out.print({
164
+ dryRun: true,
165
+ action: 'submitTransaction',
166
+ multisig: address,
167
+ destination: options.dest,
168
+ value: options.value + ' ETH',
169
+ data: options.data,
170
+ network: globalOpts.network
171
+ });
172
+ return;
173
+ }
174
+
175
+ out.startSpinner('Submitting transaction...');
176
+ const submitTx = await multisig.submitTransaction(options.dest, valueWei, options.data);
177
+ out.updateSpinner(`Transaction sent: ${submitTx.hash}`);
178
+
179
+ const receipt = await submitTx.wait();
180
+
181
+ // Find transaction ID from Submission event
182
+ let transactionId = null;
183
+ for (const log of receipt.logs) {
184
+ try {
185
+ const parsed = multisig.interface.parseLog(log);
186
+ if (parsed?.name === 'Submission') {
187
+ transactionId = Number(parsed.args.transactionId);
188
+ break;
189
+ }
190
+ } catch {}
191
+ }
192
+
193
+ out.succeedSpinner('Transaction submitted');
194
+
195
+ // Get owners and required for notification data
196
+ const [owners, required] = await Promise.all([
197
+ multisig.getOwners(),
198
+ multisig.required()
199
+ ]);
200
+
201
+ // Build notification payload for agents
202
+ const notificationData = buildNotificationPayload({
203
+ transactionId,
204
+ network: network.key,
205
+ contractAddress: address,
206
+ destination: options.dest,
207
+ value: options.value + ' ETH',
208
+ data: options.data,
209
+ confirmations: 1, // Submitter auto-confirms
210
+ required: Number(required),
211
+ owners: owners,
212
+ senderAddress: wallet.address
213
+ });
214
+
215
+ out.print({
216
+ multisig: address,
217
+ network: network.key,
218
+ transactionId,
219
+ destination: options.dest,
220
+ value: options.value + ' ETH',
221
+ txHash: receipt.hash,
222
+ status: 'submitted',
223
+ confirmations: `1/${required}`,
224
+ canExecute: Number(required) <= 1,
225
+ approvalUrl: notificationData.approvalUrl,
226
+ notifyOwners: notificationData.notifyOwners
227
+ });
228
+
229
+ } catch (error) {
230
+ out.error(error.message);
231
+ }
232
+ });
233
+
234
+ // tx confirm - Confirm a transaction
235
+ tx
236
+ .command('confirm')
237
+ .description('Confirm a pending transaction')
238
+ .option('--address <address>', 'MultiSig address or alias')
239
+ .option('--network <network>', 'Target network (prompts if not specified)')
240
+ .requiredOption('--txid <transactionId>', 'Transaction ID to confirm', parseInt)
241
+ .action(async (options, command) => {
242
+ const globalOpts = command.parent.parent.opts();
243
+ const out = createOutput(globalOpts);
244
+
245
+ try {
246
+ const address = resolveAddress(options.address);
247
+ const wallet = await getWallet(globalOpts);
248
+
249
+ const network = await getTransactionNetwork(options.address, options.network || globalOpts.network, globalOpts, out);
250
+ if (!network) return;
251
+
252
+ const provider = new ethers.JsonRpcProvider(network.rpc);
253
+ const signer = wallet.connect(provider);
254
+
255
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, signer);
256
+
257
+ // Check if already confirmed (idempotent)
258
+ const alreadyConfirmed = await multisig.confirmations(options.txid, wallet.address);
259
+ if (alreadyConfirmed) {
260
+ out.print({
261
+ multisig: address,
262
+ transactionId: options.txid,
263
+ status: 'already_confirmed',
264
+ message: 'You have already confirmed this transaction'
265
+ });
266
+ return;
267
+ }
268
+
269
+ if (globalOpts.dryRun) {
270
+ out.print({
271
+ dryRun: true,
272
+ action: 'confirmTransaction',
273
+ multisig: address,
274
+ transactionId: options.txid,
275
+ network: globalOpts.network
276
+ });
277
+ return;
278
+ }
279
+
280
+ out.startSpinner('Confirming transaction...');
281
+ const confirmTx = await multisig.confirmTransaction(options.txid);
282
+ out.updateSpinner(`Transaction sent: ${confirmTx.hash}`);
283
+
284
+ await confirmTx.wait();
285
+
286
+ // Check confirmation count
287
+ const [confirmCount, required] = await Promise.all([
288
+ multisig.getConfirmationCount(options.txid),
289
+ multisig.required()
290
+ ]);
291
+
292
+ out.succeedSpinner('Transaction confirmed');
293
+
294
+ out.print({
295
+ multisig: address,
296
+ transactionId: options.txid,
297
+ txHash: confirmTx.hash,
298
+ confirmations: `${confirmCount}/${required}`,
299
+ canExecute: Number(confirmCount) >= Number(required),
300
+ status: 'confirmed'
301
+ });
302
+
303
+ } catch (error) {
304
+ out.error(error.message);
305
+ }
306
+ });
307
+
308
+ // tx execute - Execute a confirmed transaction
309
+ tx
310
+ .command('execute')
311
+ .description('Execute a fully confirmed transaction')
312
+ .option('--address <address>', 'MultiSig address or alias')
313
+ .option('--network <network>', 'Target network (prompts if not specified)')
314
+ .requiredOption('--txid <transactionId>', 'Transaction ID to execute', parseInt)
315
+ .action(async (options, command) => {
316
+ const globalOpts = command.parent.parent.opts();
317
+ const out = createOutput(globalOpts);
318
+
319
+ try {
320
+ const address = resolveAddress(options.address);
321
+ const wallet = await getWallet(globalOpts);
322
+
323
+ const network = await getTransactionNetwork(options.address, options.network || globalOpts.network, globalOpts, out);
324
+ if (!network) return;
325
+
326
+ const provider = new ethers.JsonRpcProvider(network.rpc);
327
+ const signer = wallet.connect(provider);
328
+
329
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, signer);
330
+
331
+ // Check if already executed (idempotent)
332
+ const txData = await multisig.transactions(options.txid);
333
+ if (txData.executed) {
334
+ out.print({
335
+ multisig: address,
336
+ transactionId: options.txid,
337
+ status: 'already_executed',
338
+ message: 'Transaction has already been executed'
339
+ });
340
+ return;
341
+ }
342
+
343
+ // Check if confirmed
344
+ const isConfirmed = await multisig.isConfirmed(options.txid);
345
+ if (!isConfirmed) {
346
+ const [confirmCount, required] = await Promise.all([
347
+ multisig.getConfirmationCount(options.txid),
348
+ multisig.required()
349
+ ]);
350
+ out.error(`Transaction not fully confirmed. Has ${confirmCount}/${required} confirmations.`);
351
+ return;
352
+ }
353
+
354
+ if (globalOpts.dryRun) {
355
+ out.print({
356
+ dryRun: true,
357
+ action: 'executeTransaction',
358
+ multisig: address,
359
+ transactionId: options.txid,
360
+ network: globalOpts.network
361
+ });
362
+ return;
363
+ }
364
+
365
+ out.startSpinner('Executing transaction...');
366
+ const executeTx = await multisig.execute(options.txid);
367
+ out.updateSpinner(`Transaction sent: ${executeTx.hash}`);
368
+
369
+ await executeTx.wait();
370
+ out.succeedSpinner('Transaction executed');
371
+
372
+ out.print({
373
+ multisig: address,
374
+ transactionId: options.txid,
375
+ txHash: executeTx.hash,
376
+ status: 'executed'
377
+ });
378
+
379
+ } catch (error) {
380
+ out.error(error.message);
381
+ }
382
+ });
383
+
384
+ // tx revoke - Revoke confirmation
385
+ tx
386
+ .command('revoke')
387
+ .description('Revoke your confirmation from a transaction')
388
+ .option('--address <address>', 'MultiSig address or alias')
389
+ .option('--network <network>', 'Target network (prompts if not specified)')
390
+ .requiredOption('--txid <transactionId>', 'Transaction ID', parseInt)
391
+ .action(async (options, command) => {
392
+ const globalOpts = command.parent.parent.opts();
393
+ const out = createOutput(globalOpts);
394
+
395
+ try {
396
+ const address = resolveAddress(options.address);
397
+ const wallet = await getWallet(globalOpts);
398
+
399
+ const network = await getTransactionNetwork(options.address, options.network || globalOpts.network, globalOpts, out);
400
+ if (!network) return;
401
+
402
+ const provider = new ethers.JsonRpcProvider(network.rpc);
403
+ const signer = wallet.connect(provider);
404
+
405
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, signer);
406
+
407
+ // Check if confirmed (idempotent)
408
+ const hasConfirmed = await multisig.confirmations(options.txid, wallet.address);
409
+ if (!hasConfirmed) {
410
+ out.print({
411
+ multisig: address,
412
+ transactionId: options.txid,
413
+ status: 'not_confirmed',
414
+ message: 'You have not confirmed this transaction'
415
+ });
416
+ return;
417
+ }
418
+
419
+ if (globalOpts.dryRun) {
420
+ out.print({
421
+ dryRun: true,
422
+ action: 'revokeConfirmation',
423
+ multisig: address,
424
+ transactionId: options.txid,
425
+ network: globalOpts.network
426
+ });
427
+ return;
428
+ }
429
+
430
+ out.startSpinner('Revoking confirmation...');
431
+ const revokeTx = await multisig.revokeConfirmation(options.txid);
432
+ out.updateSpinner(`Transaction sent: ${revokeTx.hash}`);
433
+
434
+ await revokeTx.wait();
435
+ out.succeedSpinner('Confirmation revoked');
436
+
437
+ out.print({
438
+ multisig: address,
439
+ transactionId: options.txid,
440
+ txHash: revokeTx.hash,
441
+ status: 'revoked'
442
+ });
443
+
444
+ } catch (error) {
445
+ out.error(error.message);
446
+ }
447
+ });
448
+
449
+ // tx pending - List pending transactions across all networks
450
+ tx
451
+ .command('pending')
452
+ .description('List pending transactions across all networks')
453
+ .option('--address <address>', 'MultiSig address or alias')
454
+ .option('--network <network>', 'Filter to specific network')
455
+ .action(async (options, command) => {
456
+ const globalOpts = command.parent.parent.opts();
457
+ const out = createOutput(globalOpts);
458
+
459
+ try {
460
+ const address = resolveAddress(options.address);
461
+ const contractNetworks = getContractNetworks(options.address);
462
+
463
+ // Determine which networks to query
464
+ let networksToQuery = [];
465
+ const specifiedNetwork = options.network || globalOpts.network;
466
+
467
+ if (specifiedNetwork) {
468
+ // Filter to specific network
469
+ const resolved = validateNetwork(specifiedNetwork);
470
+ if (!resolved) {
471
+ out.error(`Unknown network: ${specifiedNetwork}`);
472
+ return;
473
+ }
474
+ networksToQuery = [resolved.key];
475
+ } else if (contractNetworks.length > 0) {
476
+ // Query all contract networks
477
+ networksToQuery = contractNetworks;
478
+ } else {
479
+ // Fallback to default
480
+ networksToQuery = ['sepolia'];
481
+ }
482
+
483
+ out.startSpinner(`Fetching pending transactions across ${networksToQuery.length} network(s)...`);
484
+
485
+ const allPending = [];
486
+ let totalRequired = 0;
487
+
488
+ for (const networkKey of networksToQuery) {
489
+ try {
490
+ const rpc = getRpcUrl(networkKey);
491
+ if (!rpc) {
492
+ out.warn(`No RPC configured for ${networkKey}, skipping...`);
493
+ continue;
494
+ }
495
+
496
+ const provider = new ethers.JsonRpcProvider(rpc);
497
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, provider);
498
+
499
+ const [txCount, required] = await Promise.all([
500
+ multisig.transactionCount(),
501
+ multisig.required()
502
+ ]);
503
+
504
+ totalRequired = Number(required);
505
+
506
+ for (let i = 0; i < Number(txCount); i++) {
507
+ const txData = await multisig.transactions(i);
508
+ if (!txData.executed) {
509
+ const confirmCount = await multisig.getConfirmationCount(i);
510
+ const confirmers = await multisig.getConfirmations(i);
511
+ allPending.push({
512
+ network: networkKey,
513
+ id: i,
514
+ destination: txData.dest,
515
+ value: ethers.formatEther(txData.value) + ' ETH',
516
+ data: txData.func,
517
+ confirmations: `${confirmCount}/${required}`,
518
+ confirmedBy: confirmers,
519
+ canExecute: Number(confirmCount) >= Number(required)
520
+ });
521
+ }
522
+ }
523
+ } catch (err) {
524
+ out.warn(`Failed to fetch pending on ${networkKey}: ${err.message}`);
525
+ }
526
+ }
527
+
528
+ out.succeedSpinner(`Found ${allPending.length} pending transactions across ${networksToQuery.length} network(s)`);
529
+
530
+ out.print({
531
+ multisig: address,
532
+ networks: networksToQuery,
533
+ required: totalRequired,
534
+ pendingCount: allPending.length,
535
+ transactions: allPending
536
+ });
537
+
538
+ } catch (error) {
539
+ out.error(error.message);
540
+ }
541
+ });
542
+
543
+ // tx transfer-erc20 - Submit ERC20 transfer
544
+ tx
545
+ .command('transfer-erc20')
546
+ .description('Submit an ERC20 token transfer')
547
+ .option('--address <address>', 'MultiSig address or alias')
548
+ .option('--network <network>', 'Target network (prompts if not specified)')
549
+ .requiredOption('--token <tokenAddress>', 'ERC20 token contract address')
550
+ .requiredOption('--to <recipient>', 'Recipient address')
551
+ .requiredOption('--amount <amount>', 'Amount to transfer (in token units)')
552
+ .action(async (options, command) => {
553
+ const globalOpts = command.parent.parent.opts();
554
+ const out = createOutput(globalOpts);
555
+
556
+ try {
557
+ if (!ethers.isAddress(options.token)) {
558
+ out.error(`Invalid token address: ${options.token}`);
559
+ return;
560
+ }
561
+ if (!ethers.isAddress(options.to)) {
562
+ out.error(`Invalid recipient address: ${options.to}`);
563
+ return;
564
+ }
565
+
566
+ const address = resolveAddress(options.address);
567
+ const wallet = await getWallet(globalOpts);
568
+
569
+ const network = await getTransactionNetwork(options.address, options.network || globalOpts.network, globalOpts, out);
570
+ if (!network) return;
571
+
572
+ const provider = new ethers.JsonRpcProvider(network.rpc);
573
+ const signer = wallet.connect(provider);
574
+
575
+ // Get token info
576
+ const token = new ethers.Contract(options.token, ERC20_ABI, provider);
577
+ const [decimals, symbol] = await Promise.all([
578
+ token.decimals(),
579
+ token.symbol()
580
+ ]);
581
+
582
+ const amountWei = ethers.parseUnits(options.amount, decimals);
583
+
584
+ // Encode transfer call
585
+ const transferData = token.interface.encodeFunctionData('transfer', [options.to, amountWei]);
586
+
587
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, signer);
588
+
589
+ if (globalOpts.dryRun) {
590
+ out.print({
591
+ dryRun: true,
592
+ action: 'transferERC20',
593
+ multisig: address,
594
+ token: options.token,
595
+ symbol,
596
+ to: options.to,
597
+ amount: options.amount,
598
+ network: globalOpts.network
599
+ });
600
+ return;
601
+ }
602
+
603
+ out.startSpinner(`Submitting ${symbol} transfer...`);
604
+ const submitTx = await multisig.submitTransaction(options.token, 0, transferData);
605
+ out.updateSpinner(`Transaction sent: ${submitTx.hash}`);
606
+
607
+ const receipt = await submitTx.wait();
608
+
609
+ // Find transaction ID
610
+ let transactionId = null;
611
+ for (const log of receipt.logs) {
612
+ try {
613
+ const parsed = multisig.interface.parseLog(log);
614
+ if (parsed?.name === 'Submission') {
615
+ transactionId = Number(parsed.args.transactionId);
616
+ break;
617
+ }
618
+ } catch {}
619
+ }
620
+
621
+ out.succeedSpinner('ERC20 transfer submitted');
622
+
623
+ out.print({
624
+ multisig: address,
625
+ transactionId,
626
+ token: options.token,
627
+ symbol,
628
+ to: options.to,
629
+ amount: options.amount,
630
+ txHash: receipt.hash,
631
+ status: 'submitted'
632
+ });
633
+
634
+ } catch (error) {
635
+ out.error(error.message);
636
+ }
637
+ });
638
+
639
+ // tx transfer-nft - Submit NFT transfer
640
+ tx
641
+ .command('transfer-nft')
642
+ .description('Submit an NFT (ERC721) transfer')
643
+ .option('--address <address>', 'MultiSig address or alias')
644
+ .option('--network <network>', 'Target network (prompts if not specified)')
645
+ .requiredOption('--token <tokenAddress>', 'NFT contract address')
646
+ .requiredOption('--to <recipient>', 'Recipient address')
647
+ .requiredOption('--tokenid <tokenId>', 'Token ID to transfer')
648
+ .action(async (options, command) => {
649
+ const globalOpts = command.parent.parent.opts();
650
+ const out = createOutput(globalOpts);
651
+
652
+ try {
653
+ if (!ethers.isAddress(options.token)) {
654
+ out.error(`Invalid token address: ${options.token}`);
655
+ return;
656
+ }
657
+ if (!ethers.isAddress(options.to)) {
658
+ out.error(`Invalid recipient address: ${options.to}`);
659
+ return;
660
+ }
661
+
662
+ const address = resolveAddress(options.address);
663
+ const wallet = await getWallet(globalOpts);
664
+
665
+ const network = await getTransactionNetwork(options.address, options.network || globalOpts.network, globalOpts, out);
666
+ if (!network) return;
667
+
668
+ const provider = new ethers.JsonRpcProvider(network.rpc);
669
+ const signer = wallet.connect(provider);
670
+
671
+ // Encode safeTransferFrom call
672
+ const nft = new ethers.Contract(options.token, ERC721_ABI, provider);
673
+ const transferData = nft.interface.encodeFunctionData('safeTransferFrom', [
674
+ address, // from (the multisig)
675
+ options.to,
676
+ options.tokenid
677
+ ]);
678
+
679
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, signer);
680
+
681
+ if (globalOpts.dryRun) {
682
+ out.print({
683
+ dryRun: true,
684
+ action: 'transferNFT',
685
+ multisig: address,
686
+ token: options.token,
687
+ to: options.to,
688
+ tokenId: options.tokenid,
689
+ network: globalOpts.network
690
+ });
691
+ return;
692
+ }
693
+
694
+ out.startSpinner('Submitting NFT transfer...');
695
+ const submitTx = await multisig.submitTransaction(options.token, 0, transferData);
696
+ out.updateSpinner(`Transaction sent: ${submitTx.hash}`);
697
+
698
+ const receipt = await submitTx.wait();
699
+
700
+ // Find transaction ID
701
+ let transactionId = null;
702
+ for (const log of receipt.logs) {
703
+ try {
704
+ const parsed = multisig.interface.parseLog(log);
705
+ if (parsed?.name === 'Submission') {
706
+ transactionId = Number(parsed.args.transactionId);
707
+ break;
708
+ }
709
+ } catch {}
710
+ }
711
+
712
+ out.succeedSpinner('NFT transfer submitted');
713
+
714
+ out.print({
715
+ multisig: address,
716
+ transactionId,
717
+ token: options.token,
718
+ to: options.to,
719
+ tokenId: options.tokenid,
720
+ txHash: receipt.hash,
721
+ status: 'submitted'
722
+ });
723
+
724
+ } catch (error) {
725
+ out.error(error.message);
726
+ }
727
+ });
728
+
729
+ // tx notify - Generate notification payload for a pending transaction
730
+ tx
731
+ .command('notify')
732
+ .description('Generate notification payload for a pending transaction')
733
+ .option('--address <address>', 'MultiSig address or alias')
734
+ .option('--network <network>', 'Target network (prompts if not specified)')
735
+ .requiredOption('--txid <transactionId>', 'Transaction ID', parseInt)
736
+ .action(async (options, command) => {
737
+ const globalOpts = command.parent.parent.opts();
738
+ const out = createOutput(globalOpts);
739
+
740
+ try {
741
+ const address = resolveAddress(options.address);
742
+ const wallet = await getWallet(globalOpts);
743
+
744
+ const network = await getTransactionNetwork(options.address, options.network || globalOpts.network, globalOpts, out);
745
+ if (!network) return;
746
+
747
+ const provider = new ethers.JsonRpcProvider(network.rpc);
748
+
749
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, provider);
750
+
751
+ // Get transaction data
752
+ const [txData, owners, required, confirmCount, confirmers] = await Promise.all([
753
+ multisig.transactions(options.txid),
754
+ multisig.getOwners(),
755
+ multisig.required(),
756
+ multisig.getConfirmationCount(options.txid),
757
+ multisig.getConfirmations(options.txid)
758
+ ]);
759
+
760
+ if (txData.executed) {
761
+ out.info('Transaction already executed');
762
+ out.print({
763
+ transactionId: options.txid,
764
+ status: 'executed',
765
+ notifyOwners: []
766
+ });
767
+ return;
768
+ }
769
+
770
+ // Build notification payload
771
+ const notificationData = buildNotificationPayload({
772
+ transactionId: options.txid,
773
+ network: globalOpts.network,
774
+ contractAddress: address,
775
+ destination: txData.dest,
776
+ value: ethers.formatEther(txData.value) + ' ETH',
777
+ data: txData.func,
778
+ confirmations: Number(confirmCount),
779
+ required: Number(required),
780
+ owners: owners,
781
+ senderAddress: wallet.address
782
+ });
783
+
784
+ out.print({
785
+ multisig: address,
786
+ transactionId: options.txid,
787
+ destination: txData.dest,
788
+ value: ethers.formatEther(txData.value) + ' ETH',
789
+ confirmations: `${confirmCount}/${required}`,
790
+ confirmedBy: confirmers,
791
+ canExecute: Number(confirmCount) >= Number(required),
792
+ approvalUrl: notificationData.approvalUrl,
793
+ notifyOwners: notificationData.notifyOwners,
794
+ message: notificationData.message
795
+ });
796
+
797
+ } catch (error) {
798
+ out.error(error.message);
799
+ }
800
+ });
801
+
802
+ // tx link - Generate approval URL for a transaction
803
+ tx
804
+ .command('link')
805
+ .description('Generate approval URL for a transaction')
806
+ .option('--address <address>', 'MultiSig address or alias')
807
+ .option('--network <network>', 'Target network (prompts if not specified)')
808
+ .requiredOption('--txid <transactionId>', 'Transaction ID', parseInt)
809
+ .action(async (options, command) => {
810
+ const globalOpts = command.parent.parent.opts();
811
+ const out = createOutput(globalOpts);
812
+
813
+ try {
814
+ const address = resolveAddress(options.address);
815
+
816
+ const network = await getTransactionNetwork(options.address, options.network || globalOpts.network, globalOpts, out);
817
+ if (!network) return;
818
+
819
+ const approvalUrl = generateApprovalUrl(
820
+ options.txid,
821
+ network.key,
822
+ address
823
+ );
824
+
825
+ if (globalOpts.json) {
826
+ out.print({
827
+ transactionId: options.txid,
828
+ network: network.key,
829
+ multisig: address,
830
+ approvalUrl
831
+ });
832
+ } else {
833
+ console.log(approvalUrl);
834
+ }
835
+
836
+ } catch (error) {
837
+ out.error(error.message);
838
+ }
839
+ });
840
+
841
+ module.exports = tx;