@tokamak-private-dapps/private-state-cli 1.0.2 → 1.1.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.
@@ -7,6 +7,7 @@ import process from "node:process";
7
7
  import readline from "node:readline/promises";
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { createRequire } from "node:module";
10
+ import AdmZip from "adm-zip";
10
11
  import {
11
12
  createCipheriv,
12
13
  createDecipheriv,
@@ -132,6 +133,8 @@ const PRIVATE_STATE_UNINSTALL_CONFIRMATION =
132
133
  const PRIVATE_STATE_CLI_PACKAGE_NAME = privateStateCliPackageJson.name;
133
134
  const GROTH16_PACKAGE_NAME = "@tokamak-private-dapps/groth16";
134
135
  const TOKAMAK_ZKEVM_CLI_PACKAGE_NAME = "@tokamak-zk-evm/cli";
136
+ const WALLET_EXPORT_FORMAT = "tokamak-private-state-wallet-export";
137
+ const WALLET_EXPORT_FORMAT_VERSION = 1;
135
138
  let jsonOutputRequested = false;
136
139
  let activeCliArgs = {};
137
140
 
@@ -329,6 +332,12 @@ async function main() {
329
332
  return;
330
333
  }
331
334
 
335
+ if (args.command === "help-commands") {
336
+ assertHelpCommandsArgs(args);
337
+ printHelp();
338
+ return;
339
+ }
340
+
332
341
  if (args.command === "install") {
333
342
  assertInstallZkEvmArgs(args);
334
343
  await handleInstallZkEvm({ args });
@@ -341,34 +350,34 @@ async function main() {
341
350
  return;
342
351
  }
343
352
 
344
- if (args.command === "update") {
353
+ if (args.command === "help-update") {
345
354
  assertUpdateArgs(args);
346
355
  await handleUpdate();
347
356
  return;
348
357
  }
349
358
 
350
- if (args.command === "doctor") {
359
+ if (args.command === "help-doctor") {
351
360
  assertDoctorArgs(args);
352
361
  await handleDoctor({ args });
353
362
  return;
354
363
  }
355
364
 
356
- if (args.command === "guide") {
365
+ if (args.command === "help-guide") {
357
366
  assertGuideArgs(args);
358
367
  await handleGuide({ args });
359
368
  return;
360
369
  }
361
370
 
362
- if (args.command === "transaction-fees") {
371
+ if (args.command === "help-transaction-fees") {
363
372
  assertTransactionFeesArgs(args);
364
373
  const { network, provider, rpcUrl } = loadExplicitCommandRuntime(args);
365
374
  await handleTransactionFees({ network, provider, rpcUrl });
366
375
  return;
367
376
  }
368
377
 
369
- if (args.command === "get-my-l1-address") {
370
- assertGetMyL1AddressArgs(args);
371
- handleGetMyL1Address({ args });
378
+ if (args.command === "account-get-l1-address") {
379
+ assertAccountGetL1AddressArgs(args);
380
+ handleAccountGetL1Address({ args });
372
381
  return;
373
382
  }
374
383
 
@@ -378,46 +387,58 @@ async function main() {
378
387
  return;
379
388
  }
380
389
 
381
- if (args.command === "list-local-wallets") {
390
+ if (args.command === "wallet-list") {
382
391
  assertListLocalWalletsArgs(args);
383
392
  handleListLocalWallets({ args });
384
393
  return;
385
394
  }
386
395
 
396
+ if (args.command === "wallet-export") {
397
+ assertWalletExportArgs(args);
398
+ handleWalletExport({ args });
399
+ return;
400
+ }
401
+
402
+ if (args.command === "wallet-import") {
403
+ assertWalletImportArgs(args);
404
+ handleWalletImport({ args });
405
+ return;
406
+ }
407
+
387
408
  const walletCommandHandlers = {
388
- "mint-notes": {
409
+ "wallet-mint-notes": {
389
410
  assert: assertMintNotesArgs,
390
411
  run: ({ provider }) => handleMintNotes({ args, provider }),
391
412
  },
392
- "redeem-notes": {
413
+ "wallet-redeem-notes": {
393
414
  assert: assertRedeemNotesArgs,
394
415
  run: ({ provider }) => handleRedeemNotes({ args, provider }),
395
416
  },
396
- "get-my-notes": {
397
- assert: assertGetMyNotesArgs,
398
- run: ({ provider }) => handleGetMyNotes({ args, provider }),
417
+ "wallet-get-notes": {
418
+ assert: assertWalletGetNotesArgs,
419
+ run: ({ provider }) => handleWalletGetNotes({ args, provider }),
399
420
  },
400
- "transfer-notes": {
421
+ "wallet-transfer-notes": {
401
422
  assert: assertTransferNotesArgs,
402
423
  run: ({ provider }) => handleTransferNotes({ args, provider }),
403
424
  },
404
- "deposit-channel": {
405
- assert: (parsedArgs) => assertWalletChannelMoveArgs(parsedArgs, "deposit-channel"),
425
+ "wallet-deposit-channel": {
426
+ assert: (parsedArgs) => assertWalletChannelMoveArgs(parsedArgs, "wallet-deposit-channel"),
406
427
  run: ({ provider }) => handleGrothVaultMove({ args, provider, direction: "deposit" }),
407
428
  },
408
- "withdraw-channel": {
409
- assert: (parsedArgs) => assertWalletChannelMoveArgs(parsedArgs, "withdraw-channel"),
429
+ "wallet-withdraw-channel": {
430
+ assert: (parsedArgs) => assertWalletChannelMoveArgs(parsedArgs, "wallet-withdraw-channel"),
410
431
  run: ({ provider }) => handleGrothVaultMove({ args, provider, direction: "withdraw" }),
411
432
  },
412
- "get-my-wallet-meta": {
413
- assert: assertGetMyWalletMetaArgs,
414
- run: ({ provider }) => handleGetMyWalletMeta({ args, provider }),
433
+ "wallet-get-meta": {
434
+ assert: assertWalletGetMetaArgs,
435
+ run: ({ provider }) => handleWalletGetMeta({ args, provider }),
415
436
  },
416
- "get-my-channel-fund": {
417
- assert: assertGetMyChannelFundArgs,
418
- run: ({ provider }) => handleGetMyChannelFund({ args, provider }),
437
+ "wallet-get-channel-fund": {
438
+ assert: assertWalletGetChannelFundArgs,
439
+ run: ({ provider }) => handleWalletGetChannelFund({ args, provider }),
419
440
  },
420
- "exit-channel": {
441
+ "channel-exit": {
421
442
  assert: assertExitChannelArgs,
422
443
  run: ({ provider }) => handleExitChannel({ args, provider }),
423
444
  },
@@ -431,56 +452,56 @@ async function main() {
431
452
  }
432
453
 
433
454
  switch (args.command) {
434
- case "create-channel": {
455
+ case "channel-create": {
435
456
  assertCreateChannelArgs(args);
436
457
  const { network, provider } = loadExplicitCommandRuntime(args);
437
458
  await prepareDeploymentArtifacts(network.chainId);
438
459
  await handleChannelCreate({ args, network, provider });
439
460
  return;
440
461
  }
441
- case "recover-workspace": {
462
+ case "channel-recover-workspace": {
442
463
  assertRecoverWorkspaceArgs(args);
443
464
  const { network, provider } = loadExplicitCommandRuntime(args);
444
465
  await prepareDeploymentArtifacts(network.chainId);
445
466
  await handleWorkspaceInit({ args, network, provider });
446
467
  return;
447
468
  }
448
- case "get-channel": {
469
+ case "channel-get-meta": {
449
470
  assertGetChannelArgs(args);
450
471
  const { network, provider } = loadExplicitCommandRuntime(args);
451
472
  await prepareDeploymentArtifacts(network.chainId);
452
473
  await handleGetChannel({ args, network, provider });
453
474
  return;
454
475
  }
455
- case "deposit-bridge": {
476
+ case "account-deposit-bridge": {
456
477
  assertDepositBridgeArgs(args);
457
478
  const { network, provider } = loadExplicitCommandRuntime(args);
458
479
  await prepareDeploymentArtifacts(network.chainId);
459
480
  await handleDepositBridge({ args, network, provider });
460
481
  return;
461
482
  }
462
- case "withdraw-bridge": {
483
+ case "account-withdraw-bridge": {
463
484
  assertWithdrawBridgeArgs(args);
464
485
  const { network, provider } = loadExplicitCommandRuntime(args);
465
486
  await prepareDeploymentArtifacts(network.chainId);
466
487
  await handleWithdrawBridge({ args, network, provider });
467
488
  return;
468
489
  }
469
- case "get-my-bridge-fund": {
470
- assertGetMyBridgeFundArgs(args);
490
+ case "account-get-bridge-fund": {
491
+ assertAccountGetBridgeFundArgs(args);
471
492
  const { network, provider } = loadExplicitCommandRuntime(args);
472
493
  await prepareDeploymentArtifacts(network.chainId);
473
- await handleGetMyBridgeFund({ args, provider });
494
+ await handleAccountGetBridgeFund({ args, provider });
474
495
  return;
475
496
  }
476
- case "recover-wallet": {
497
+ case "wallet-recover-workspace": {
477
498
  assertRecoverWalletArgs(args);
478
499
  const { network, provider, rpcUrl } = loadExplicitCommandRuntime(args);
479
500
  await prepareDeploymentArtifacts(network.chainId);
480
501
  await handleRecoverWallet({ args, network, provider, rpcUrl });
481
502
  return;
482
503
  }
483
- case "join-channel": {
504
+ case "channel-join": {
484
505
  assertJoinChannelArgs(args);
485
506
  const { network, provider, rpcUrl } = loadExplicitCommandRuntime(args);
486
507
  await prepareDeploymentArtifacts(network.chainId);
@@ -518,7 +539,7 @@ async function handleChannelCreate({ args, network, provider }) {
518
539
  const policySnapshot = dapp.policySnapshot;
519
540
 
520
541
  printImmutableChannelPolicyWarning({
521
- action: "create-channel",
542
+ action: "channel create",
522
543
  channelName,
523
544
  channelId,
524
545
  policySnapshot,
@@ -527,7 +548,7 @@ async function handleChannelCreate({ args, network, provider }) {
527
548
  await waitForReceipt(await bridgeCore.createChannel(channelId, dappId, joinToll, dapp.metadataDigest));
528
549
  const channelInfo = await bridgeCore.getChannel(channelId);
529
550
 
530
- const workspaceResult = await initializeChannelWorkspace({
551
+ const workspaceResult = await syncChannelWorkspace({
531
552
  workspaceName,
532
553
  channelName,
533
554
  network,
@@ -535,11 +556,12 @@ async function handleChannelCreate({ args, network, provider }) {
535
556
  bridgeResources,
536
557
  persist: true,
537
558
  fromGenesis: true,
538
- progressAction: "create-channel",
559
+ minimumToBlock: receipt.blockNumber,
560
+ progressAction: "channel create",
539
561
  });
540
562
 
541
563
  printJson({
542
- action: "create-channel",
564
+ action: "channel create",
543
565
  channelName,
544
566
  channelId: channelId.toString(),
545
567
  dappId,
@@ -637,7 +659,7 @@ async function handleWorkspaceInit({ args, network, provider }) {
637
659
  const workspaceName = channelName;
638
660
  const bridgeResources = loadBridgeResources({ chainId: network.chainId });
639
661
 
640
- const { workspaceDir, workspace, currentSnapshot } = await initializeChannelWorkspace({
662
+ const { workspaceDir, workspace, currentSnapshot } = await syncChannelWorkspace({
641
663
  workspaceName,
642
664
  channelName,
643
665
  network,
@@ -647,11 +669,11 @@ async function handleWorkspaceInit({ args, network, provider }) {
647
669
  allowExistingWorkspaceSync: true,
648
670
  useWorkspaceRecoveryIndex: true,
649
671
  fromGenesis: args.fromGenesis === true,
650
- progressAction: "recover-workspace",
672
+ progressAction: "channel recover-workspace",
651
673
  });
652
674
 
653
675
  printJson({
654
- action: "recover-workspace",
676
+ action: "channel recover-workspace",
655
677
  workspace: workspaceName,
656
678
  workspaceDir,
657
679
  channelName,
@@ -683,7 +705,7 @@ async function handleGetChannel({ args, network, provider }) {
683
705
  } catch (error) {
684
706
  if (isContractError(error, bridgeCore.interface, "UnknownChannel")) {
685
707
  printJson({
686
- action: "get-channel",
708
+ action: "channel get-meta",
687
709
  channelName,
688
710
  channelId: channelId.toString(),
689
711
  exists: false,
@@ -722,7 +744,7 @@ async function handleGetChannel({ args, network, provider }) {
722
744
  ]);
723
745
 
724
746
  printJson({
725
- action: "get-channel",
747
+ action: "channel get-meta",
726
748
  channelName,
727
749
  channelId: channelId.toString(),
728
750
  exists: true,
@@ -745,7 +767,7 @@ async function handleGetChannel({ args, network, provider }) {
745
767
  });
746
768
  }
747
769
 
748
- async function initializeChannelWorkspace({
770
+ async function syncChannelWorkspace({
749
771
  workspaceName,
750
772
  channelName,
751
773
  network,
@@ -755,6 +777,7 @@ async function initializeChannelWorkspace({
755
777
  allowExistingWorkspaceSync = false,
756
778
  useWorkspaceRecoveryIndex = false,
757
779
  fromGenesis = false,
780
+ minimumToBlock = null,
758
781
  progressAction = null,
759
782
  }) {
760
783
  const workspaceDir = channelWorkspacePath(networkNameFromChainId(network.chainId), workspaceName);
@@ -788,7 +811,10 @@ async function initializeChannelWorkspace({
788
811
  const canonicalAssetDecimals = await fetchTokenDecimals(provider, canonicalAsset);
789
812
  const currentRootVectorHash = normalizeBytes32Hex(await channelManager.currentRootVectorHash());
790
813
  const genesisBlockNumber = Number(await channelManager.genesisBlockNumber());
791
- const latestBlock = await provider.getBlockNumber();
814
+ const observedLatestBlock = await provider.getBlockNumber();
815
+ const latestBlock = minimumToBlock === null
816
+ ? observedLatestBlock
817
+ : Math.max(observedLatestBlock, Number(minimumToBlock));
792
818
  const managedStorageAddresses = normalizedAddressVector(await channelManager.getManagedStorageAddresses());
793
819
  const policySnapshot = await readChannelPolicySnapshot({
794
820
  channelManager,
@@ -837,7 +863,7 @@ async function initializeChannelWorkspace({
837
863
  throw new Error([
838
864
  `Workspace recovery index is missing or unusable for channel ${channelName} on ${networkNameFromChainId(network.chainId)}.`,
839
865
  "The CLI will not fall back to replaying channel logs from genesis unless explicitly requested.",
840
- "Run recover-workspace --from-genesis or recover-wallet --from-genesis to rebuild from channel genesis.",
866
+ "Run channel recover-workspace --from-genesis or wallet recover-workspace --from-genesis to rebuild from channel genesis.",
841
867
  ].join(" "));
842
868
  }
843
869
  const reconstruction = localSnapshotReusable
@@ -928,7 +954,7 @@ async function initializeChannelWorkspace({
928
954
  async function handleDepositBridge({ args, network, provider }) {
929
955
  if (args.wallet !== undefined) {
930
956
  throw new Error(
931
- "--wallet is not supported by deposit-bridge. Channel wallet keys are set up only by join-channel.",
957
+ "--wallet is not supported by account deposit-bridge. Channel wallet keys are set up only by channel join.",
932
958
  );
933
959
  }
934
960
  const signer = requireL1Signer(args, provider);
@@ -952,7 +978,7 @@ async function handleDepositBridge({ args, network, provider }) {
952
978
  const availableBalance = await bridgeTokenVault.availableBalanceOf(signer.address);
953
979
 
954
980
  printJson({
955
- action: "deposit-bridge",
981
+ action: "account deposit-bridge",
956
982
  amountInput,
957
983
  amountBaseUnits: amount.toString(),
958
984
  l1Address: signer.address,
@@ -968,7 +994,7 @@ async function handleDepositBridge({ args, network, provider }) {
968
994
  });
969
995
  }
970
996
 
971
- async function handleGetMyBridgeFund({ args, provider }) {
997
+ async function handleAccountGetBridgeFund({ args, provider }) {
972
998
  const signer = requireL1Signer(args, provider);
973
999
  const chainId = Number((await provider.getNetwork()).chainId);
974
1000
  const bridgeVaultContext = await loadBridgeVaultContext({ provider, chainId });
@@ -980,7 +1006,7 @@ async function handleGetMyBridgeFund({ args, provider }) {
980
1006
  const availableBalance = await bridgeTokenVault.availableBalanceOf(signer.address);
981
1007
 
982
1008
  printJson({
983
- action: "get-my-bridge-fund",
1009
+ action: "account get-bridge-fund",
984
1010
  l1Address: signer.address,
985
1011
  bridgeTokenVault: bridgeVaultContext.bridgeTokenVaultAddress,
986
1012
  canonicalAsset: bridgeVaultContext.canonicalAsset,
@@ -1002,7 +1028,7 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
1002
1028
  walletName,
1003
1029
  });
1004
1030
  const bridgeResources = loadBridgeResources({ chainId: network.chainId });
1005
- const initialized = await initializeChannelWorkspace({
1031
+ const initialized = await syncChannelWorkspace({
1006
1032
  workspaceName: channelName,
1007
1033
  channelName,
1008
1034
  network,
@@ -1012,7 +1038,7 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
1012
1038
  allowExistingWorkspaceSync: true,
1013
1039
  useWorkspaceRecoveryIndex: true,
1014
1040
  fromGenesis: args.fromGenesis === true,
1015
- progressAction: "recover-wallet",
1041
+ progressAction: "wallet recover-workspace",
1016
1042
  });
1017
1043
  const context = {
1018
1044
  workspaceName: channelName,
@@ -1050,13 +1076,41 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
1050
1076
  const leafIndex = deriveChannelTokenVaultLeafIndex(storageKey);
1051
1077
  const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
1052
1078
 
1053
- expect(
1054
- registration.exists,
1055
- cliError(
1056
- CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION,
1057
- `No channelTokenVault registration exists for ${signer.address}. Run join-channel first.`,
1058
- ),
1059
- );
1079
+ if (!registration.exists) {
1080
+ const cleanup = removeLocalWalletArtifacts(walletName, context.workspace.network);
1081
+ if (cleanup.removed) {
1082
+ printJson({
1083
+ action: "wallet recover-workspace",
1084
+ status: "stale-wallet-removed",
1085
+ wallet: walletName,
1086
+ removedWalletDir: cleanup.removedWalletDir ? cleanup.walletDir : null,
1087
+ removedWalletSecretFile: cleanup.removedWalletSecret ? cleanup.walletSecretFile : null,
1088
+ walletSecretSource: resolvedWalletSecretSource(args),
1089
+ walletSecretFile: resolvedWalletSecretFile(network.name, walletName),
1090
+ workspace: context.workspaceName,
1091
+ channelName: context.workspace.channelName,
1092
+ channelId: context.workspace.channelId,
1093
+ l1Address: signer.address,
1094
+ l2Address: l2Identity.l2Address,
1095
+ l2StorageKey: storageKey,
1096
+ leafIndex: leafIndex.toString(),
1097
+ reason: "The local wallet existed, but the L1 address is no longer registered in the channel.",
1098
+ nextAction: buildRecoverWalletRemovedNextAction({
1099
+ channelName,
1100
+ networkName: network.name,
1101
+ accountName: args.account,
1102
+ }),
1103
+ });
1104
+ return;
1105
+ }
1106
+ expect(
1107
+ false,
1108
+ cliError(
1109
+ CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION,
1110
+ `No channelTokenVault registration exists for ${signer.address}. Run channel join first.`,
1111
+ ),
1112
+ );
1113
+ }
1060
1114
  expect(
1061
1115
  ethers.toBigInt(getAddress(registration.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address)),
1062
1116
  "The existing channel registration L2 address does not match the derived L2 address.",
@@ -1100,10 +1154,10 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
1100
1154
  provider,
1101
1155
  signer,
1102
1156
  noteReceiveKeyMaterial,
1103
- progressAction: "recover-wallet",
1157
+ progressAction: "wallet recover-workspace",
1104
1158
  });
1105
1159
  printJson({
1106
- action: "recover-wallet",
1160
+ action: "wallet recover-workspace",
1107
1161
  status: "already-recovered",
1108
1162
  wallet: walletName,
1109
1163
  walletDir: existingWallet.walletDir,
@@ -1125,7 +1179,7 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
1125
1179
  return;
1126
1180
  }
1127
1181
 
1128
- clearWalletRecoveryArtifacts(walletPath(walletName, context.workspace.network));
1182
+ fs.rmSync(walletPath(walletName, context.workspace.network), { recursive: true, force: true });
1129
1183
 
1130
1184
  const walletContext = ensureWallet({
1131
1185
  channelContext: context,
@@ -1147,11 +1201,11 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
1147
1201
  provider,
1148
1202
  signer,
1149
1203
  noteReceiveKeyMaterial,
1150
- progressAction: "recover-wallet",
1204
+ progressAction: "wallet recover-workspace",
1151
1205
  });
1152
1206
 
1153
1207
  printJson({
1154
- action: "recover-wallet",
1208
+ action: "wallet recover-workspace",
1155
1209
  status: "recovered",
1156
1210
  wallet: walletName,
1157
1211
  walletDir: walletContext.walletDir,
@@ -1301,8 +1355,30 @@ function assertExistingRecoverableWallet({
1301
1355
  );
1302
1356
  }
1303
1357
 
1304
- function clearWalletRecoveryArtifacts(walletDir) {
1305
- fs.rmSync(walletDir, { recursive: true, force: true });
1358
+ function removeLocalWalletArtifacts(walletName, networkName) {
1359
+ const walletDir = walletPath(walletName, networkName);
1360
+ const walletSecretFile = walletSecretPath(networkName, walletName);
1361
+ const walletSecretDir = path.dirname(walletSecretFile);
1362
+ const removedWalletDir = fs.existsSync(walletDir);
1363
+ const removedWalletSecret = fs.existsSync(walletSecretFile) || fs.existsSync(walletSecretDir);
1364
+ if (removedWalletDir) {
1365
+ fs.rmSync(walletDir, { recursive: true, force: true });
1366
+ }
1367
+ if (removedWalletSecret) {
1368
+ fs.rmSync(walletSecretDir, { recursive: true, force: true });
1369
+ }
1370
+ return {
1371
+ walletDir,
1372
+ walletSecretFile,
1373
+ removed: removedWalletDir || removedWalletSecret,
1374
+ removedWalletDir,
1375
+ removedWalletSecret,
1376
+ };
1377
+ }
1378
+
1379
+ function buildRecoverWalletRemovedNextAction({ channelName, networkName, accountName }) {
1380
+ const account = accountName ? String(accountName) : "<ACCOUNT>";
1381
+ return `channel join --channel-name ${channelName} --network ${networkName} --account ${account} --wallet-secret-path <PATH>`;
1306
1382
  }
1307
1383
 
1308
1384
  async function handleInstallZkEvm({ args }) {
@@ -1740,10 +1816,10 @@ function trimFixedNumber(value, maxDecimals) {
1740
1816
  return trimmed ? `${integer}.${trimmed}` : integer;
1741
1817
  }
1742
1818
 
1743
- function handleGetMyL1Address({ args }) {
1819
+ function handleAccountGetL1Address({ args }) {
1744
1820
  const signer = requireL1Signer(args);
1745
1821
  printJson({
1746
- action: "get-my-l1-address",
1822
+ action: "account get-l1-address",
1747
1823
  l1Address: signer.address,
1748
1824
  account: args.account ?? null,
1749
1825
  });
@@ -1770,7 +1846,7 @@ function handleAccountImport({ args }) {
1770
1846
  privateKeyPath,
1771
1847
  }, 0o600);
1772
1848
  printJson({
1773
- action: "account-import",
1849
+ action: "account import",
1774
1850
  account,
1775
1851
  network: networkName,
1776
1852
  l1Address: getAddress(signer.address),
@@ -1791,7 +1867,7 @@ function handleListLocalWallets({ args }) {
1791
1867
  });
1792
1868
 
1793
1869
  printJson({
1794
- action: "list-local-wallets",
1870
+ action: "wallet list",
1795
1871
  workspaceRoot,
1796
1872
  filters: {
1797
1873
  network: networkFilter,
@@ -1801,6 +1877,175 @@ function handleListLocalWallets({ args }) {
1801
1877
  });
1802
1878
  }
1803
1879
 
1880
+ function handleWalletExport({ args }) {
1881
+ const outputPath = path.resolve(String(requireArg(args.output, "--output")));
1882
+ expect(!fs.existsSync(outputPath), `Export output already exists: ${outputPath}.`);
1883
+ ensureDir(path.dirname(outputPath));
1884
+
1885
+ const includeNotes = args.includeNotes === true;
1886
+ const wallets = args.all === true
1887
+ ? listLocalWallets({ networkFilter: "mainnet" }).filter((wallet) => wallet.hasEncryptedWallet)
1888
+ : [resolveExportWalletInfo({
1889
+ networkName: requireNetworkName(args),
1890
+ walletName: requireWalletName(args),
1891
+ })];
1892
+
1893
+ expect(
1894
+ wallets.length > 0,
1895
+ args.all === true
1896
+ ? "No local mainnet wallets are available to export."
1897
+ : "No local wallet is available to export.",
1898
+ );
1899
+
1900
+ const archive = new AdmZip();
1901
+ const files = new Map();
1902
+ const exportedWallets = [];
1903
+ for (const wallet of wallets) {
1904
+ const normalized = normalizeExportWalletInfo(wallet);
1905
+ exportedWallets.push({
1906
+ network: normalized.network,
1907
+ channelName: normalized.channelName,
1908
+ wallet: normalized.wallet,
1909
+ });
1910
+ for (const filePath of walletExportFilePaths(normalized, { includeNotes })) {
1911
+ const archivePath = archivePathForLocalCliFile(filePath);
1912
+ if (!files.has(archivePath)) {
1913
+ files.set(archivePath, filePath);
1914
+ }
1915
+ }
1916
+ }
1917
+
1918
+ const manifest = {
1919
+ format: WALLET_EXPORT_FORMAT,
1920
+ formatVersion: WALLET_EXPORT_FORMAT_VERSION,
1921
+ createdAt: new Date().toISOString(),
1922
+ cliPackage: PRIVATE_STATE_CLI_PACKAGE_NAME,
1923
+ cliVersion: privateStateCliPackageJson.version,
1924
+ exportMode: args.all === true ? "all-mainnet" : "single-wallet",
1925
+ includeNotes,
1926
+ notes: includeNotes
1927
+ ? [
1928
+ "Includes the channel workspace cache required for immediate wallet command use when the cache is still chain-aligned.",
1929
+ ]
1930
+ : [
1931
+ "Includes wallet identity, encrypted wallet state, metadata, and wallet-local secret only.",
1932
+ "Run channel recover-workspace after import before wallet commands need channel state.",
1933
+ ],
1934
+ wallets: exportedWallets,
1935
+ files: [...files.keys()].sort(),
1936
+ };
1937
+
1938
+ archive.addFile("manifest.json", Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"));
1939
+ for (const archivePath of manifest.files) {
1940
+ archive.addFile(archivePath, fs.readFileSync(files.get(archivePath)));
1941
+ }
1942
+ archive.writeZip(outputPath);
1943
+ protectSecretFile(outputPath, "wallet export ZIP");
1944
+
1945
+ printJson({
1946
+ action: "wallet export",
1947
+ output: outputPath,
1948
+ exportMode: manifest.exportMode,
1949
+ includeNotes,
1950
+ walletCount: exportedWallets.length,
1951
+ fileCount: manifest.files.length,
1952
+ wallets: exportedWallets.map(({ network, channelName, wallet }) => ({ network, channelName, wallet })),
1953
+ });
1954
+ }
1955
+
1956
+ function handleWalletImport({ args }) {
1957
+ const inputPath = path.resolve(String(requireArg(args.input, "--input")));
1958
+ expect(fs.existsSync(inputPath), `Import ZIP does not exist: ${inputPath}.`);
1959
+
1960
+ const { archive, manifest } = readWalletImportArchive(inputPath);
1961
+
1962
+ const archiveFiles = new Set(manifest.files);
1963
+ for (const entry of archive.getEntries()) {
1964
+ if (entry.isDirectory) {
1965
+ continue;
1966
+ }
1967
+ expect(
1968
+ entry.entryName === "manifest.json" || archiveFiles.has(entry.entryName),
1969
+ `Unexpected file in wallet import ZIP: ${entry.entryName}.`,
1970
+ );
1971
+ }
1972
+
1973
+ const targetRoot = privateStateCliDataRoot();
1974
+ ensureDir(targetRoot);
1975
+ const plannedWrites = manifest.files.map((archivePath) => {
1976
+ validateWalletArchivePath(archivePath);
1977
+ const entry = archive.getEntry(archivePath);
1978
+ expect(entry && !entry.isDirectory, `Wallet import ZIP is missing ${archivePath}.`);
1979
+ const targetPath = path.resolve(targetRoot, archivePath);
1980
+ expectPathWithinRoot(targetPath, targetRoot, `Unsafe import target for ${archivePath}.`);
1981
+ expect(!fs.existsSync(targetPath), `Refusing to overwrite existing file: ${targetPath}.`);
1982
+ return {
1983
+ archivePath,
1984
+ targetPath,
1985
+ data: entry.getData(),
1986
+ };
1987
+ });
1988
+
1989
+ commitWalletImportFiles({ targetRoot, plannedWrites });
1990
+
1991
+ printJson({
1992
+ action: "wallet import",
1993
+ input: inputPath,
1994
+ exportMode: manifest.exportMode,
1995
+ includeNotes: Boolean(manifest.includeNotes),
1996
+ walletCount: manifest.wallets.length,
1997
+ fileCount: plannedWrites.length,
1998
+ wallets: manifest.wallets.map(({ network, channelName, wallet }) => ({ network, channelName, wallet })),
1999
+ nextStep: manifest.includeNotes
2000
+ ? "Wallet commands can run immediately if the imported channel workspace cache is still chain-aligned."
2001
+ : "Run channel recover-workspace before wallet commands need channel state.",
2002
+ });
2003
+ }
2004
+
2005
+ function readWalletImportArchive(inputPath) {
2006
+ try {
2007
+ const archive = new AdmZip(inputPath);
2008
+ const manifestEntry = archive.getEntry("manifest.json");
2009
+ expect(manifestEntry, "Wallet import ZIP is missing manifest.json.");
2010
+ const manifest = JSON.parse(manifestEntry.getData().toString("utf8"));
2011
+ validateWalletExportManifest(manifest);
2012
+ return { archive, manifest };
2013
+ } catch (error) {
2014
+ throw new Error(`Failed to read wallet import ZIP ${inputPath}: ${error.message}`);
2015
+ }
2016
+ }
2017
+
2018
+ function commitWalletImportFiles({ targetRoot, plannedWrites }) {
2019
+ const stagingRoot = fs.mkdtempSync(path.join(targetRoot, ".wallet-import-"));
2020
+ const committedPaths = [];
2021
+ try {
2022
+ for (const write of plannedWrites) {
2023
+ write.stagingPath = path.resolve(stagingRoot, write.archivePath);
2024
+ expectPathWithinRoot(write.stagingPath, stagingRoot, `Unsafe staging target for ${write.archivePath}.`);
2025
+ ensureDir(path.dirname(write.stagingPath));
2026
+ fs.writeFileSync(write.stagingPath, write.data);
2027
+ applyImportedWalletFileMode(write.archivePath, write.stagingPath);
2028
+ }
2029
+
2030
+ for (const write of plannedWrites) {
2031
+ expect(!fs.existsSync(write.targetPath), `Refusing to overwrite existing file: ${write.targetPath}.`);
2032
+ }
2033
+
2034
+ for (const write of plannedWrites) {
2035
+ ensureDir(path.dirname(write.targetPath));
2036
+ fs.renameSync(write.stagingPath, write.targetPath);
2037
+ committedPaths.push(write.targetPath);
2038
+ }
2039
+ } catch (error) {
2040
+ for (const committedPath of committedPaths.reverse()) {
2041
+ fs.rmSync(committedPath, { force: true });
2042
+ }
2043
+ throw error;
2044
+ } finally {
2045
+ fs.rmSync(stagingRoot, { recursive: true, force: true });
2046
+ }
2047
+ }
2048
+
1804
2049
  async function handleGuide({ args }) {
1805
2050
  const guide = {
1806
2051
  action: "guide",
@@ -1816,7 +2061,7 @@ async function handleGuide({ args }) {
1816
2061
  nextSafeAction: null,
1817
2062
  why: null,
1818
2063
  candidateCommands: [],
1819
- privacyTip: "For mint-notes, transfer-notes, and redeem-notes, add --tx-submitter <ACCOUNT> to let a separate local L1 account submit executeChannelTransaction and pay gas.",
2064
+ privacyTip: "For wallet mint-notes, wallet transfer-notes, and wallet redeem-notes, add --tx-submitter <ACCOUNT> to let a separate local L1 account submit executeChannelTransaction and pay gas.",
1820
2065
  };
1821
2066
 
1822
2067
  guide.state.local = inspectGuideLocalState(args);
@@ -1839,12 +2084,12 @@ async function handleGuide({ args }) {
1839
2084
 
1840
2085
  if (!args.network) {
1841
2086
  setGuideNextAction(guide, {
1842
- command: "guide --network <NAME>",
2087
+ command: "help guide --network <NAME>",
1843
2088
  why: "Select a network before the guide can inspect RPC, deployment artifacts, channels, accounts, or wallets.",
1844
2089
  candidates: [
1845
- "guide --network mainnet",
1846
- "guide --network sepolia",
1847
- "guide --network anvil",
2090
+ "help guide --network mainnet",
2091
+ "help guide --network sepolia",
2092
+ "help guide --network anvil",
1848
2093
  ],
1849
2094
  });
1850
2095
  printJson(guide);
@@ -1857,7 +2102,7 @@ async function handleGuide({ args }) {
1857
2102
  guide.checks.push(networkRuntime.check);
1858
2103
  if (!networkRuntime.network) {
1859
2104
  setGuideNextAction(guide, {
1860
- command: "guide --network <NAME>",
2105
+ command: "help guide --network <NAME>",
1861
2106
  why: `The requested network ${networkName} is not supported by the CLI network config.`,
1862
2107
  });
1863
2108
  printJson(guide);
@@ -2225,8 +2470,8 @@ async function inspectGuideWallet({ walletName, networkName, provider, artifacts
2225
2470
  function applyGuideNextAction(guide) {
2226
2471
  if (guide.state.local?.walletSelectorError && guide.selectors.network) {
2227
2472
  setGuideNextAction(guide, {
2228
- command: `list-local-wallets --network ${guide.selectors.network}`,
2229
- why: "The selected wallet name is malformed. List local wallets and retry guide with an existing deterministic wallet name.",
2473
+ command: `wallet list --network ${guide.selectors.network}`,
2474
+ why: "The selected wallet name is malformed. List local wallets and retry help guide with an existing deterministic wallet name.",
2230
2475
  });
2231
2476
  return;
2232
2477
  }
@@ -2254,14 +2499,14 @@ function applyGuideNextAction(guide) {
2254
2499
  if (guide.selectors.channelName && guide.state.channel?.onchain?.exists === false) {
2255
2500
  const account = guide.selectors.account ?? "<ACCOUNT>";
2256
2501
  setGuideNextAction(guide, {
2257
- command: `create-channel --channel-name ${guide.selectors.channelName} --join-toll <TOKENS> --network ${guide.selectors.network} --account ${account}`,
2502
+ command: `channel create --channel-name ${guide.selectors.channelName} --join-toll <TOKENS> --network ${guide.selectors.network} --account ${account}`,
2258
2503
  why: "The selected channel name is not registered on-chain yet.",
2259
2504
  });
2260
2505
  return;
2261
2506
  }
2262
2507
  if (guide.selectors.channelName && guide.state.channel?.onchain?.exists && !guide.state.channel?.local?.workspaceExists) {
2263
2508
  setGuideNextAction(guide, {
2264
- command: `recover-workspace --channel-name ${guide.selectors.channelName} --network ${guide.selectors.network} --from-genesis`,
2509
+ command: `channel recover-workspace --channel-name ${guide.selectors.channelName} --network ${guide.selectors.network} --from-genesis`,
2265
2510
  why: "The channel exists on-chain, but the local channel workspace has not been recovered yet, so there is no local recovery index to resume from.",
2266
2511
  });
2267
2512
  return;
@@ -2270,7 +2515,7 @@ function applyGuideNextAction(guide) {
2270
2515
  const channelName = guide.selectors.channelName ?? guide.state.channel?.channelName ?? "<CHANNEL>";
2271
2516
  const account = guide.selectors.account ?? "<ACCOUNT>";
2272
2517
  setGuideNextAction(guide, {
2273
- command: `join-channel --channel-name ${channelName} --network ${guide.selectors.network} --account ${account} --wallet-secret-path <PATH>`,
2518
+ command: `channel join --channel-name ${channelName} --network ${guide.selectors.network} --account ${account} --wallet-secret-path <PATH>`,
2274
2519
  why: "The selected local wallet does not exist. Join the channel to create the wallet and register the channel L2 identity.",
2275
2520
  });
2276
2521
  return;
@@ -2279,7 +2524,7 @@ function applyGuideNextAction(guide) {
2279
2524
  const channelName = guide.state.wallet.channelName ?? guide.selectors.channelName ?? "<CHANNEL>";
2280
2525
  const account = guide.selectors.account ?? "<ACCOUNT>";
2281
2526
  setGuideNextAction(guide, {
2282
- command: `join-channel --channel-name ${channelName} --network ${guide.selectors.network} --account ${account} --wallet-secret-path <PATH>`,
2527
+ command: `channel join --channel-name ${channelName} --network ${guide.selectors.network} --account ${account} --wallet-secret-path <PATH>`,
2283
2528
  why: "The local wallet exists, but the corresponding L1 address is not registered in the channel.",
2284
2529
  });
2285
2530
  return;
@@ -2296,46 +2541,46 @@ function applyGuideNextAction(guide) {
2296
2541
  if (guide.state.wallet?.exists && bridgeBalance === 0n && (channelBalance === null || channelBalance === 0n) && unusedNotes === 0) {
2297
2542
  const account = guide.selectors.account ?? "<ACCOUNT>";
2298
2543
  setGuideNextAction(guide, {
2299
- command: `deposit-bridge --amount <TOKENS> --network ${guide.selectors.network} --account ${account}`,
2544
+ command: `account deposit-bridge --amount <TOKENS> --network ${guide.selectors.network} --account ${account}`,
2300
2545
  why: "The wallet is joined, but there is no bridge balance, channel balance, or local unused note to spend.",
2301
2546
  });
2302
2547
  return;
2303
2548
  }
2304
2549
  if (guide.state.wallet?.exists && bridgeBalance !== null && bridgeBalance > 0n && channelBalance === 0n) {
2305
2550
  setGuideNextAction(guide, {
2306
- command: `deposit-channel --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amount <TOKENS>`,
2551
+ command: `wallet deposit-channel --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amount <TOKENS>`,
2307
2552
  why: "The account has funds in the shared bridge vault, but the wallet has no channel L2 accounting balance.",
2308
2553
  });
2309
2554
  return;
2310
2555
  }
2311
2556
  if (guide.state.wallet?.exists && channelBalance !== null && channelBalance > 0n && unusedNotes === 0) {
2312
2557
  setGuideNextAction(guide, {
2313
- command: `mint-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amounts <A,B> [--tx-submitter <ACCOUNT>]`,
2558
+ command: `wallet mint-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amounts <A,B> [--tx-submitter <ACCOUNT>]`,
2314
2559
  why: "The wallet has channel L2 balance and no unused private notes yet. Use --tx-submitter for stronger transaction-submission privacy.",
2315
2560
  });
2316
2561
  return;
2317
2562
  }
2318
2563
  if (guide.state.wallet?.exists && unusedNotes !== null && unusedNotes > 0) {
2319
2564
  setGuideNextAction(guide, {
2320
- command: `transfer-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID,ID> --recipients <ADDR,ADDR> --amounts <A,B> [--tx-submitter <ACCOUNT>]`,
2565
+ command: `wallet transfer-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID,ID> --recipients <ADDR,ADDR> --amounts <A,B> [--tx-submitter <ACCOUNT>]`,
2321
2566
  why: "The wallet has unused private notes. It can transfer or redeem those notes. Use --tx-submitter for stronger transaction-submission privacy.",
2322
2567
  candidates: [
2323
- `get-my-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network}`,
2324
- `redeem-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID> [--tx-submitter <ACCOUNT>]`,
2568
+ `wallet get-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network}`,
2569
+ `wallet redeem-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID> [--tx-submitter <ACCOUNT>]`,
2325
2570
  ],
2326
2571
  });
2327
2572
  return;
2328
2573
  }
2329
2574
  if (guide.state.wallet?.exists && channelBalance === 0n) {
2330
2575
  setGuideNextAction(guide, {
2331
- command: `exit-channel --wallet ${guide.selectors.wallet} --network ${guide.selectors.network}`,
2576
+ command: `channel exit --wallet ${guide.selectors.wallet} --network ${guide.selectors.network}`,
2332
2577
  why: "The wallet has zero channel balance, so channel exit is allowed by both the CLI and bridge contract.",
2333
2578
  });
2334
2579
  return;
2335
2580
  }
2336
2581
 
2337
2582
  setGuideNextAction(guide, {
2338
- command: "guide --network <NAME> --channel-name <CHANNEL> --account <ACCOUNT> --wallet <WALLET>",
2583
+ command: "help guide --network <NAME> --channel-name <CHANNEL> --account <ACCOUNT> --wallet <WALLET>",
2339
2584
  why: "Provide more selectors so the guide can choose a single next safe action.",
2340
2585
  });
2341
2586
  }
@@ -2399,12 +2644,12 @@ function redactRpcUrl(rpcUrl) {
2399
2644
  }
2400
2645
  }
2401
2646
 
2402
- async function handleGetMyWalletMeta({ args, provider }) {
2647
+ async function handleWalletGetMeta({ args, provider }) {
2403
2648
  const { wallet, walletMetadata } = loadUnlockedWalletWithMetadata(args);
2404
2649
  const contextResult = await loadPreferredWalletChannelContext({
2405
2650
  walletContext: wallet,
2406
2651
  provider,
2407
- progressAction: "get-my-wallet-meta",
2652
+ progressAction: "wallet get-meta",
2408
2653
  });
2409
2654
  const context = contextResult.context;
2410
2655
  const {
@@ -2420,7 +2665,7 @@ async function handleGetMyWalletMeta({ args, provider }) {
2420
2665
  });
2421
2666
 
2422
2667
  printJson({
2423
- action: "get-my-wallet-meta",
2668
+ action: "wallet get-meta",
2424
2669
  wallet: wallet.walletName,
2425
2670
  network: walletMetadata.network,
2426
2671
  channelName: walletMetadata.channelName,
@@ -2490,7 +2735,7 @@ async function loadWalletChannelRegistrationState({
2490
2735
  registration.exists,
2491
2736
  cliError(
2492
2737
  CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION,
2493
- `No channelTokenVault registration exists for ${signer.address}. Run join-channel first.`,
2738
+ `No channelTokenVault registration exists for ${signer.address}. Run channel join first.`,
2494
2739
  ),
2495
2740
  );
2496
2741
  expect(
@@ -2508,7 +2753,7 @@ async function loadWalletChannelRegistrationState({
2508
2753
  };
2509
2754
  }
2510
2755
 
2511
- async function handleGetMyChannelFund({ args, provider }) {
2756
+ async function handleWalletGetChannelFund({ args, provider }) {
2512
2757
  const { wallet, walletMetadata } = loadUnlockedWalletWithMetadata(args);
2513
2758
  const {
2514
2759
  signer,
@@ -2520,11 +2765,11 @@ async function handleGetMyChannelFund({ args, provider }) {
2520
2765
  } = await loadWalletChannelFundState({
2521
2766
  walletContext: wallet,
2522
2767
  provider,
2523
- progressAction: "get-my-channel-fund",
2768
+ progressAction: "wallet get-channel-fund",
2524
2769
  });
2525
2770
 
2526
2771
  printJson({
2527
- action: "get-my-channel-fund",
2772
+ action: "wallet get-channel-fund",
2528
2773
  wallet: wallet.walletName,
2529
2774
  network: walletMetadata.network,
2530
2775
  channelName: walletMetadata.channelName,
@@ -2556,7 +2801,7 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
2556
2801
  !existingRegistration.exists,
2557
2802
  [
2558
2803
  `L1 address ${signer.address} is already registered in channel ${context.workspace.channelName}.`,
2559
- "Use recover-wallet or normal wallet commands for an existing channel registration.",
2804
+ "Use wallet recover-workspace or normal wallet commands for an existing channel registration.",
2560
2805
  ].join(" "),
2561
2806
  );
2562
2807
  const walletSecret = prepareJoinWalletSecretForName({
@@ -2593,7 +2838,7 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
2593
2838
  );
2594
2839
  let nextNonce = await provider.getTransactionCount(signer.address, "pending");
2595
2840
  printImmutableChannelPolicyWarning({
2596
- action: "join-channel",
2841
+ action: "channel join",
2597
2842
  channelName: context.workspace.channelName,
2598
2843
  channelId: ethers.toBigInt(context.workspace.channelId),
2599
2844
  channelManager: context.workspace.channelManager,
@@ -2629,7 +2874,7 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
2629
2874
  });
2630
2875
 
2631
2876
  printJson({
2632
- action: "join-channel",
2877
+ action: "channel join",
2633
2878
  workspace: context.workspaceName,
2634
2879
  wallet: walletContext.walletName,
2635
2880
  walletSecretSource: resolvedWalletSecretSource(args),
@@ -2665,17 +2910,18 @@ async function handleExitChannel({ args, provider }) {
2665
2910
  channelFund === 0n,
2666
2911
  [
2667
2912
  `The current channel fund for ${signer.address} is ${channelFund.toString()}.`,
2668
- "exit-channel requires a zero channel balance.",
2669
- "Run withdraw-channel first, then retry exit-channel.",
2913
+ "channel exit requires a zero channel balance.",
2914
+ "Run wallet withdraw-channel first, then retry channel exit.",
2670
2915
  ].join(" "),
2671
2916
  );
2672
2917
  const [refundAmount, refundBps] = await context.channelManager.getExitTollRefundQuote(signer.address);
2673
2918
  const receipt = await waitForReceipt(
2674
2919
  await context.bridgeTokenVault.connect(signer).exitChannel(ethers.toBigInt(context.workspace.channelId)),
2675
2920
  );
2921
+ const cleanup = removeLocalWalletArtifacts(walletContext.walletName, walletMetadata.network);
2676
2922
 
2677
2923
  printJson({
2678
- action: "exit-channel",
2924
+ action: "channel exit",
2679
2925
  wallet: walletContext.walletName,
2680
2926
  network: walletMetadata.network,
2681
2927
  channelName: walletMetadata.channelName,
@@ -2690,15 +2936,17 @@ async function handleExitChannel({ args, provider }) {
2690
2936
  gasUsed: receiptGasUsed(receipt),
2691
2937
  txUrl: explorerTxUrl(network, receipt.hash),
2692
2938
  receipt: sanitizeReceipt(receipt),
2939
+ removedWalletDir: cleanup.removedWalletDir ? cleanup.walletDir : null,
2940
+ removedWalletSecretFile: cleanup.removedWalletSecret ? cleanup.walletSecretFile : null,
2693
2941
  });
2694
2942
  }
2695
2943
 
2696
2944
  async function handleGrothVaultMove({ args, provider, direction }) {
2697
- const operationName = args.command === "withdraw-channel"
2698
- ? "withdraw-channel"
2945
+ const operationName = args.command === "wallet-withdraw-channel"
2946
+ ? "wallet withdraw-channel"
2699
2947
  : direction === "deposit"
2700
- ? "deposit-channel"
2701
- : "withdraw";
2948
+ ? "wallet deposit-channel"
2949
+ : "wallet withdraw-channel";
2702
2950
  emitProgress(operationName, "loading");
2703
2951
  const { wallet: walletContext } = loadUnlockedWalletWithMetadata(args);
2704
2952
  const contextResult = await loadPreferredWalletChannelContext({
@@ -2729,7 +2977,7 @@ async function handleGrothVaultMove({ args, provider, direction }) {
2729
2977
  availableBalance >= amount,
2730
2978
  [
2731
2979
  `Deposit amount ${amount.toString()} exceeds the shared bridge-vault balance`,
2732
- `${availableBalance.toString()} for ${signer.address}. Run deposit-bridge first.`,
2980
+ `${availableBalance.toString()} for ${signer.address}. Run account deposit-bridge first.`,
2733
2981
  ].join(" "),
2734
2982
  );
2735
2983
  }
@@ -2738,7 +2986,7 @@ async function handleGrothVaultMove({ args, provider, direction }) {
2738
2986
  registration.exists,
2739
2987
  cliError(
2740
2988
  CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION,
2741
- `No channelTokenVault registration exists for ${signer.address}. Run join-channel first.`,
2989
+ `No channelTokenVault registration exists for ${signer.address}. Run channel join first.`,
2742
2990
  ),
2743
2991
  );
2744
2992
  expect(
@@ -2802,7 +3050,12 @@ async function handleGrothVaultMove({ args, provider, direction }) {
2802
3050
  sealWalletOperationDir(operationDir, walletContext.walletSecret);
2803
3051
 
2804
3052
  context.currentSnapshot = transition.nextSnapshot;
2805
- persistCurrentState(context);
3053
+ await refreshPersistedWorkspaceAfterLocalTransaction({
3054
+ context,
3055
+ provider,
3056
+ receipt,
3057
+ progressAction: operationName,
3058
+ });
2806
3059
 
2807
3060
  emitProgress(operationName, "done");
2808
3061
  printJson({
@@ -2818,6 +3071,8 @@ async function handleGrothVaultMove({ args, provider, direction }) {
2818
3071
  updatedRoot: transition.update.updatedRoot,
2819
3072
  gasUsed: receiptGasUsed(receipt),
2820
3073
  txUrl: explorerTxUrl(network, receipt.hash),
3074
+ usedWorkspaceCache: contextResult.usingWorkspaceCache,
3075
+ recoveredWorkspace: contextResult.recoveredWorkspace,
2821
3076
  });
2822
3077
  }
2823
3078
 
@@ -2835,7 +3090,7 @@ async function handleWithdrawBridge({ args, network, provider }) {
2835
3090
  const receipt = await waitForReceipt(await bridgeTokenVault.claimToWallet(amount));
2836
3091
 
2837
3092
  printJson({
2838
- action: "withdraw-bridge",
3093
+ action: "account withdraw-bridge",
2839
3094
  l1Address: signer.address,
2840
3095
  amountInput,
2841
3096
  amountBaseUnits: amount.toString(),
@@ -2927,13 +3182,13 @@ async function handleMintNotes({ args, provider }) {
2927
3182
  const { channelFund } = await loadWalletChannelFundState({
2928
3183
  walletContext: wallet,
2929
3184
  provider,
2930
- progressAction: "mint-notes",
3185
+ progressAction: "wallet mint-notes",
2931
3186
  });
2932
3187
  expect(
2933
3188
  totalMintAmount <= channelFund,
2934
3189
  [
2935
3190
  `Mint amount total ${totalMintAmount.toString()} exceeds the current channel fund`,
2936
- `${channelFund.toString()}. Run get-my-channel-fund to inspect the available balance.`,
3191
+ `${channelFund.toString()}. Run wallet get-channel-fund to inspect the available balance.`,
2937
3192
  ].join(" "),
2938
3193
  );
2939
3194
  const templatePayload = buildMintNotesTemplatePayload({
@@ -2944,12 +3199,12 @@ async function handleMintNotes({ args, provider }) {
2944
3199
  args,
2945
3200
  wallet,
2946
3201
  provider,
2947
- operationName: "mint-notes",
3202
+ operationName: "wallet mint-notes",
2948
3203
  templatePayload,
2949
3204
  });
2950
3205
 
2951
3206
  printJson({
2952
- action: "mint-notes",
3207
+ action: "wallet mint-notes",
2953
3208
  wallet: wallet.walletName,
2954
3209
  workspace: execution.context.workspaceName,
2955
3210
  operationDir: execution.operationDir,
@@ -2988,12 +3243,12 @@ async function handleRedeemNotes({ args, provider }) {
2988
3243
  args,
2989
3244
  wallet,
2990
3245
  provider,
2991
- operationName: "redeem-notes",
3246
+ operationName: "wallet redeem-notes",
2992
3247
  templatePayload,
2993
3248
  });
2994
3249
 
2995
3250
  printJson({
2996
- action: "redeem-notes",
3251
+ action: "wallet redeem-notes",
2997
3252
  wallet: wallet.walletName,
2998
3253
  workspace: execution.context.workspaceName,
2999
3254
  operationDir: execution.operationDir,
@@ -3020,7 +3275,7 @@ async function handleRedeemNotes({ args, provider }) {
3020
3275
  });
3021
3276
  }
3022
3277
 
3023
- async function handleGetMyNotes({ args, provider }) {
3278
+ async function handleWalletGetNotes({ args, provider }) {
3024
3279
  const { wallet, walletMetadata } = loadUnlockedWalletWithMetadata(args);
3025
3280
  expect(
3026
3281
  typeof wallet.wallet.controller === "string" && wallet.wallet.controller.length > 0,
@@ -3030,7 +3285,7 @@ async function handleGetMyNotes({ args, provider }) {
3030
3285
  const { context } = await loadPreferredWalletChannelContext({
3031
3286
  walletContext: wallet,
3032
3287
  provider,
3033
- progressAction: "get-my-notes",
3288
+ progressAction: "wallet get-notes",
3034
3289
  });
3035
3290
  const signer = restoreWalletSigner(wallet, provider);
3036
3291
  const { recoveredDeliveryState } = await recoverWalletReceivedNotes({
@@ -3038,7 +3293,7 @@ async function handleGetMyNotes({ args, provider }) {
3038
3293
  context,
3039
3294
  provider,
3040
3295
  signer,
3041
- progressAction: "get-my-notes",
3296
+ progressAction: "wallet get-notes",
3042
3297
  });
3043
3298
 
3044
3299
  const unusedTrackedNotes = wallet.wallet.notes.unusedOrder
@@ -3063,7 +3318,7 @@ async function handleGetMyNotes({ args, provider }) {
3063
3318
  const spentTotal = spentTrackedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n);
3064
3319
 
3065
3320
  printJson({
3066
- action: "get-my-notes",
3321
+ action: "wallet get-notes",
3067
3322
  wallet: wallet.walletName,
3068
3323
  network: walletMetadata.network,
3069
3324
  channelName: walletMetadata.channelName,
@@ -3087,7 +3342,7 @@ async function handleTransferNotes({ args, provider }) {
3087
3342
  const preparedContextResult = await loadPreferredWalletChannelContext({
3088
3343
  walletContext: wallet,
3089
3344
  provider,
3090
- progressAction: "transfer-notes",
3345
+ progressAction: "wallet transfer-notes",
3091
3346
  });
3092
3347
  const context = preparedContextResult.context;
3093
3348
  const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
@@ -3123,7 +3378,7 @@ async function handleTransferNotes({ args, provider }) {
3123
3378
  args,
3124
3379
  wallet,
3125
3380
  provider,
3126
- operationName: "transfer-notes",
3381
+ operationName: "wallet transfer-notes",
3127
3382
  templatePayload,
3128
3383
  });
3129
3384
  const outputNotes = buildLifecycleTrackedOutputs({
@@ -3134,7 +3389,7 @@ async function handleTransferNotes({ args, provider }) {
3134
3389
  });
3135
3390
 
3136
3391
  printJson({
3137
- action: "transfer-notes",
3392
+ action: "wallet transfer-notes",
3138
3393
  wallet: wallet.walletName,
3139
3394
  workspace: execution.context.workspaceName,
3140
3395
  operationDir: execution.operationDir,
@@ -3611,7 +3866,7 @@ function requireUsableWalletNoteReceiveRecoveryIndex({ walletContext, context, l
3611
3866
  throw new Error([
3612
3867
  `Wallet note recovery index is missing or unusable for wallet ${walletContext.walletName}.`,
3613
3868
  `Expected noteReceiveLastScannedBlock to be an integer between ${genesisBlockNumber} and ${Number(latestBlock) + 1}.`,
3614
- "Run recover-wallet --from-genesis to rebuild wallet note state from channel genesis.",
3869
+ "Run wallet recover-workspace --from-genesis to rebuild wallet note state from channel genesis.",
3615
3870
  ].join(" "));
3616
3871
  }
3617
3872
  return nextBlock;
@@ -3851,16 +4106,16 @@ function buildRedeemNotesTemplatePayload({ wallet, inputNotes }) {
3851
4106
  }
3852
4107
 
3853
4108
  function selectMintNotesMethod(noteCount) {
3854
- expect(noteCount >= 1, "mint-notes requires at least one output amount.");
4109
+ expect(noteCount >= 1, "wallet mint-notes requires at least one output amount.");
3855
4110
  expect(
3856
4111
  noteCount <= 2,
3857
- "mint-notes supports only one or two output amounts with the currently registered DApp.",
4112
+ "wallet mint-notes supports only one or two output amounts with the currently registered DApp.",
3858
4113
  );
3859
4114
  return `mintNotes${noteCount}`;
3860
4115
  }
3861
4116
 
3862
4117
  function selectRedeemNotesMethod(noteCount) {
3863
- expect(noteCount === 1, "redeem-notes supports exactly one input note with the currently registered DApp.");
4118
+ expect(noteCount === 1, "wallet redeem-notes supports exactly one input note with the currently registered DApp.");
3864
4119
  return `redeemNotes${noteCount}`;
3865
4120
  }
3866
4121
 
@@ -3979,7 +4234,7 @@ function selectTransferNotesMethod(inputCount, outputCount) {
3979
4234
  if (inputCount === 2 && outputCount === 1) {
3980
4235
  return "transferNotes2To1";
3981
4236
  }
3982
- throw new Error("transfer-notes supports only 1->1, 1->2, and 2->1 note transfers.");
4237
+ throw new Error("wallet transfer-notes supports only 1->1, 1->2, and 2->1 note transfers.");
3983
4238
  }
3984
4239
 
3985
4240
  function loadWalletUnusedInputNotes(walletContext, noteIds) {
@@ -4115,7 +4370,7 @@ async function recoverWalletChannelWorkspace({ walletContext, provider, progress
4115
4370
  const networkName = walletContext.wallet.network ?? networkNameFromChainId(Number(walletContext.wallet.chainId));
4116
4371
  const network = resolveCliNetwork(networkName);
4117
4372
  const bridgeResources = loadBridgeResources({ chainId: network.chainId });
4118
- await initializeChannelWorkspace({
4373
+ await syncChannelWorkspace({
4119
4374
  workspaceName: walletContext.wallet.channelName,
4120
4375
  channelName: walletContext.wallet.channelName,
4121
4376
  network,
@@ -4128,6 +4383,49 @@ async function recoverWalletChannelWorkspace({ walletContext, provider, progress
4128
4383
  });
4129
4384
  }
4130
4385
 
4386
+ async function refreshPersistedWorkspaceAfterLocalTransaction({
4387
+ context,
4388
+ provider,
4389
+ receipt,
4390
+ progressAction = null,
4391
+ }) {
4392
+ if (!context.persistChannelWorkspace || !context.workspaceDir) {
4393
+ return context;
4394
+ }
4395
+ const network = resolveCliNetwork(context.workspace.network);
4396
+ const bridgeResources = loadBridgeResources({ chainId: Number(context.workspace.chainId) });
4397
+ const refreshed = await syncChannelWorkspace({
4398
+ workspaceName: context.workspaceName,
4399
+ channelName: context.workspace.channelName,
4400
+ network,
4401
+ provider,
4402
+ bridgeResources,
4403
+ persist: true,
4404
+ allowExistingWorkspaceSync: true,
4405
+ useWorkspaceRecoveryIndex: true,
4406
+ minimumToBlock: receipt?.blockNumber ?? null,
4407
+ progressAction,
4408
+ });
4409
+
4410
+ context.workspaceDir = refreshed.workspaceDir;
4411
+ context.workspace = refreshed.workspace;
4412
+ context.currentSnapshot = refreshed.currentSnapshot;
4413
+ context.blockInfo = refreshed.blockInfo;
4414
+ context.contractCodes = refreshed.contractCodes;
4415
+ context.bridgeAbiManifest = bridgeResources.bridgeAbiManifest;
4416
+ context.channelManager = new Contract(
4417
+ refreshed.workspace.channelManager,
4418
+ bridgeResources.bridgeAbiManifest.contracts.channelManager.abi,
4419
+ provider,
4420
+ );
4421
+ context.bridgeTokenVault = new Contract(
4422
+ refreshed.workspace.bridgeTokenVault,
4423
+ bridgeResources.bridgeAbiManifest.contracts.bridgeTokenVault.abi,
4424
+ provider,
4425
+ );
4426
+ return context;
4427
+ }
4428
+
4131
4429
  function isRecoverableWalletWorkspaceFailure(error) {
4132
4430
  const message = String(error?.message ?? error);
4133
4431
  return (message.includes("--verify") && message.includes("failed with exit code"))
@@ -4179,6 +4477,7 @@ async function executeWalletDirectTemplateCommand({
4179
4477
  txSubmitterAccount,
4180
4478
  l2Identity,
4181
4479
  context: contextResult.context,
4480
+ provider,
4182
4481
  operationName,
4183
4482
  functionName: templatePayload.method,
4184
4483
  templatePayload,
@@ -4210,6 +4509,7 @@ async function executeWalletDirectTemplateCommand({
4210
4509
  txSubmitterAccount,
4211
4510
  l2Identity,
4212
4511
  context: contextResult.context,
4512
+ provider,
4213
4513
  operationName,
4214
4514
  functionName: templatePayload.method,
4215
4515
  templatePayload,
@@ -4230,6 +4530,7 @@ async function executeWalletTemplateSend({
4230
4530
  txSubmitterAccount,
4231
4531
  l2Identity,
4232
4532
  context,
4533
+ provider,
4233
4534
  operationName,
4234
4535
  functionName,
4235
4536
  templatePayload,
@@ -4323,7 +4624,12 @@ async function executeWalletTemplateSend({
4323
4624
  applyNoteLifecycleToWallet(wallet, noteLifecycle, functionName, receipt.hash);
4324
4625
  context.currentSnapshot = nextSnapshot;
4325
4626
  persistWallet(wallet);
4326
- persistCurrentState(context);
4627
+ await refreshPersistedWorkspaceAfterLocalTransaction({
4628
+ context,
4629
+ provider,
4630
+ receipt,
4631
+ progressAction: operationName,
4632
+ });
4327
4633
  sealWalletOperationDir(operationDir, wallet.walletSecret);
4328
4634
 
4329
4635
  return {
@@ -4383,7 +4689,7 @@ async function loadJoinChannelContext({ args, network, provider }) {
4383
4689
  const channelName = requireArg(args.channelName, "--channel-name");
4384
4690
 
4385
4691
  const bridgeResources = loadBridgeResources({ chainId });
4386
- const initialized = await initializeChannelWorkspace({
4692
+ const initialized = await syncChannelWorkspace({
4387
4693
  workspaceName: channelName,
4388
4694
  channelName,
4389
4695
  network: { chainId, name: resolvedNetworkName },
@@ -4477,7 +4783,7 @@ function assertWalletUsesChannelBoundDerivation(wallet, walletName) {
4477
4783
  wallet.l2DerivationMode === CHANNEL_BOUND_L2_DERIVATION_MODE,
4478
4784
  [
4479
4785
  `Wallet ${walletName} was not created with the current channel-bound L2 derivation rule.`,
4480
- "Create a fresh wallet with join-channel.",
4786
+ "Create a fresh wallet with channel join.",
4481
4787
  ].join(" "),
4482
4788
  );
4483
4789
  expect(
@@ -5710,7 +6016,13 @@ function parseArgs(argv) {
5710
6016
  }
5711
6017
 
5712
6018
  parsed.command = parsed.positional[0];
5713
- if ((parsed.command === "account" || parsed.command === "wallet") && parsed.positional[1]) {
6019
+ if (
6020
+ (parsed.command === "account"
6021
+ || parsed.command === "channel"
6022
+ || parsed.command === "wallet"
6023
+ || parsed.command === "help")
6024
+ && parsed.positional[1]
6025
+ ) {
5714
6026
  parsed.command = `${parsed.command}-${parsed.positional[1]}`;
5715
6027
  parsed.positional = [parsed.command];
5716
6028
  }
@@ -5871,7 +6183,7 @@ function resolveWalletDefaultSecret(networkName, walletName) {
5871
6183
  CLI_ERROR_CODES.MISSING_WALLET_SECRET,
5872
6184
  [
5873
6185
  `Missing wallet default secret file: ${secretPath}.`,
5874
- "Run join-channel with --wallet-secret-path before wallet commands.",
6186
+ "Run channel join with --wallet-secret-path before wallet commands.",
5875
6187
  ].join(" "),
5876
6188
  );
5877
6189
  }
@@ -5884,12 +6196,17 @@ function prepareJoinWalletSecretForName({
5884
6196
  walletName,
5885
6197
  }) {
5886
6198
  const secretPath = walletSecretPath(networkName, walletName);
6199
+ const { channelName } = parseWalletName(walletName);
6200
+ const walletDir = walletPath(walletName, networkName);
5887
6201
  expect(
5888
- !walletConfigExists(walletPath(walletName, networkName)),
6202
+ !walletConfigExists(walletDir),
5889
6203
  [
5890
6204
  `Wallet ${walletName} already exists on ${networkName}.`,
5891
- "join-channel always creates a new local wallet.",
5892
- "Use recover-wallet or normal wallet commands for an existing local wallet.",
6205
+ "channel join always creates a new local wallet.",
6206
+ "If this wallet was previously exited on-chain, run",
6207
+ `wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account ${args.account ?? "<ACCOUNT>"}`,
6208
+ "once to remove the stale local wallet, then retry channel join.",
6209
+ "Use normal wallet commands for an existing active local wallet.",
5893
6210
  ].join(" "),
5894
6211
  );
5895
6212
  const sourcePath = path.resolve(String(requireArg(args.walletSecretPath, "--wallet-secret-path")));
@@ -5898,13 +6215,6 @@ function prepareJoinWalletSecretForName({
5898
6215
  ? readSecretFile(sourcePath, "--wallet-secret-path")
5899
6216
  : readImportSecretSourceFile(sourcePath, "--wallet-secret-path");
5900
6217
  if (sourcePath !== canonicalPath) {
5901
- expect(
5902
- !fs.existsSync(canonicalPath),
5903
- [
5904
- `Wallet default secret file already exists: ${canonicalPath}.`,
5905
- "Remove it before joining with a different --wallet-secret-path.",
5906
- ].join(" "),
5907
- );
5908
6218
  writeSecretFile(canonicalPath, walletSecret);
5909
6219
  }
5910
6220
  return walletSecret;
@@ -6027,6 +6337,148 @@ function listLocalWallets({ networkFilter = null, channelFilter = null } = {}) {
6027
6337
  );
6028
6338
  }
6029
6339
 
6340
+ function privateStateCliDataRoot() {
6341
+ const root = path.dirname(workspaceRoot);
6342
+ expect(
6343
+ path.dirname(secretRoot) === root,
6344
+ `Unexpected CLI data root layout: ${workspaceRoot} and ${secretRoot} are not siblings.`,
6345
+ );
6346
+ return root;
6347
+ }
6348
+
6349
+ function resolveExportWalletInfo({ networkName, walletName }) {
6350
+ resolveCliNetwork(networkName);
6351
+ const walletDir = walletPath(walletName, networkName);
6352
+ return {
6353
+ wallet: walletName,
6354
+ network: networkName,
6355
+ channelName: parseWalletName(walletName).channelName,
6356
+ walletDir,
6357
+ metadataPath: walletMetadataPath(walletDir),
6358
+ hasMetadata: fs.existsSync(walletMetadataPath(walletDir)),
6359
+ hasEncryptedWallet: walletConfigExists(walletDir),
6360
+ };
6361
+ }
6362
+
6363
+ function normalizeExportWalletInfo(walletInfo) {
6364
+ const wallet = requireWalletName({ wallet: walletInfo.wallet });
6365
+ const network = requireNetworkName({ network: walletInfo.network });
6366
+ const walletDir = walletInfo.walletDir ?? walletPath(wallet, network);
6367
+ const metadataPath = walletMetadataPath(walletDir);
6368
+ const encryptedWalletPath = walletConfigPath(walletDir);
6369
+ const metadata = readJsonIfExists(metadataPath);
6370
+ const channelName = metadata?.channelName ?? walletInfo.channelName ?? parseWalletName(wallet).channelName;
6371
+ const walletSecret = walletSecretPath(network, wallet);
6372
+
6373
+ expect(fs.existsSync(encryptedWalletPath), `Wallet export cannot find encrypted wallet file: ${encryptedWalletPath}.`);
6374
+ expect(fs.existsSync(metadataPath), `Wallet export cannot find wallet metadata file: ${metadataPath}.`);
6375
+ expect(fs.existsSync(walletSecret), `Wallet export cannot find wallet-local secret file: ${walletSecret}.`);
6376
+ expect(
6377
+ metadata.network === network,
6378
+ `Wallet export metadata network ${metadata.network} does not match ${network}.`,
6379
+ );
6380
+ expect(
6381
+ metadata.channelName === channelName,
6382
+ `Wallet export metadata channel ${metadata.channelName} does not match ${channelName}.`,
6383
+ );
6384
+
6385
+ return {
6386
+ network,
6387
+ channelName,
6388
+ wallet,
6389
+ walletDir,
6390
+ walletSecretPath: walletSecret,
6391
+ };
6392
+ }
6393
+
6394
+ function walletExportFilePaths(walletInfo, { includeNotes }) {
6395
+ const walletFiles = [
6396
+ walletInfo.walletSecretPath,
6397
+ walletConfigPath(walletInfo.walletDir),
6398
+ walletMetadataPath(walletInfo.walletDir),
6399
+ ];
6400
+ if (!includeNotes) {
6401
+ return walletFiles;
6402
+ }
6403
+
6404
+ const workspaceDir = channelWorkspacePath(walletInfo.network, walletInfo.channelName);
6405
+ const currentDir = channelWorkspaceCurrentPath(workspaceDir);
6406
+ const workspaceFiles = [
6407
+ channelWorkspaceConfigPath(workspaceDir),
6408
+ path.join(currentDir, "state_snapshot.json"),
6409
+ path.join(currentDir, "state_snapshot.normalized.json"),
6410
+ path.join(currentDir, "block_info.json"),
6411
+ path.join(currentDir, "contract_codes.json"),
6412
+ ];
6413
+ for (const filePath of workspaceFiles) {
6414
+ expect(
6415
+ fs.existsSync(filePath),
6416
+ [
6417
+ `wallet export --include-notes requires channel workspace cache file: ${filePath}.`,
6418
+ "Run channel recover-workspace first, or export without --include-notes.",
6419
+ ].join(" "),
6420
+ );
6421
+ }
6422
+ return [...walletFiles, ...workspaceFiles];
6423
+ }
6424
+
6425
+ function archivePathForLocalCliFile(filePath) {
6426
+ const root = privateStateCliDataRoot();
6427
+ const absolutePath = path.resolve(filePath);
6428
+ expectPathWithinRoot(absolutePath, root, `Cannot export file outside CLI data root: ${absolutePath}.`);
6429
+ return path.relative(root, absolutePath).split(path.sep).join("/");
6430
+ }
6431
+
6432
+ function validateWalletExportManifest(manifest) {
6433
+ expect(manifest?.format === WALLET_EXPORT_FORMAT, "Wallet import ZIP has an unsupported format.");
6434
+ expect(
6435
+ Number(manifest.formatVersion) === WALLET_EXPORT_FORMAT_VERSION,
6436
+ `Wallet import ZIP format version ${manifest?.formatVersion} is not supported.`,
6437
+ );
6438
+ expect(Array.isArray(manifest.files), "Wallet import ZIP manifest is missing files[].");
6439
+ expect(Array.isArray(manifest.wallets), "Wallet import ZIP manifest is missing wallets[].");
6440
+ expect(typeof manifest.includeNotes === "boolean", "Wallet import ZIP manifest is missing includeNotes.");
6441
+ expect(manifest.wallets.length > 0, "Wallet import ZIP manifest does not list any wallets.");
6442
+ const uniqueFiles = new Set(manifest.files);
6443
+ expect(uniqueFiles.size === manifest.files.length, "Wallet import ZIP manifest contains duplicate file paths.");
6444
+ expect(manifest.files.length > 0, "Wallet import ZIP manifest does not list any files.");
6445
+ for (const filePath of manifest.files) {
6446
+ validateWalletArchivePath(filePath);
6447
+ }
6448
+ for (const wallet of manifest.wallets) {
6449
+ requireNetworkName({ network: wallet.network });
6450
+ requireWalletName({ wallet: wallet.wallet });
6451
+ requireArg(wallet.channelName, "wallets[].channelName");
6452
+ }
6453
+ }
6454
+
6455
+ function validateWalletArchivePath(archivePath) {
6456
+ expect(typeof archivePath === "string" && archivePath.length > 0, "Wallet import ZIP contains an empty path.");
6457
+ expect(!archivePath.includes("\0"), `Wallet import ZIP contains an invalid path: ${archivePath}.`);
6458
+ expect(!archivePath.includes("\\"), `Wallet import ZIP path must use forward slashes: ${archivePath}.`);
6459
+ expect(!path.posix.isAbsolute(archivePath), `Wallet import ZIP path must be relative: ${archivePath}.`);
6460
+ expect(path.posix.normalize(archivePath) === archivePath, `Wallet import ZIP path is not normalized: ${archivePath}.`);
6461
+ expect(
6462
+ archivePath.startsWith("secrets/") || archivePath.startsWith("workspace/"),
6463
+ `Wallet import ZIP path must start with secrets/ or workspace/: ${archivePath}.`,
6464
+ );
6465
+ }
6466
+
6467
+ function expectPathWithinRoot(targetPath, rootPath, message) {
6468
+ const relative = path.relative(path.resolve(rootPath), path.resolve(targetPath));
6469
+ expect(relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative), message);
6470
+ }
6471
+
6472
+ function applyImportedWalletFileMode(archivePath, targetPath) {
6473
+ if (
6474
+ archivePath.startsWith("secrets/")
6475
+ || archivePath.endsWith("/wallet.json")
6476
+ || archivePath.endsWith("/wallet.metadata.json")
6477
+ ) {
6478
+ protectSecretFile(targetPath, `imported wallet file ${archivePath}`);
6479
+ }
6480
+ }
6481
+
6030
6482
  function channelDataPath(workspaceDir) {
6031
6483
  return workspaceChannelDir(workspaceDir);
6032
6484
  }
@@ -6125,14 +6577,18 @@ function assertUninstallArgs(args) {
6125
6577
  assertAllowedCommandSchema(args, "uninstall");
6126
6578
  }
6127
6579
 
6580
+ function assertHelpCommandsArgs(args) {
6581
+ assertAllowedCommandSchema(args, "help-commands");
6582
+ }
6583
+
6128
6584
  function assertUpdateArgs(args) {
6129
- assertAllowedCommandSchema(args, "update");
6585
+ assertAllowedCommandSchema(args, "help-update");
6130
6586
  }
6131
6587
 
6132
6588
  function assertDoctorArgs(args) {
6133
- assertAllowedCommandSchema(args, "doctor");
6589
+ assertAllowedCommandSchema(args, "help-doctor");
6134
6590
  if (args.gpu !== undefined && args.gpu !== true) {
6135
- throw new Error("doctor option --gpu does not accept a value.");
6591
+ throw new Error("help doctor option --gpu does not accept a value.");
6136
6592
  }
6137
6593
  }
6138
6594
 
@@ -6149,11 +6605,11 @@ function assertGuideArgs(args) {
6149
6605
  if (args.wallet !== undefined) {
6150
6606
  requireWalletName(args);
6151
6607
  }
6152
- assertAllowedCommandSchema(args, "guide");
6608
+ assertAllowedCommandSchema(args, "help-guide");
6153
6609
  }
6154
6610
 
6155
6611
  function assertTransactionFeesArgs(args) {
6156
- assertAllowedCommandSchema(args, "transaction-fees");
6612
+ assertAllowedCommandSchema(args, "help-transaction-fees");
6157
6613
  }
6158
6614
 
6159
6615
  function assertAccountImportArgs(args) {
@@ -6161,7 +6617,7 @@ function assertAccountImportArgs(args) {
6161
6617
  }
6162
6618
 
6163
6619
  function assertMintNotesArgs(args) {
6164
- assertAllowedCommandSchema(args, "mint-notes");
6620
+ assertAllowedCommandSchema(args, "wallet-mint-notes");
6165
6621
  assertTxSubmitterArg(args);
6166
6622
  parseAmountVector(args.amounts, {
6167
6623
  allowZeroEntries: true,
@@ -6170,13 +6626,13 @@ function assertMintNotesArgs(args) {
6170
6626
  }
6171
6627
 
6172
6628
  function assertRedeemNotesArgs(args) {
6173
- assertAllowedCommandSchema(args, "redeem-notes");
6629
+ assertAllowedCommandSchema(args, "wallet-redeem-notes");
6174
6630
  assertTxSubmitterArg(args);
6175
6631
  selectRedeemNotesMethod(parseNoteIdVector(args.noteIds).length);
6176
6632
  }
6177
6633
 
6178
6634
  function assertTransferNotesArgs(args) {
6179
- assertAllowedCommandSchema(args, "transfer-notes");
6635
+ assertAllowedCommandSchema(args, "wallet-transfer-notes");
6180
6636
  assertTxSubmitterArg(args);
6181
6637
  const noteIds = parseNoteIdVector(args.noteIds);
6182
6638
  const recipients = parseRecipientVector(args.recipients);
@@ -6197,28 +6653,28 @@ function assertTxSubmitterArg(args) {
6197
6653
  }
6198
6654
  }
6199
6655
 
6200
- function assertGetMyNotesArgs(args) {
6201
- assertWalletSecretArgs(args, "get-my-notes");
6656
+ function assertWalletGetNotesArgs(args) {
6657
+ assertWalletSecretArgs(args, "wallet-get-notes");
6202
6658
  }
6203
6659
 
6204
6660
  function assertCreateChannelArgs(args) {
6205
- assertAllowedCommandSchema(args, "create-channel");
6661
+ assertAllowedCommandSchema(args, "channel-create");
6206
6662
  }
6207
6663
 
6208
6664
  function assertRecoverWorkspaceArgs(args) {
6209
- assertAllowedCommandSchema(args, "recover-workspace");
6665
+ assertAllowedCommandSchema(args, "channel-recover-workspace");
6210
6666
  }
6211
6667
 
6212
6668
  function assertGetChannelArgs(args) {
6213
- assertAllowedCommandSchema(args, "get-channel");
6669
+ assertAllowedCommandSchema(args, "channel-get-meta");
6214
6670
  }
6215
6671
 
6216
6672
  function assertDepositBridgeArgs(args) {
6217
- assertAllowedCommandSchema(args, "deposit-bridge");
6673
+ assertAllowedCommandSchema(args, "account-deposit-bridge");
6218
6674
  }
6219
6675
 
6220
- function assertGetMyBridgeFundArgs(args) {
6221
- assertAllowedCommandSchema(args, "get-my-bridge-fund");
6676
+ function assertAccountGetBridgeFundArgs(args) {
6677
+ assertAllowedCommandSchema(args, "account-get-bridge-fund");
6222
6678
  }
6223
6679
 
6224
6680
  function assertExplicitSignerCommandArgs(args, commandName) {
@@ -6226,22 +6682,22 @@ function assertExplicitSignerCommandArgs(args, commandName) {
6226
6682
  }
6227
6683
 
6228
6684
  function assertRecoverWalletArgs(args) {
6229
- assertExplicitSignerCommandArgs(args, "recover-wallet");
6685
+ assertExplicitSignerCommandArgs(args, "wallet-recover-workspace");
6230
6686
  if (args.fromGenesis !== undefined && args.fromGenesis !== true) {
6231
- throw new Error("recover-wallet option --from-genesis does not accept a value.");
6687
+ throw new Error("wallet recover-workspace option --from-genesis does not accept a value.");
6232
6688
  }
6233
6689
  }
6234
6690
 
6235
6691
  function assertJoinChannelArgs(args) {
6236
- assertAllowedCommandSchema(args, "join-channel");
6692
+ assertAllowedCommandSchema(args, "channel-join");
6237
6693
  }
6238
6694
 
6239
- function assertGetMyWalletMetaArgs(args) {
6240
- assertWalletSecretArgs(args, "get-my-wallet-meta");
6695
+ function assertWalletGetMetaArgs(args) {
6696
+ assertWalletSecretArgs(args, "wallet-get-meta");
6241
6697
  }
6242
6698
 
6243
- function assertGetMyL1AddressArgs(args) {
6244
- assertAllowedCommandSchema(args, "get-my-l1-address");
6699
+ function assertAccountGetL1AddressArgs(args) {
6700
+ assertAllowedCommandSchema(args, "account-get-l1-address");
6245
6701
  }
6246
6702
 
6247
6703
  function assertListLocalWalletsArgs(args) {
@@ -6251,19 +6707,45 @@ function assertListLocalWalletsArgs(args) {
6251
6707
  if (args.channelName !== undefined) {
6252
6708
  requireArg(args.channelName, "--channel-name");
6253
6709
  }
6254
- assertAllowedCommandSchema(args, "list-local-wallets");
6710
+ assertAllowedCommandSchema(args, "wallet-list");
6711
+ }
6712
+
6713
+ function assertWalletExportArgs(args) {
6714
+ assertAllowedCommandSchema(args, "wallet-export");
6715
+ assertFlagOption(args, "all", "wallet export");
6716
+ assertFlagOption(args, "includeNotes", "wallet export");
6717
+ requireArg(args.output, "--output");
6718
+ if (args.all === true) {
6719
+ expect(
6720
+ args.network === undefined && args.wallet === undefined,
6721
+ "wallet export --all exports every local mainnet wallet and does not accept --network or --wallet.",
6722
+ );
6723
+ return;
6724
+ }
6725
+ requireNetworkName(args);
6726
+ requireWalletName(args);
6727
+ }
6728
+
6729
+ function assertWalletImportArgs(args) {
6730
+ assertAllowedCommandSchema(args, "wallet-import");
6731
+ }
6732
+
6733
+ function assertFlagOption(args, key, commandName) {
6734
+ if (args[key] !== undefined && args[key] !== true) {
6735
+ throw new Error(`${commandName} option --${toKebabCase(key)} does not accept a value.`);
6736
+ }
6255
6737
  }
6256
6738
 
6257
6739
  function assertWithdrawBridgeArgs(args) {
6258
- assertAllowedCommandSchema(args, "withdraw-bridge");
6740
+ assertAllowedCommandSchema(args, "account-withdraw-bridge");
6259
6741
  }
6260
6742
 
6261
- function assertGetMyChannelFundArgs(args) {
6262
- assertWalletSecretArgs(args, "get-my-channel-fund");
6743
+ function assertWalletGetChannelFundArgs(args) {
6744
+ assertWalletSecretArgs(args, "wallet-get-channel-fund");
6263
6745
  }
6264
6746
 
6265
6747
  function assertExitChannelArgs(args) {
6266
- assertWalletSecretArgs(args, "exit-channel");
6748
+ assertWalletSecretArgs(args, "channel-exit");
6267
6749
  }
6268
6750
 
6269
6751
  function createWalletOperationDir(walletName, networkName, suffix) {
@@ -6289,17 +6771,6 @@ function persistWalletMetadata(context) {
6289
6771
  });
6290
6772
  }
6291
6773
 
6292
- function persistCurrentState(context) {
6293
- if (!context.persistChannelWorkspace || !context.workspaceDir) {
6294
- return;
6295
- }
6296
- writeJson(path.join(channelWorkspaceCurrentPath(context.workspaceDir), "state_snapshot.json"), context.currentSnapshot);
6297
- writeJson(
6298
- path.join(channelWorkspaceCurrentPath(context.workspaceDir), "state_snapshot.normalized.json"),
6299
- context.currentSnapshot,
6300
- );
6301
- }
6302
-
6303
6774
  function printHelp() {
6304
6775
  const commandHelp = PRIVATE_STATE_CLI_COMMANDS.map((command) => [
6305
6776
  ` ${privateStateCliCommandSynopsis(command)}`,
@@ -6313,10 +6784,10 @@ ${commandHelp}
6313
6784
  Secret source options:
6314
6785
  Use account import --private-key-file once to create a protected local account secret.
6315
6786
  L1 signing commands use --account only.
6316
- A wallet secret source file is arbitrary high-entropy secret text read once by join-channel.
6787
+ A wallet secret source file is arbitrary high-entropy secret text read once by channel join.
6317
6788
  Create one before joining a channel, for example:
6318
6789
  openssl rand -hex 32 > ./wallet-secret.txt
6319
- private-state-cli join-channel --channel-name <NAME> --network <NAME> --account <NAME> --wallet-secret-path ./wallet-secret.txt
6790
+ private-state-cli channel join --channel-name <NAME> --network <NAME> --account <NAME> --wallet-secret-path ./wallet-secret.txt
6320
6791
  Bridge-facing commands accept optional --rpc-url. When provided, it is saved to
6321
6792
  ~/tokamak-private-channels/secrets/<network>/.env as RPC_URL. When omitted, the CLI reads RPC_URL from that file.
6322
6793
  Wallet commands use wallet-local default secret files only.
@@ -6331,7 +6802,7 @@ Options:
6331
6802
  Print the command result as JSON. Without --json, commands print human-readable output.
6332
6803
 
6333
6804
  --help
6334
- Show this help
6805
+ Show this help. Equivalent to help commands.
6335
6806
  `);
6336
6807
  }
6337
6808
 
@@ -7056,18 +7527,18 @@ function buildRecoveryHints(error, args = {}) {
7056
7527
  || message.includes("does not match the wallet channel")
7057
7528
  || message.includes("The provided wallet does not belong to the selected channel")
7058
7529
  ) {
7059
- hints.push(`private-state-cli list-local-wallets --network ${networkName}`);
7060
- hints.push(`private-state-cli guide --network ${networkName} --wallet ${walletName}`);
7530
+ hints.push(`private-state-cli wallet list --network ${networkName}`);
7531
+ hints.push(`private-state-cli help guide --network ${networkName} --wallet ${walletName}`);
7061
7532
  }
7062
7533
 
7063
7534
  if (error?.code === CLI_ERROR_CODES.MISSING_WALLET_SECRET) {
7064
7535
  hints.push("restore the wallet-local default secret file from backup before running wallet commands.");
7065
- hints.push(`private-state-cli guide --network ${networkName} --wallet ${walletName}`);
7536
+ hints.push(`private-state-cli help guide --network ${networkName} --wallet ${walletName}`);
7066
7537
  }
7067
7538
 
7068
7539
  if (error?.code === CLI_ERROR_CODES.WALLET_DECRYPT_FAILED) {
7069
7540
  hints.push("verify that the wallet-local default secret file is the same secret used when the wallet was created.");
7070
- hints.push("if the encrypted wallet file is corrupted but the wallet secret and L1 account secret still exist, rerun recover-wallet.");
7541
+ hints.push("if the encrypted wallet file is corrupted but the wallet secret and L1 account secret still exist, rerun wallet recover-workspace.");
7071
7542
  hints.push("if the wallet secret was lost, the local L2 key cannot be recovered from the encrypted wallet file.");
7072
7543
  }
7073
7544
 
@@ -7076,7 +7547,7 @@ function buildRecoveryHints(error, args = {}) {
7076
7547
  || message.includes("Missing --account.")
7077
7548
  ) {
7078
7549
  hints.push(`private-state-cli account import --account ${accountName} --network ${networkName} --private-key-file <PATH>`);
7079
- hints.push(`private-state-cli guide --network ${networkName} --account ${accountName}`);
7550
+ hints.push(`private-state-cli help guide --network ${networkName} --account ${accountName}`);
7080
7551
  }
7081
7552
 
7082
7553
  if (
@@ -7084,31 +7555,31 @@ function buildRecoveryHints(error, args = {}) {
7084
7555
  || message.includes("DApp deployment artifact")
7085
7556
  ) {
7086
7557
  hints.push("private-state-cli install");
7087
- hints.push("private-state-cli doctor --json");
7558
+ hints.push("private-state-cli help doctor --json");
7088
7559
  }
7089
7560
 
7090
7561
  if (error?.code === CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION) {
7091
- hints.push(`private-state-cli join-channel --channel-name ${channelName} --network ${networkName} --account ${accountName} --wallet-secret-path <PATH>`);
7092
- hints.push(`private-state-cli guide --network ${networkName} --channel-name ${channelName} --account ${accountName}`);
7562
+ hints.push(`private-state-cli channel join --channel-name ${channelName} --network ${networkName} --account ${accountName} --wallet-secret-path <PATH>`);
7563
+ hints.push(`private-state-cli help guide --network ${networkName} --channel-name ${channelName} --account ${accountName}`);
7093
7564
  }
7094
7565
 
7095
7566
  if (error?.code === CLI_ERROR_CODES.STALE_WORKSPACE) {
7096
- hints.push(`private-state-cli recover-workspace --channel-name ${channelName} --network ${networkName}`);
7097
- hints.push(`private-state-cli guide --network ${networkName} --channel-name ${channelName}`);
7567
+ hints.push(`private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName}`);
7568
+ hints.push(`private-state-cli help guide --network ${networkName} --channel-name ${channelName}`);
7098
7569
  }
7099
7570
 
7100
7571
  if (message.includes("Workspace recovery index is missing or unusable")) {
7101
- hints.push(`private-state-cli recover-workspace --channel-name ${channelName} --network ${networkName} --from-genesis`);
7102
- hints.push(`private-state-cli recover-wallet --channel-name ${channelName} --network ${networkName} --account ${accountName} --from-genesis`);
7572
+ hints.push(`private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName} --from-genesis`);
7573
+ hints.push(`private-state-cli wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account ${accountName} --from-genesis`);
7103
7574
  }
7104
7575
 
7105
7576
  if (message.includes("Wallet note recovery index is missing or unusable")) {
7106
- hints.push(`private-state-cli recover-wallet --channel-name ${channelName} --network ${networkName} --account ${accountName} --from-genesis`);
7577
+ hints.push(`private-state-cli wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account ${accountName} --from-genesis`);
7107
7578
  }
7108
7579
 
7109
7580
  if (message.includes("Missing channel selector")) {
7110
- hints.push(`private-state-cli list-local-wallets --network ${networkName}`);
7111
- hints.push(`private-state-cli guide --network ${networkName} --channel-name <CHANNEL> --wallet <WALLET>`);
7581
+ hints.push(`private-state-cli wallet list --network ${networkName}`);
7582
+ hints.push(`private-state-cli help guide --network ${networkName} --channel-name <CHANNEL> --wallet <WALLET>`);
7112
7583
  }
7113
7584
 
7114
7585
  return [...new Set(hints)];