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,855 @@
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 } = require('../../utils/networks');
6
+ const { computePinHash, generateZkProof } = require('../../utils/pin');
7
+ const { saveContract, resolveAddress, getContractNetworks, getContract, getDefaultContract } = require('../../utils/contracts');
8
+ const { fetchAccountStateAcrossNetworks, analyzeAccountSync, handleDecoupling, preflightAccountOperation, displayPreflightResults } = require('../../utils/crosschain');
9
+ const { MULTISIG_ABI, MSA_FACTORY_ABI, getFactoryAddress } = require('../../utils/constants');
10
+
11
+ const account = new Command('account')
12
+ .description('Account management commands');
13
+
14
+ // account create - Deploy new MultiSig account
15
+ account
16
+ .command('create')
17
+ .description('Deploy a new MultiSig account')
18
+ .requiredOption('--owners <addresses>', 'Comma-separated list of owner addresses')
19
+ .requiredOption('--required <number>', 'Number of required confirmations', parseInt)
20
+ .requiredOption('--pin <pin>', 'PIN for account management')
21
+ .requiredOption('--name <name>', 'Name for the MultiSig account')
22
+ .action(async (options, command) => {
23
+ const globalOpts = command.parent.parent.opts();
24
+ const out = createOutput(globalOpts);
25
+
26
+ try {
27
+ // Parse owners
28
+ const owners = options.owners.split(',').map(addr => addr.trim());
29
+ for (const owner of owners) {
30
+ if (!ethers.isAddress(owner)) {
31
+ out.error(`Invalid owner address: ${owner}`);
32
+ return;
33
+ }
34
+ }
35
+
36
+ if (options.required < 1 || options.required > owners.length) {
37
+ out.error(`Required confirmations must be between 1 and ${owners.length}`);
38
+ return;
39
+ }
40
+
41
+ // Get wallet
42
+ const wallet = await getWallet(globalOpts);
43
+
44
+ // Get network
45
+ const network = await getNetwork(globalOpts.network);
46
+ const provider = new ethers.JsonRpcProvider(network.rpc);
47
+ const signer = wallet.connect(provider);
48
+
49
+ // Compute PIN hash
50
+ const pinHash = computePinHash(options.pin);
51
+
52
+ // Account name (use alias if provided, otherwise default)
53
+ const accountName = options.name || options.saveAs || 'MultiSig';
54
+
55
+ // Get factory address from centralized config
56
+ const factoryAddress = getFactoryAddress(globalOpts.network);
57
+ const factory = new ethers.Contract(factoryAddress, MSA_FACTORY_ABI, signer);
58
+
59
+ // Predict address
60
+ out.startSpinner('Predicting contract address...');
61
+ let predictedAddress;
62
+ try {
63
+ predictedAddress = await factory.predictMSAAddress(owners, options.required, pinHash, accountName);
64
+ out.updateSpinner(`Predicted address: ${predictedAddress}`);
65
+ } catch (e) {
66
+ out.failSpinner('Could not predict address');
67
+ }
68
+
69
+ // Check if already deployed
70
+ if (predictedAddress) {
71
+ const code = await provider.getCode(predictedAddress);
72
+ if (code !== '0x') {
73
+ out.stopSpinner();
74
+ out.print({
75
+ address: predictedAddress,
76
+ status: 'already_deployed',
77
+ network: globalOpts.network
78
+ });
79
+ return;
80
+ }
81
+ }
82
+
83
+ // Confirm if not --yes
84
+ if (!globalOpts.yes && !globalOpts.json) {
85
+ out.stopSpinner();
86
+ out.info(`Deploying MultiSig to ${network.name}`);
87
+ out.info(`Name: ${accountName}`);
88
+ out.info(`Owners: ${owners.join(', ')}`);
89
+ out.info(`Required: ${options.required}`);
90
+ out.info(`Predicted address: ${predictedAddress || 'unknown'}`);
91
+
92
+ const inquirer = require('inquirer');
93
+ const { confirm } = await inquirer.prompt([{
94
+ type: 'confirm',
95
+ name: 'confirm',
96
+ message: 'Proceed with deployment?',
97
+ default: true
98
+ }]);
99
+ if (!confirm) {
100
+ out.warn('Deployment cancelled');
101
+ return;
102
+ }
103
+ }
104
+
105
+ // Dry run check
106
+ if (globalOpts.dryRun) {
107
+ out.print({
108
+ dryRun: true,
109
+ predictedAddress,
110
+ owners,
111
+ required: options.required,
112
+ network: globalOpts.network,
113
+ pinHash
114
+ });
115
+ return;
116
+ }
117
+
118
+ // Deploy
119
+ out.startSpinner('Deploying MultiSig account...');
120
+ const fee = 1000000000000000n; // 0.001 ETH
121
+
122
+ const tx = await factory.newMSA(owners, options.required, pinHash, accountName, { value: fee });
123
+ out.updateSpinner(`Transaction sent: ${tx.hash}`);
124
+
125
+ const receipt = await tx.wait();
126
+
127
+ // Find NewMSACreated event
128
+ let deployedAddress = predictedAddress;
129
+ const event = receipt.logs.find(log => {
130
+ try {
131
+ return factory.interface.parseLog(log)?.name === 'NewMSACreated';
132
+ } catch { return false; }
133
+ });
134
+
135
+ if (event) {
136
+ const parsed = factory.interface.parseLog(event);
137
+ deployedAddress = parsed.args.msaAddress;
138
+ }
139
+
140
+ out.succeedSpinner('MultiSig deployed successfully');
141
+
142
+ // Save contract with name as alias
143
+ saveContract(accountName, deployedAddress, globalOpts.network);
144
+
145
+ out.print({
146
+ address: deployedAddress,
147
+ txHash: receipt.hash,
148
+ network: globalOpts.network,
149
+ owners,
150
+ required: options.required,
151
+ name: accountName,
152
+ alias: accountName
153
+ });
154
+
155
+ } catch (error) {
156
+ out.error(error.message);
157
+ }
158
+ });
159
+
160
+ // account info - Show account information
161
+ account
162
+ .command('info')
163
+ .description('Show account information (owners, required confirmations, balance)')
164
+ .option('--address <address>', 'MultiSig address or alias')
165
+ .action(async (options, command) => {
166
+ const globalOpts = command.parent.parent.opts();
167
+ const out = createOutput(globalOpts);
168
+
169
+ try {
170
+ const address = resolveAddress(options.address);
171
+ // Get first network from contract's networks, or use specified network
172
+ const contractNetworks = getContractNetworks(options.address);
173
+ const networkKey = globalOpts.network || contractNetworks[0] || 'sepolia';
174
+ const network = await getNetwork(networkKey);
175
+ const provider = new ethers.JsonRpcProvider(network.rpc);
176
+
177
+ // Check if contract exists
178
+ const code = await provider.getCode(address);
179
+ if (code === '0x') {
180
+ out.error(`No contract found at ${address} on ${network.name}`);
181
+ return;
182
+ }
183
+
184
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, provider);
185
+
186
+ out.startSpinner('Fetching account info...');
187
+
188
+ const [owners, required, balance] = await Promise.all([
189
+ multisig.getOwners(),
190
+ multisig.required(),
191
+ provider.getBalance(address)
192
+ ]);
193
+
194
+ out.succeedSpinner('Account info retrieved');
195
+
196
+ out.print({
197
+ address,
198
+ network: networkKey,
199
+ networks: contractNetworks,
200
+ owners: owners.map(o => o),
201
+ required: Number(required),
202
+ ownerCount: owners.length,
203
+ confirmationsNeeded: `${required} of ${owners.length}`,
204
+ balance: ethers.formatEther(balance) + ' ETH'
205
+ });
206
+
207
+ } catch (error) {
208
+ out.error(error.message);
209
+ }
210
+ });
211
+
212
+ // account owners - List owners
213
+ account
214
+ .command('owners')
215
+ .description('List account owners')
216
+ .option('--address <address>', 'MultiSig address or alias')
217
+ .action(async (options, command) => {
218
+ const globalOpts = command.parent.parent.opts();
219
+ const out = createOutput(globalOpts);
220
+
221
+ try {
222
+ const address = resolveAddress(options.address);
223
+ // Get first network from contract's networks, or use specified network
224
+ const contractNetworks = getContractNetworks(options.address);
225
+ const networkKey = globalOpts.network || contractNetworks[0] || 'sepolia';
226
+ const network = await getNetwork(networkKey);
227
+ const provider = new ethers.JsonRpcProvider(network.rpc);
228
+
229
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, provider);
230
+
231
+ out.startSpinner('Fetching owners...');
232
+ const [owners, required] = await Promise.all([
233
+ multisig.getOwners(),
234
+ multisig.required()
235
+ ]);
236
+ out.succeedSpinner('Owners retrieved');
237
+
238
+ out.print({
239
+ address,
240
+ owners: owners.map(o => o),
241
+ required: Number(required),
242
+ total: owners.length
243
+ });
244
+
245
+ } catch (error) {
246
+ out.error(error.message);
247
+ }
248
+ });
249
+
250
+ // account add - Add owner (applies to all networks)
251
+ account
252
+ .command('add')
253
+ .description('Add a new owner to the account (applies to all configured networks)')
254
+ .option('--address <address>', 'MultiSig address or alias')
255
+ .requiredOption('--owner <ownerAddress>', 'Address of new owner to add')
256
+ .requiredOption('--pin <pin>', 'PIN for authentication')
257
+ .option('--force', 'Proceed even if pre-flight check fails')
258
+ .action(async (options, command) => {
259
+ const globalOpts = command.parent.parent.opts();
260
+ const out = createOutput(globalOpts);
261
+
262
+ try {
263
+ if (!ethers.isAddress(options.owner)) {
264
+ out.error(`Invalid owner address: ${options.owner}`);
265
+ return;
266
+ }
267
+
268
+ const address = resolveAddress(options.address);
269
+ const wallet = await getWallet(globalOpts);
270
+
271
+ // Get alias - either from options or from default contract
272
+ const aliasOrAddress = options.address || (getDefaultContract()?.alias);
273
+ const networks = getContractNetworks(aliasOrAddress);
274
+
275
+ // Pre-flight check across all networks
276
+ out.startSpinner('Running pre-flight checks across all networks...');
277
+ const preflight = await preflightAccountOperation(
278
+ aliasOrAddress,
279
+ address,
280
+ wallet.address,
281
+ 'addOwner',
282
+ { owner: options.owner }
283
+ );
284
+ out.stopSpinner();
285
+
286
+ displayPreflightResults(preflight, out);
287
+
288
+ if (!preflight.canProceed && !options.force) {
289
+ out.error('Pre-flight check failed. Use --force to proceed anyway.');
290
+ out.print({
291
+ preflightFailed: true,
292
+ networks: preflight.networks
293
+ });
294
+ return;
295
+ }
296
+
297
+ if (!preflight.canProceed && options.force) {
298
+ out.warn('Proceeding despite pre-flight failures (--force)');
299
+ }
300
+
301
+ if (globalOpts.dryRun) {
302
+ out.print({
303
+ dryRun: true,
304
+ action: 'addOwner',
305
+ address,
306
+ newOwner: options.owner,
307
+ networks,
308
+ preflight: preflight.networks
309
+ });
310
+ return;
311
+ }
312
+
313
+ // Execute on all networks
314
+ const results = [];
315
+ for (const networkKey of networks) {
316
+ try {
317
+ const rpc = getRpcUrl(networkKey);
318
+ if (!rpc) {
319
+ results.push({ network: networkKey, success: false, error: 'No RPC configured' });
320
+ continue;
321
+ }
322
+
323
+ const provider = new ethers.JsonRpcProvider(rpc);
324
+ const signer = wallet.connect(provider);
325
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, signer);
326
+
327
+ // Check if already owner on this network
328
+ const isAlreadyOwner = await multisig.isOwner(options.owner);
329
+ if (isAlreadyOwner) {
330
+ results.push({ network: networkKey, success: true, status: 'already_owner' });
331
+ continue;
332
+ }
333
+
334
+ out.startSpinner(`Adding owner on ${networkKey}...`);
335
+
336
+ const [storedPinHash, pinNonce] = await Promise.all([
337
+ multisig.pinHash(),
338
+ multisig.pinNonce()
339
+ ]);
340
+
341
+ const { pA, pB, pC } = await generateZkProof(options.pin, storedPinHash, pinNonce, wallet.address);
342
+
343
+ const tx = await multisig.addOwner(options.owner, pA, pB, pC);
344
+ await tx.wait();
345
+
346
+ out.succeedSpinner(`Owner added on ${networkKey}`);
347
+ results.push({ network: networkKey, success: true, txHash: tx.hash });
348
+ } catch (err) {
349
+ out.failSpinner(`Failed on ${networkKey}: ${err.message}`);
350
+ results.push({ network: networkKey, success: false, error: err.message });
351
+ }
352
+ }
353
+
354
+ const successful = results.filter(r => r.success).length;
355
+ const failed = results.filter(r => !r.success);
356
+
357
+ // Handle decoupling if some networks failed
358
+ if (failed.length > 0) {
359
+ handleDecoupling(aliasOrAddress, address, failed, out);
360
+ }
361
+
362
+ out.print({
363
+ address,
364
+ owner: options.owner,
365
+ networksProcessed: networks.length,
366
+ successful,
367
+ failed: failed.length,
368
+ results,
369
+ status: failed.length === 0 ? 'added' : 'partially_added'
370
+ });
371
+
372
+ // Force exit since snarkjs keeps handles open
373
+ process.exit(0);
374
+
375
+ } catch (error) {
376
+ out.error(error.message);
377
+ process.exit(1);
378
+ }
379
+ });
380
+
381
+ // account remove - Remove owner (applies to all networks)
382
+ account
383
+ .command('remove')
384
+ .description('Remove an owner from the account (applies to all configured networks)')
385
+ .option('--address <address>', 'MultiSig address or alias')
386
+ .requiredOption('--owner <ownerAddress>', 'Address of owner to remove')
387
+ .requiredOption('--pin <pin>', 'PIN for authentication')
388
+ .option('--force', 'Proceed even if pre-flight check fails')
389
+ .action(async (options, command) => {
390
+ const globalOpts = command.parent.parent.opts();
391
+ const out = createOutput(globalOpts);
392
+
393
+ try {
394
+ if (!ethers.isAddress(options.owner)) {
395
+ out.error(`Invalid owner address: ${options.owner}`);
396
+ return;
397
+ }
398
+
399
+ const address = resolveAddress(options.address);
400
+ const wallet = await getWallet(globalOpts);
401
+
402
+ // Get alias - either from options or from default contract
403
+ const aliasOrAddress = options.address || (getDefaultContract()?.alias);
404
+ const networks = getContractNetworks(aliasOrAddress);
405
+
406
+ // Pre-flight check across all networks
407
+ out.startSpinner('Running pre-flight checks across all networks...');
408
+ const preflight = await preflightAccountOperation(
409
+ aliasOrAddress,
410
+ address,
411
+ wallet.address,
412
+ 'removeOwner',
413
+ { owner: options.owner }
414
+ );
415
+ out.stopSpinner();
416
+
417
+ displayPreflightResults(preflight, out);
418
+
419
+ if (!preflight.canProceed && !options.force) {
420
+ out.error('Pre-flight check failed. Use --force to proceed anyway.');
421
+ out.print({
422
+ preflightFailed: true,
423
+ networks: preflight.networks
424
+ });
425
+ return;
426
+ }
427
+
428
+ if (!preflight.canProceed && options.force) {
429
+ out.warn('Proceeding despite pre-flight failures (--force)');
430
+ }
431
+
432
+ if (globalOpts.dryRun) {
433
+ out.print({
434
+ dryRun: true,
435
+ action: 'removeOwner',
436
+ address,
437
+ owner: options.owner,
438
+ networks,
439
+ preflight: preflight.networks
440
+ });
441
+ return;
442
+ }
443
+
444
+ // Execute on all networks
445
+ const results = [];
446
+ for (const networkKey of networks) {
447
+ try {
448
+ const rpc = getRpcUrl(networkKey);
449
+ if (!rpc) {
450
+ results.push({ network: networkKey, success: false, error: 'No RPC configured' });
451
+ continue;
452
+ }
453
+
454
+ const provider = new ethers.JsonRpcProvider(rpc);
455
+ const signer = wallet.connect(provider);
456
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, signer);
457
+
458
+ // Check if owner exists on this network
459
+ const isOwner = await multisig.isOwner(options.owner);
460
+ if (!isOwner) {
461
+ results.push({ network: networkKey, success: true, status: 'not_owner' });
462
+ continue;
463
+ }
464
+
465
+ out.startSpinner(`Removing owner on ${networkKey}...`);
466
+
467
+ const [storedPinHash, pinNonce] = await Promise.all([
468
+ multisig.pinHash(),
469
+ multisig.pinNonce()
470
+ ]);
471
+
472
+ const { pA, pB, pC } = await generateZkProof(options.pin, storedPinHash, pinNonce, wallet.address);
473
+
474
+ const tx = await multisig.removeOwner(options.owner, pA, pB, pC);
475
+ await tx.wait();
476
+
477
+ out.succeedSpinner(`Owner removed on ${networkKey}`);
478
+ results.push({ network: networkKey, success: true, txHash: tx.hash });
479
+ } catch (err) {
480
+ out.failSpinner(`Failed on ${networkKey}: ${err.message}`);
481
+ results.push({ network: networkKey, success: false, error: err.message });
482
+ }
483
+ }
484
+
485
+ const successful = results.filter(r => r.success).length;
486
+ const failed = results.filter(r => !r.success);
487
+
488
+ // Handle decoupling if some networks failed
489
+ if (failed.length > 0) {
490
+ handleDecoupling(aliasOrAddress, address, failed, out);
491
+ }
492
+
493
+ out.print({
494
+ address,
495
+ owner: options.owner,
496
+ networksProcessed: networks.length,
497
+ successful,
498
+ failed: failed.length,
499
+ results,
500
+ status: failed.length === 0 ? 'removed' : 'partially_removed'
501
+ });
502
+
503
+ // Force exit since snarkjs keeps handles open
504
+ process.exit(0);
505
+
506
+ } catch (error) {
507
+ out.error(error.message);
508
+ process.exit(1);
509
+ }
510
+ });
511
+
512
+ // account replace - Replace owner (applies to all networks)
513
+ account
514
+ .command('replace')
515
+ .description('Replace an existing owner with a new one (applies to all configured networks)')
516
+ .option('--address <address>', 'MultiSig address or alias')
517
+ .requiredOption('--old <oldOwner>', 'Address of owner to replace')
518
+ .requiredOption('--new <newOwner>', 'Address of new owner')
519
+ .requiredOption('--pin <pin>', 'PIN for authentication')
520
+ .option('--force', 'Proceed even if pre-flight check fails')
521
+ .action(async (options, command) => {
522
+ const globalOpts = command.parent.parent.opts();
523
+ const out = createOutput(globalOpts);
524
+
525
+ try {
526
+ if (!ethers.isAddress(options.old)) {
527
+ out.error(`Invalid old owner address: ${options.old}`);
528
+ return;
529
+ }
530
+ if (!ethers.isAddress(options.new)) {
531
+ out.error(`Invalid new owner address: ${options.new}`);
532
+ return;
533
+ }
534
+
535
+ const address = resolveAddress(options.address);
536
+ const wallet = await getWallet(globalOpts);
537
+
538
+ // Get alias - either from options or from default contract
539
+ const aliasOrAddress = options.address || (getDefaultContract()?.alias);
540
+ const networks = getContractNetworks(aliasOrAddress);
541
+
542
+ // Pre-flight check across all networks
543
+ out.startSpinner('Running pre-flight checks across all networks...');
544
+ const preflight = await preflightAccountOperation(
545
+ aliasOrAddress,
546
+ address,
547
+ wallet.address,
548
+ 'replaceOwner',
549
+ { oldOwner: options.old, newOwner: options.new }
550
+ );
551
+ out.stopSpinner();
552
+
553
+ displayPreflightResults(preflight, out);
554
+
555
+ if (!preflight.canProceed && !options.force) {
556
+ out.error('Pre-flight check failed. Use --force to proceed anyway.');
557
+ out.print({
558
+ preflightFailed: true,
559
+ networks: preflight.networks
560
+ });
561
+ return;
562
+ }
563
+
564
+ if (!preflight.canProceed && options.force) {
565
+ out.warn('Proceeding despite pre-flight failures (--force)');
566
+ }
567
+
568
+ if (globalOpts.dryRun) {
569
+ out.print({
570
+ dryRun: true,
571
+ action: 'replaceOwner',
572
+ address,
573
+ oldOwner: options.old,
574
+ newOwner: options.new,
575
+ networks,
576
+ preflight: preflight.networks
577
+ });
578
+ return;
579
+ }
580
+
581
+ // Execute on all networks
582
+ const results = [];
583
+ for (const networkKey of networks) {
584
+ try {
585
+ const rpc = getRpcUrl(networkKey);
586
+ if (!rpc) {
587
+ results.push({ network: networkKey, success: false, error: 'No RPC configured' });
588
+ continue;
589
+ }
590
+
591
+ const provider = new ethers.JsonRpcProvider(rpc);
592
+ const signer = wallet.connect(provider);
593
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, signer);
594
+
595
+ out.startSpinner(`Replacing owner on ${networkKey}...`);
596
+
597
+ const [storedPinHash, pinNonce] = await Promise.all([
598
+ multisig.pinHash(),
599
+ multisig.pinNonce()
600
+ ]);
601
+
602
+ const { pA, pB, pC } = await generateZkProof(options.pin, storedPinHash, pinNonce, wallet.address);
603
+
604
+ const tx = await multisig.replaceOwner(options.old, options.new, pA, pB, pC);
605
+ await tx.wait();
606
+
607
+ out.succeedSpinner(`Owner replaced on ${networkKey}`);
608
+ results.push({ network: networkKey, success: true, txHash: tx.hash });
609
+ } catch (err) {
610
+ out.failSpinner(`Failed on ${networkKey}: ${err.message}`);
611
+ results.push({ network: networkKey, success: false, error: err.message });
612
+ }
613
+ }
614
+
615
+ const successful = results.filter(r => r.success).length;
616
+ const failed = results.filter(r => !r.success);
617
+
618
+ // Handle decoupling if some networks failed
619
+ if (failed.length > 0) {
620
+ handleDecoupling(aliasOrAddress, address, failed, out);
621
+ }
622
+
623
+ out.print({
624
+ address,
625
+ oldOwner: options.old,
626
+ newOwner: options.new,
627
+ networksProcessed: networks.length,
628
+ successful,
629
+ failed: failed.length,
630
+ results,
631
+ status: failed.length === 0 ? 'replaced' : 'partially_replaced'
632
+ });
633
+
634
+ // Force exit since snarkjs keeps handles open
635
+ process.exit(0);
636
+
637
+ } catch (error) {
638
+ out.error(error.message);
639
+ process.exit(1);
640
+ }
641
+ });
642
+
643
+ // account sync - Synchronize account state across all networks
644
+ account
645
+ .command('sync')
646
+ .description('Synchronize account owners and requirements across all networks')
647
+ .option('--address <address>', 'MultiSig address or alias')
648
+ .option('--dry-run', 'Show what would be synced without making changes')
649
+ .requiredOption('--pin <pin>', 'PIN for authentication')
650
+ .action(async (options, command) => {
651
+ const globalOpts = command.parent.parent.opts();
652
+ const out = createOutput(globalOpts);
653
+
654
+ try {
655
+ const address = resolveAddress(options.address);
656
+ const networks = getContractNetworks(options.address);
657
+
658
+ if (networks.length < 2) {
659
+ out.info('Account is only configured for one network. Nothing to sync.');
660
+ return;
661
+ }
662
+
663
+ out.startSpinner(`Checking account state across ${networks.length} networks...`);
664
+
665
+ // Fetch state from all networks
666
+ const states = await fetchAccountStateAcrossNetworks(options.address, address);
667
+ const analysis = analyzeAccountSync(states);
668
+
669
+ out.stopSpinner();
670
+
671
+ if (analysis.inSync) {
672
+ out.print({
673
+ address,
674
+ networks,
675
+ status: 'in_sync',
676
+ owners: analysis.canonical.owners,
677
+ required: analysis.canonical.required,
678
+ message: 'Account is in sync across all networks'
679
+ });
680
+ return;
681
+ }
682
+
683
+ // Show discrepancies
684
+ if (!globalOpts.json) {
685
+ const chalk = require('chalk');
686
+ console.log(chalk.yellow('\n⚠️ Account is out of sync:\n'));
687
+ console.log(chalk.gray(`Canonical state (${analysis.networkCount - analysis.discrepancies.length} networks):`));
688
+ console.log(chalk.gray(` Owners: ${analysis.canonical.owners.join(', ')}`));
689
+ console.log(chalk.gray(` Required: ${analysis.canonical.required}\n`));
690
+
691
+ for (const d of analysis.discrepancies) {
692
+ console.log(chalk.red(`Network: ${d.network}`));
693
+ if (d.missingOwners.length > 0) {
694
+ console.log(chalk.yellow(` Missing owners: ${d.missingOwners.join(', ')}`));
695
+ }
696
+ if (d.extraOwners.length > 0) {
697
+ console.log(chalk.yellow(` Extra owners: ${d.extraOwners.join(', ')}`));
698
+ }
699
+ if (d.requirementMismatch) {
700
+ console.log(chalk.yellow(` Required: ${d.currentRequired} (should be ${analysis.canonical.required})`));
701
+ }
702
+ }
703
+ }
704
+
705
+ if (options.dryRun || globalOpts.dryRun) {
706
+ out.print({
707
+ dryRun: true,
708
+ address,
709
+ canonical: analysis.canonical,
710
+ discrepancies: analysis.discrepancies
711
+ });
712
+ return;
713
+ }
714
+
715
+ // Confirm sync
716
+ if (!globalOpts.yes && !globalOpts.json) {
717
+ const inquirer = require('inquirer');
718
+ const { confirm } = await inquirer.prompt([{
719
+ type: 'confirm',
720
+ name: 'confirm',
721
+ message: 'Proceed with synchronization?',
722
+ default: false
723
+ }]);
724
+ if (!confirm) {
725
+ out.warn('Sync cancelled');
726
+ return;
727
+ }
728
+ }
729
+
730
+ // Get wallet for signing
731
+ const wallet = await getWallet(globalOpts);
732
+ const results = [];
733
+
734
+ // Sync each discrepant network
735
+ for (const d of analysis.discrepancies) {
736
+ out.startSpinner(`Syncing ${d.network}...`);
737
+
738
+ try {
739
+ const rpc = getRpcUrl(d.network);
740
+ const provider = new ethers.JsonRpcProvider(rpc);
741
+ const signer = wallet.connect(provider);
742
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, signer);
743
+
744
+ // Get zkSNARK proof data
745
+ const [storedPinHash, pinNonce] = await Promise.all([
746
+ multisig.pinHash(),
747
+ multisig.pinNonce()
748
+ ]);
749
+
750
+ // Add missing owners
751
+ for (const owner of d.missingOwners) {
752
+ out.updateSpinner(`Adding owner ${owner.slice(0, 10)}... on ${d.network}`);
753
+ const { pA, pB, pC } = await generateZkProof(options.pin, storedPinHash, pinNonce, wallet.address);
754
+ const tx = await multisig.addOwner(owner, pA, pB, pC);
755
+ await tx.wait();
756
+ }
757
+
758
+ // Remove extra owners
759
+ for (const owner of d.extraOwners) {
760
+ out.updateSpinner(`Removing owner ${owner.slice(0, 10)}... on ${d.network}`);
761
+ const { pA, pB, pC } = await generateZkProof(options.pin, storedPinHash, pinNonce, wallet.address);
762
+ const tx = await multisig.removeOwner(owner, pA, pB, pC);
763
+ await tx.wait();
764
+ }
765
+
766
+ // Fix requirement if needed
767
+ if (d.requirementMismatch) {
768
+ out.updateSpinner(`Updating requirement on ${d.network}`);
769
+ const { pA, pB, pC } = await generateZkProof(options.pin, storedPinHash, pinNonce, wallet.address);
770
+ const tx = await multisig.changeRequirement(analysis.canonical.required, pA, pB, pC);
771
+ await tx.wait();
772
+ }
773
+
774
+ results.push({ network: d.network, success: true });
775
+ out.succeedSpinner(`Synced ${d.network}`);
776
+ } catch (err) {
777
+ results.push({ network: d.network, success: false, error: err.message });
778
+ out.failSpinner(`Failed to sync ${d.network}: ${err.message}`);
779
+ }
780
+ }
781
+
782
+ const successful = results.filter(r => r.success).length;
783
+ out.print({
784
+ address,
785
+ syncedNetworks: successful,
786
+ totalDiscrepancies: analysis.discrepancies.length,
787
+ results,
788
+ status: successful === analysis.discrepancies.length ? 'fully_synced' : 'partially_synced'
789
+ });
790
+
791
+ // Force exit since snarkjs keeps handles open
792
+ process.exit(0);
793
+
794
+ } catch (error) {
795
+ out.error(error.message);
796
+ process.exit(1);
797
+ }
798
+ });
799
+
800
+ // account status - Check sync status across all networks
801
+ account
802
+ .command('status')
803
+ .description('Check account sync status across all configured networks')
804
+ .option('--address <address>', 'MultiSig address or alias')
805
+ .action(async (options, command) => {
806
+ const globalOpts = command.parent.parent.opts();
807
+ const out = createOutput(globalOpts);
808
+
809
+ try {
810
+ const address = resolveAddress(options.address);
811
+ const networks = getContractNetworks(options.address);
812
+
813
+ if (networks.length === 0) {
814
+ out.warn('No networks configured for this contract.');
815
+ return;
816
+ }
817
+
818
+ out.startSpinner(`Checking account state across ${networks.length} networks...`);
819
+
820
+ const states = await fetchAccountStateAcrossNetworks(options.address, address);
821
+ const analysis = analyzeAccountSync(states);
822
+
823
+ out.stopSpinner();
824
+
825
+ if (analysis.inSync) {
826
+ if (!globalOpts.json) {
827
+ const chalk = require('chalk');
828
+ console.log(chalk.green('\n✓ Account is in sync across all networks\n'));
829
+ console.log(chalk.gray(`Networks: ${networks.join(', ')}`));
830
+ console.log(chalk.gray(`Owners: ${analysis.canonical.owners.length}`));
831
+ console.log(chalk.gray(`Required: ${analysis.canonical.required}`));
832
+ }
833
+ } else {
834
+ if (!globalOpts.json) {
835
+ const chalk = require('chalk');
836
+ console.log(chalk.yellow('\n⚠️ Account is OUT OF SYNC\n'));
837
+ console.log(chalk.gray('Run "ethnotary account sync" to synchronize.'));
838
+ }
839
+ }
840
+
841
+ out.print({
842
+ address,
843
+ networks,
844
+ inSync: analysis.inSync,
845
+ canonical: analysis.canonical,
846
+ discrepancies: analysis.discrepancies,
847
+ networkStates: states
848
+ });
849
+
850
+ } catch (error) {
851
+ out.error(error.message);
852
+ }
853
+ });
854
+
855
+ module.exports = account;