@tokamak-private-dapps/private-state-cli 2.0.0 → 2.1.1

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.
@@ -44,6 +44,18 @@ import {
44
44
  requireCanonicalCompatibleBackendVersion,
45
45
  requireExactSemverVersion,
46
46
  } from "@tokamak-private-dapps/common-library/proof-backend-versioning";
47
+ import {
48
+ bigintToHex32,
49
+ buildStateManager,
50
+ buildTokamakTxSnapshot,
51
+ currentStorageBigInt,
52
+ deriveChannelTokenVaultLeafIndex,
53
+ deriveLiquidBalanceStorageKey,
54
+ fetchContractCodes,
55
+ normalizeBytesHex,
56
+ normalizeBytes32Hex,
57
+ serializeBigInts,
58
+ } from "@tokamak-private-dapps/common-library/tokamak-l2-helpers";
47
59
  import {
48
60
  resolveTokamakBlockInputConfig,
49
61
  } from "@tokamak-private-dapps/common-library/tokamak-runtime-paths";
@@ -107,20 +119,6 @@ import {
107
119
  normalizeEncryptedNoteValueWords,
108
120
  unpackEncryptedNoteValue,
109
121
  } from "./lib/private-state-note-delivery.mjs";
110
- import {
111
- bigintToHex32,
112
- buildStateManager,
113
- buildTokamakTxSnapshot,
114
- bytes32FromHex,
115
- currentStorageBigInt,
116
- deriveChannelTokenVaultLeafIndex,
117
- deriveLiquidBalanceStorageKey,
118
- fetchContractCodes,
119
- normalizeBytesHex,
120
- normalizeBytes32Hex,
121
- serializeBigInts,
122
- } from "./lib/private-state-tokamak-helpers.mjs";
123
-
124
122
  const require = createRequire(import.meta.url);
125
123
  const defaultCommandCwd = process.cwd();
126
124
  const privateStateCliPackageJson = require("./package.json");
@@ -137,9 +135,11 @@ const GROTH16_PACKAGE_NAME = "@tokamak-private-dapps/groth16";
137
135
  const TOKAMAK_ZKEVM_CLI_PACKAGE_NAME = "@tokamak-zk-evm/cli";
138
136
  const WALLET_BACKUP_EXPORT_FORMAT = "tokamak-private-state-wallet-backup-export";
139
137
  const WALLET_KEY_EXPORT_FORMAT = "tokamak-private-state-wallet-key-export";
138
+ const WALLET_INDEX_FORMAT = "tokamak-private-state-wallet-index";
140
139
  const WALLET_EVIDENCE_BUNDLE_FORMAT = "tokamak-private-state-raw-evidence-bundle";
141
140
  const WALLET_EXPORT_FORMAT_VERSION = 2;
142
- const WALLET_EVIDENCE_BUNDLE_FORMAT_VERSION = 1;
141
+ const WALLET_INDEX_FORMAT_VERSION = 1;
142
+ const WALLET_EVIDENCE_BUNDLE_FORMAT_VERSION = 2;
143
143
  const WALLET_WORKSPACE_FORMAT_VERSION = 2;
144
144
  const CHANNEL_WORKSPACE_MIRROR_PROTOCOL_VERSION = 2;
145
145
  const CHANNEL_WORKSPACE_MIRROR_MANIFEST_PATH_PREFIX =
@@ -918,7 +918,12 @@ async function handleWorkspaceInit({ args, network, provider }) {
918
918
  const bridgeResources = loadBridgeResources({ chainId: network.chainId });
919
919
  const recoverySource = resolveWorkspaceRecoverySource(args);
920
920
 
921
- const { workspaceDir, workspace, currentSnapshot } = await syncChannelWorkspace({
921
+ const {
922
+ workspaceDir,
923
+ workspace,
924
+ currentSnapshot,
925
+ cleanRebuildBackup,
926
+ } = await syncChannelWorkspace({
922
927
  workspaceName,
923
928
  channelName,
924
929
  network,
@@ -937,6 +942,7 @@ async function handleWorkspaceInit({ args, network, provider }) {
937
942
  source: workspace.recoverySource ?? recoverySource,
938
943
  workspace: workspaceName,
939
944
  workspaceDir,
945
+ cleanRebuildBackup: cleanRebuildBackup ?? null,
940
946
  channelName,
941
947
  channelId: workspace.channelId,
942
948
  channelManager: workspace.channelManager,
@@ -1647,10 +1653,18 @@ async function fetchChannelWorkspaceMirror({
1647
1653
 
1648
1654
  const mirrorAheadOfLocal = localRecoveryIndex
1649
1655
  && Number(manifestPrecheck.recoveryLastScannedBlock) > Number(localRecoveryIndex.nextBlock);
1650
- const bundleResult = mirrorAheadOfLocal
1656
+ const deltaBundleDescriptor = mirrorAheadOfLocal
1657
+ ? selectWorkspaceMirrorDeltaBundle({
1658
+ manifest,
1659
+ fromBlock: Number(localRecoveryIndex.nextBlock),
1660
+ toBlock: Number(manifest.checkpoint.recoveryLastScannedBlock) - 1,
1661
+ })
1662
+ : null;
1663
+ const bundleResult = mirrorAheadOfLocal && deltaBundleDescriptor
1651
1664
  ? await fetchAndApplyWorkspaceMirrorDelta({
1652
1665
  manifest,
1653
1666
  manifestUrl,
1667
+ bundleDescriptor: deltaBundleDescriptor,
1654
1668
  localRecoveryIndex,
1655
1669
  chainId,
1656
1670
  channelId,
@@ -2052,6 +2066,7 @@ function validateWorkspaceMirrorCheckpointArchive({
2052
2066
  async function fetchAndApplyWorkspaceMirrorDelta({
2053
2067
  manifest,
2054
2068
  manifestUrl,
2069
+ bundleDescriptor,
2055
2070
  localRecoveryIndex,
2056
2071
  chainId,
2057
2072
  channelId,
@@ -2065,7 +2080,6 @@ async function fetchAndApplyWorkspaceMirrorDelta({
2065
2080
  }) {
2066
2081
  const fromBlock = Number(localRecoveryIndex.nextBlock);
2067
2082
  const toBlock = Number(manifest.checkpoint.recoveryLastScannedBlock) - 1;
2068
- const bundleDescriptor = selectWorkspaceMirrorDeltaBundle({ manifest, fromBlock, toBlock });
2069
2083
  expect(
2070
2084
  bundleDescriptor,
2071
2085
  `Workspace mirror does not provide a delta bundle for local recovery index ${fromBlock} to checkpoint block ${toBlock}.`,
@@ -2163,6 +2177,7 @@ async function syncChannelWorkspace({
2163
2177
  }) {
2164
2178
  const workspaceDir = channelWorkspacePath(networkNameFromChainId(network.chainId), workspaceName);
2165
2179
  const channelDir = channelDataPath(workspaceDir);
2180
+ let cleanRebuildBackup = null;
2166
2181
  const hasPersistedChannelData = fs.existsSync(channelWorkspaceConfigPath(workspaceDir))
2167
2182
  || fs.existsSync(channelWorkspaceCurrentPath(workspaceDir))
2168
2183
  || fs.existsSync(channelWorkspaceOperationsPath(workspaceDir));
@@ -2174,6 +2189,13 @@ async function syncChannelWorkspace({
2174
2189
  const existingArtifacts = persist && hasPersistedChannelData
2175
2190
  ? loadExistingWorkspaceArtifacts(workspaceDir)
2176
2191
  : null;
2192
+ if (persist && useWorkspaceRecoveryIndex && fromGenesis) {
2193
+ cleanRebuildBackup = backupWorkspaceForCleanRebuild({
2194
+ workspaceDir,
2195
+ networkName: networkNameFromChainId(network.chainId),
2196
+ channelName,
2197
+ });
2198
+ }
2177
2199
 
2178
2200
  const { bridgeDeployment, bridgeAbiManifest } = bridgeResources;
2179
2201
  const bridgeCore = new Contract(bridgeDeployment.bridgeCore, bridgeAbiManifest.contracts.bridgeCore.abi, provider);
@@ -2285,9 +2307,57 @@ async function syncChannelWorkspace({
2285
2307
  throw new Error([
2286
2308
  `Workspace recovery index is missing or unusable for channel ${channelName} on ${networkNameFromChainId(network.chainId)}.`,
2287
2309
  "The CLI will not fall back to replaying channel logs from genesis unless explicitly requested.",
2288
- "Run channel recover-workspace --source rpc --from-genesis or wallet recover-workspace --from-genesis to rebuild from channel genesis.",
2310
+ "Run channel recover-workspace first to refresh the local channel workspace.",
2289
2311
  ].join(" "));
2290
2312
  }
2313
+ const workspaceBase = {
2314
+ name: workspaceName,
2315
+ network: networkNameFromChainId(network.chainId),
2316
+ chainId: network.chainId,
2317
+ appDeploymentPath: deploymentManifestPath,
2318
+ storageLayoutPath: storageLayoutManifestPath,
2319
+ channelId: channelId.toString(),
2320
+ channelName,
2321
+ dappId: Number(channelInfo.dappId),
2322
+ genesisBlockNumber,
2323
+ bridgeCore: getAddress(bridgeDeployment.bridgeCore),
2324
+ channelManager: getAddress(channelInfo.manager),
2325
+ bridgeTokenVault: getAddress(channelInfo.bridgeTokenVault),
2326
+ canonicalAsset,
2327
+ canonicalAssetDecimals,
2328
+ controller: controllerAddress,
2329
+ l2AccountingVault: l2AccountingVaultAddress,
2330
+ aPubBlockHash: normalizeBytes32Hex(channelInfo.aPubBlockHash),
2331
+ dappMetadataDigestSchema: policySnapshot.dappMetadataDigestSchema,
2332
+ dappMetadataDigest: policySnapshot.dappMetadataDigest,
2333
+ functionRoot: policySnapshot.functionRoot,
2334
+ policySnapshot,
2335
+ managedStorageAddresses,
2336
+ liquidBalancesSlot: liquidBalancesSlot.toString(),
2337
+ recoverySource,
2338
+ workspaceMirror: mirrorRecovery.workspaceMirror,
2339
+ };
2340
+ const buildWorkspaceForSnapshot = ({ currentSnapshot, scanRange }) => {
2341
+ const recoveryRootVectorHash = normalizeBytes32Hex(hashRootVector(currentSnapshot.stateRoots));
2342
+ return {
2343
+ ...workspaceBase,
2344
+ recoveryLastScannedBlock: Number(scanRange.toBlock) + 1,
2345
+ recoveryRootVectorHash,
2346
+ recoveryScanRange: scanRange,
2347
+ };
2348
+ };
2349
+ const persistWorkspaceCheckpoint = persist
2350
+ ? ({ currentSnapshot, scanRange }) => {
2351
+ persistChannelWorkspaceFiles({
2352
+ workspaceDir,
2353
+ channelDir,
2354
+ workspace: buildWorkspaceForSnapshot({ currentSnapshot, scanRange }),
2355
+ currentSnapshot,
2356
+ blockInfo,
2357
+ contractCodes,
2358
+ });
2359
+ }
2360
+ : null;
2291
2361
  const reconstruction = localSnapshotReusable
2292
2362
  ? {
2293
2363
  currentSnapshot: existingArtifacts.stateSnapshot,
@@ -2323,56 +2393,23 @@ async function syncChannelWorkspace({
2323
2393
  fromBlock: selectedRecoveryIndex?.nextBlock ?? genesisBlockNumber,
2324
2394
  toBlock: latestBlock,
2325
2395
  progressAction,
2396
+ onCheckpoint: persistWorkspaceCheckpoint,
2326
2397
  });
2327
2398
  const currentSnapshot = reconstruction.currentSnapshot;
2328
- const recoveryRootVectorHash = normalizeBytes32Hex(hashRootVector(currentSnapshot.stateRoots));
2329
- const recoveryLastScannedBlock = Number(reconstruction.scanRange.toBlock) + 1;
2330
-
2331
- const workspace = {
2332
- name: workspaceName,
2333
- network: networkNameFromChainId(network.chainId),
2334
- chainId: network.chainId,
2335
- appDeploymentPath: deploymentManifestPath,
2336
- storageLayoutPath: storageLayoutManifestPath,
2337
- channelId: channelId.toString(),
2338
- channelName,
2339
- dappId: Number(channelInfo.dappId),
2340
- genesisBlockNumber,
2341
- bridgeCore: getAddress(bridgeDeployment.bridgeCore),
2342
- channelManager: getAddress(channelInfo.manager),
2343
- bridgeTokenVault: getAddress(channelInfo.bridgeTokenVault),
2344
- canonicalAsset,
2345
- canonicalAssetDecimals,
2346
- controller: controllerAddress,
2347
- l2AccountingVault: l2AccountingVaultAddress,
2348
- aPubBlockHash: normalizeBytes32Hex(channelInfo.aPubBlockHash),
2349
- dappMetadataDigestSchema: policySnapshot.dappMetadataDigestSchema,
2350
- dappMetadataDigest: policySnapshot.dappMetadataDigest,
2351
- functionRoot: policySnapshot.functionRoot,
2352
- policySnapshot,
2353
- managedStorageAddresses,
2354
- liquidBalancesSlot: liquidBalancesSlot.toString(),
2355
- recoverySource,
2356
- workspaceMirror: mirrorRecovery.workspaceMirror,
2357
- recoveryLastScannedBlock,
2358
- recoveryRootVectorHash,
2359
- recoveryScanRange: reconstruction.scanRange,
2360
- };
2399
+ const workspace = buildWorkspaceForSnapshot({
2400
+ currentSnapshot,
2401
+ scanRange: reconstruction.scanRange,
2402
+ });
2361
2403
 
2362
2404
  if (persist) {
2363
- ensureDir(channelDir);
2364
- ensureDir(channelWorkspaceCurrentPath(workspaceDir));
2365
- ensureDir(channelWorkspaceOperationsPath(workspaceDir));
2366
- ensureDir(workspaceWalletsDir(workspaceDir));
2367
-
2368
- writeJsonIfChanged(channelWorkspaceConfigPath(workspaceDir), workspace);
2369
- writeJsonIfChanged(path.join(channelWorkspaceCurrentPath(workspaceDir), "state_snapshot.json"), currentSnapshot);
2370
- writeJsonIfChanged(
2371
- path.join(channelWorkspaceCurrentPath(workspaceDir), "state_snapshot.normalized.json"),
2405
+ persistChannelWorkspaceFiles({
2406
+ workspaceDir,
2407
+ channelDir,
2408
+ workspace,
2372
2409
  currentSnapshot,
2373
- );
2374
- writeJsonIfChanged(path.join(channelWorkspaceCurrentPath(workspaceDir), "block_info.json"), blockInfo);
2375
- writeJsonIfChanged(path.join(channelWorkspaceCurrentPath(workspaceDir), "contract_codes.json"), contractCodes);
2410
+ blockInfo,
2411
+ contractCodes,
2412
+ });
2376
2413
  }
2377
2414
 
2378
2415
  return {
@@ -2381,6 +2418,7 @@ async function syncChannelWorkspace({
2381
2418
  currentSnapshot,
2382
2419
  blockInfo,
2383
2420
  contractCodes,
2421
+ cleanRebuildBackup,
2384
2422
  };
2385
2423
  }
2386
2424
 
@@ -2461,39 +2499,13 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2461
2499
  const channelName = requireArg(args.channelName, "--channel-name");
2462
2500
  const signer = requireL1Signer(args, provider);
2463
2501
  const walletName = walletNameForChannelAndAddress(channelName, signer.address);
2464
- const bridgeResources = loadBridgeResources({ chainId: network.chainId });
2465
- const initialized = await syncChannelWorkspace({
2466
- workspaceName: channelName,
2502
+ const channelContextResult = await loadFreshChannelWorkspaceContextResult({
2467
2503
  channelName,
2468
- network,
2504
+ networkName: requireNetworkName(args),
2469
2505
  provider,
2470
- bridgeResources,
2471
- persist: true,
2472
- allowExistingWorkspaceSync: true,
2473
- useWorkspaceRecoveryIndex: true,
2474
- fromGenesis: args.fromGenesis === true,
2475
2506
  progressAction: "wallet recover-workspace",
2476
2507
  });
2477
- const context = {
2478
- workspaceName: channelName,
2479
- workspaceDir: initialized.workspaceDir,
2480
- persistChannelWorkspace: true,
2481
- workspace: initialized.workspace,
2482
- bridgeAbiManifest: bridgeResources.bridgeAbiManifest,
2483
- currentSnapshot: initialized.currentSnapshot,
2484
- blockInfo: initialized.blockInfo,
2485
- contractCodes: initialized.contractCodes,
2486
- channelManager: new Contract(
2487
- initialized.workspace.channelManager,
2488
- bridgeResources.bridgeAbiManifest.contracts.channelManager.abi,
2489
- provider,
2490
- ),
2491
- bridgeTokenVault: new Contract(
2492
- initialized.workspace.bridgeTokenVault,
2493
- bridgeResources.bridgeAbiManifest.contracts.bridgeTokenVault.abi,
2494
- provider,
2495
- ),
2496
- };
2508
+ const context = channelContextResult.context;
2497
2509
  const noteReceiveKeyMaterial = await deriveNoteReceiveKeyMaterial({
2498
2510
  signer,
2499
2511
  chainId: network.chainId,
@@ -2502,64 +2514,51 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2502
2514
  account: signer.address,
2503
2515
  });
2504
2516
  const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
2505
-
2506
- if (!registration.exists) {
2507
- const cleanup = removeLocalWalletArtifacts(walletName, context.workspace.network);
2508
- if (cleanup.removed) {
2509
- printJson({
2510
- action: "wallet recover-workspace",
2511
- status: "stale-wallet-removed",
2512
- wallet: walletName,
2513
- removedWalletDir: cleanup.removedWalletDir ? cleanup.walletDir : null,
2514
- removedWalletSecretFile: cleanup.removedWalletSecret ? cleanup.walletSecretFile : null,
2515
- workspace: context.workspaceName,
2516
- channelName: context.workspace.channelName,
2517
- channelId: context.workspace.channelId,
2518
- l1Address: signer.address,
2519
- l2Address: null,
2520
- l2StorageKey: null,
2521
- leafIndex: null,
2522
- reason: "The local wallet existed, but the L1 address is no longer registered in the channel.",
2523
- nextAction: buildRecoverWalletRemovedNextAction({
2524
- channelName,
2525
- networkName: network.name,
2526
- accountName: args.account,
2527
- }),
2528
- });
2529
- return;
2530
- }
2531
- expect(
2532
- false,
2533
- cliError(
2534
- CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION,
2535
- `No channelTokenVault registration exists for ${signer.address}. Run channel join first.`,
2536
- ),
2537
- );
2538
- }
2517
+ const lifecycleEpoch = await resolveWalletLifecycleEpoch({
2518
+ context,
2519
+ provider,
2520
+ l1Address: signer.address,
2521
+ registration,
2522
+ });
2523
+ expect(
2524
+ lifecycleEpoch,
2525
+ cliError(
2526
+ CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION,
2527
+ `No channelTokenVault registration history exists for ${signer.address}. Run channel join first.`,
2528
+ ),
2529
+ );
2530
+ const registeredNoteReceivePubKey = lifecycleEpoch.noteReceivePubKey;
2539
2531
  expect(
2540
- ethers.toBigInt(normalizeBytes32Hex(registration.noteReceivePubKey.x))
2532
+ ethers.toBigInt(normalizeBytes32Hex(registeredNoteReceivePubKey.x))
2541
2533
  === ethers.toBigInt(normalizeBytes32Hex(noteReceiveKeyMaterial.noteReceivePubKey.x)),
2542
2534
  "The existing note-receive public key X does not match the derived note-receive public key.",
2543
2535
  );
2544
2536
  expect(
2545
- Number(registration.noteReceivePubKey.yParity) === Number(noteReceiveKeyMaterial.noteReceivePubKey.yParity),
2537
+ Number(registeredNoteReceivePubKey.yParity) === Number(noteReceiveKeyMaterial.noteReceivePubKey.yParity),
2546
2538
  "The existing note-receive public key parity does not match the derived note-receive public key.",
2547
2539
  );
2548
2540
  const l2Identity = {
2549
2541
  l2PrivateKey: null,
2550
2542
  l2PublicKey: null,
2551
- l2Address: getAddress(registration.l2Address),
2543
+ l2Address: getAddress(lifecycleEpoch.l2Address),
2552
2544
  };
2553
- const storageKey = normalizeBytes32Hex(registration.channelTokenVaultKey);
2545
+ const storageKey = normalizeBytes32Hex(lifecycleEpoch.channelTokenVaultKey);
2554
2546
 
2555
- const walletDir = walletPath(walletName, context.workspace.network);
2547
+ const walletDir = walletEpochPath(walletName, context.workspace.network, lifecycleEpoch.epochId);
2556
2548
  const existingWallet = walletConfigExists(walletDir)
2557
- ? loadWallet(walletName, context.workspace.network)
2549
+ ? loadWalletFromDir({
2550
+ walletName,
2551
+ networkName: context.workspace.network,
2552
+ walletDir,
2553
+ })
2558
2554
  : null;
2559
2555
 
2560
2556
  if (existingWallet) {
2561
2557
  existingWallet.wallet.noteReceivePrivateKey = noteReceiveKeyMaterial.privateKey;
2558
+ applyWalletLifecycleEpoch(existingWallet.wallet, lifecycleEpoch);
2562
2559
  persistWalletKeys(existingWallet);
2560
+ persistWallet(existingWallet);
2561
+ persistWalletIndexForContext(existingWallet);
2563
2562
  const { recoveredDeliveryState } = await recoverWalletReceivedNotes({
2564
2563
  walletContext: existingWallet,
2565
2564
  context,
@@ -2574,13 +2573,18 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2574
2573
  status: "already-recovered",
2575
2574
  wallet: walletName,
2576
2575
  walletDir: existingWallet.walletDir,
2576
+ recoveredChannelWorkspace: channelContextResult.recoveredWorkspace,
2577
+ channelAutoRecoveryBlockDelta: channelContextResult.autoRecoveryBlockDelta,
2577
2578
  workspace: context.workspaceName,
2578
2579
  channelName: context.workspace.channelName,
2579
2580
  channelId: context.workspace.channelId,
2580
2581
  l1Address: signer.address,
2581
2582
  l2Address: l2Identity.l2Address,
2582
2583
  l2StorageKey: storageKey,
2583
- leafIndex: registration.leafIndex.toString(),
2584
+ leafIndex: lifecycleEpoch.leafIndex.toString(),
2585
+ epochId: lifecycleEpoch.epochId,
2586
+ lifecycleStatus: lifecycleEpoch.lifecycleStatus,
2587
+ exitedAtTxHash: lifecycleEpoch.exitedAtTxHash,
2584
2588
  noteReceivePubKey: noteReceiveKeyMaterial.noteReceivePubKey,
2585
2589
  l2Nonce: existingWallet.wallet.l2Nonce,
2586
2590
  recoveredFromLogs: recoveredDeliveryState.importedNotes,
@@ -2590,8 +2594,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2590
2594
  return;
2591
2595
  }
2592
2596
 
2593
- fs.rmSync(walletPath(walletName, context.workspace.network), { recursive: true, force: true });
2594
-
2595
2597
  const walletContext = ensureWallet({
2596
2598
  channelContext: context,
2597
2599
  signerAddress: signer.address,
@@ -2599,8 +2601,9 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2599
2601
  l2Identity,
2600
2602
  walletSecret: noteReceiveKeyMaterial.privateKey,
2601
2603
  storageKey,
2602
- leafIndex: registration.leafIndex,
2604
+ leafIndex: lifecycleEpoch.leafIndex,
2603
2605
  noteReceiveKeyMaterial,
2606
+ lifecycleEpoch,
2604
2607
  rpcUrl,
2605
2608
  });
2606
2609
  walletContext.wallet.l2Nonce = 0;
@@ -2621,13 +2624,18 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2621
2624
  status: "recovered",
2622
2625
  wallet: walletName,
2623
2626
  walletDir: walletContext.walletDir,
2627
+ recoveredChannelWorkspace: channelContextResult.recoveredWorkspace,
2628
+ channelAutoRecoveryBlockDelta: channelContextResult.autoRecoveryBlockDelta,
2624
2629
  workspace: context.workspaceName,
2625
2630
  channelName: context.workspace.channelName,
2626
2631
  channelId: context.workspace.channelId,
2627
2632
  l1Address: signer.address,
2628
2633
  l2Address: l2Identity.l2Address,
2629
2634
  l2StorageKey: storageKey,
2630
- leafIndex: registration.leafIndex.toString(),
2635
+ leafIndex: lifecycleEpoch.leafIndex.toString(),
2636
+ epochId: lifecycleEpoch.epochId,
2637
+ lifecycleStatus: lifecycleEpoch.lifecycleStatus,
2638
+ exitedAtTxHash: lifecycleEpoch.exitedAtTxHash,
2631
2639
  noteReceivePubKey: noteReceiveKeyMaterial.noteReceivePubKey,
2632
2640
  l2Nonce: walletContext.wallet.l2Nonce,
2633
2641
  recoveredFromLogs: recoveredDeliveryState.importedNotes,
@@ -2636,32 +2644,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2636
2644
  });
2637
2645
  }
2638
2646
 
2639
- function removeLocalWalletArtifacts(walletName, networkName) {
2640
- const walletDir = walletPath(walletName, networkName);
2641
- const walletSecretFile = walletSecretPath(networkName, walletName);
2642
- const walletSecretDir = path.dirname(walletSecretFile);
2643
- const removedWalletDir = fs.existsSync(walletDir);
2644
- const removedWalletSecret = fs.existsSync(walletSecretFile) || fs.existsSync(walletSecretDir);
2645
- if (removedWalletDir) {
2646
- fs.rmSync(walletDir, { recursive: true, force: true });
2647
- }
2648
- if (removedWalletSecret) {
2649
- fs.rmSync(walletSecretDir, { recursive: true, force: true });
2650
- }
2651
- return {
2652
- walletDir,
2653
- walletSecretFile,
2654
- removed: removedWalletDir || removedWalletSecret,
2655
- removedWalletDir,
2656
- removedWalletSecret,
2657
- };
2658
- }
2659
-
2660
- function buildRecoverWalletRemovedNextAction({ channelName, networkName, accountName }) {
2661
- const account = accountName ? String(accountName) : "<ACCOUNT>";
2662
- return `channel join --channel-name ${channelName} --network ${networkName} --account ${account} --wallet-secret-path <PATH> --acknowledge-action-impact`;
2663
- }
2664
-
2665
2647
  async function handleInstallZkEvm({ args }) {
2666
2648
  const selectedVersions = await resolvePrivateStateInstallRuntimeVersions(args);
2667
2649
  const tokamakCliRuntime = await installTokamakCliRuntimeForPrivateState({
@@ -2762,6 +2744,73 @@ function uniquePaths(paths) {
2762
2744
  return [...new Set(paths.map((entry) => path.resolve(entry)))];
2763
2745
  }
2764
2746
 
2747
+ function backupWorkspaceForCleanRebuild({ workspaceDir, networkName, channelName }) {
2748
+ const resolvedWorkspaceDir = path.resolve(workspaceDir);
2749
+ if (!fs.existsSync(resolvedWorkspaceDir)) {
2750
+ return null;
2751
+ }
2752
+ expectPathWithinRoot(
2753
+ resolvedWorkspaceDir,
2754
+ workspaceRoot,
2755
+ `Clean rebuild refuses to move a path outside the private-state workspace root: ${resolvedWorkspaceDir}.`,
2756
+ );
2757
+
2758
+ const backupRoot = path.join(
2759
+ privateStateCliDataRoot(),
2760
+ "workspace-rebuild-backups",
2761
+ slugifyPathComponent(networkName),
2762
+ );
2763
+ ensureDir(backupRoot);
2764
+ const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
2765
+ const backupBasePath = path.join(
2766
+ backupRoot,
2767
+ `${slugifyPathComponent(channelName)}-${timestamp}`,
2768
+ );
2769
+ const backupPath = nextAvailablePath(backupBasePath);
2770
+ fs.renameSync(resolvedWorkspaceDir, backupPath);
2771
+ return {
2772
+ workspaceDir: resolvedWorkspaceDir,
2773
+ backupPath,
2774
+ secretsPreserved: true,
2775
+ };
2776
+ }
2777
+
2778
+ function persistChannelWorkspaceFiles({
2779
+ workspaceDir,
2780
+ channelDir,
2781
+ workspace,
2782
+ currentSnapshot,
2783
+ blockInfo,
2784
+ contractCodes,
2785
+ }) {
2786
+ ensureDir(channelDir);
2787
+ ensureDir(channelWorkspaceCurrentPath(workspaceDir));
2788
+ ensureDir(channelWorkspaceOperationsPath(workspaceDir));
2789
+ ensureDir(workspaceWalletsDir(workspaceDir));
2790
+
2791
+ writeJsonIfChanged(path.join(channelWorkspaceCurrentPath(workspaceDir), "state_snapshot.json"), currentSnapshot);
2792
+ writeJsonIfChanged(
2793
+ path.join(channelWorkspaceCurrentPath(workspaceDir), "state_snapshot.normalized.json"),
2794
+ currentSnapshot,
2795
+ );
2796
+ writeJsonIfChanged(path.join(channelWorkspaceCurrentPath(workspaceDir), "block_info.json"), blockInfo);
2797
+ writeJsonIfChanged(path.join(channelWorkspaceCurrentPath(workspaceDir), "contract_codes.json"), contractCodes);
2798
+ writeJsonIfChanged(channelWorkspaceConfigPath(workspaceDir), workspace);
2799
+ }
2800
+
2801
+ function nextAvailablePath(basePath) {
2802
+ if (!fs.existsSync(basePath)) {
2803
+ return basePath;
2804
+ }
2805
+ for (let index = 1; index <= 1000; index += 1) {
2806
+ const candidate = `${basePath}-${index}`;
2807
+ if (!fs.existsSync(candidate)) {
2808
+ return candidate;
2809
+ }
2810
+ }
2811
+ throw new Error(`Unable to allocate a clean rebuild backup path for ${basePath}.`);
2812
+ }
2813
+
2765
2814
  function removeManagedRoot({ label, rootPath }) {
2766
2815
  const resolvedPath = path.resolve(rootPath);
2767
2816
  try {
@@ -2982,7 +3031,6 @@ function handleInvestigator() {
2982
3031
  function resolveInvestigatorIndexPath() {
2983
3032
  const candidates = [
2984
3033
  path.join(privateStateCliPackageRoot, "investigator", "index.html"),
2985
- path.resolve(privateStateCliPackageRoot, "..", "investigator", "index.html"),
2986
3034
  ];
2987
3035
  const htmlPath = candidates.find((candidate) => fs.existsSync(candidate));
2988
3036
  if (!htmlPath) {
@@ -3371,9 +3419,13 @@ function handleWalletImportKey({ args, keyKind }) {
3371
3419
  : walletViewingKeySecretPath(networkName, walletName);
3372
3420
  expect(!fs.existsSync(targetPath), `Refusing to overwrite existing ${keyKind} key: ${targetPath}.`);
3373
3421
  writeSecretFile(targetPath, JSON.stringify(payload, null, 2));
3374
- const walletDir = walletPath(walletName, networkName);
3375
- ensureDir(walletDir);
3376
- if (walletConfigExists(walletDir)) {
3422
+ const walletRoot = walletRootPath(walletName, networkName);
3423
+ const walletIndex = fs.existsSync(walletRoot)
3424
+ ? requireWalletIndex({ walletRoot, walletName, networkName })
3425
+ : null;
3426
+ const selectedEpoch = walletIndex ? selectedWalletEpoch(walletIndex, walletName, networkName) : null;
3427
+ if (selectedEpoch) {
3428
+ const walletDir = walletEpochPathFromRoot(walletRoot, selectedEpoch.epochId);
3377
3429
  const metadataPath = keyKind === "spending"
3378
3430
  ? walletSpendingKeyMetadataPath(walletDir)
3379
3431
  : walletViewingKeyMetadataPath(walletDir);
@@ -3842,7 +3894,13 @@ async function inspectGuideAccount({ account, networkName, network, provider, ar
3842
3894
  }
3843
3895
 
3844
3896
  async function inspectGuideWallet({ walletName, networkName, provider, artifactsInstalled }) {
3845
- const walletDir = walletPath(walletName, networkName);
3897
+ let walletDir = walletRootPath(walletName, networkName);
3898
+ let workspaceError = null;
3899
+ try {
3900
+ walletDir = selectedWalletEpochDir(walletName, networkName);
3901
+ } catch (error) {
3902
+ workspaceError = error.message;
3903
+ }
3846
3904
  const viewingKeyFile = walletViewingKeySecretPath(networkName, walletName);
3847
3905
  const spendingKeyFile = walletSpendingKeySecretPath(networkName, walletName);
3848
3906
  const result = {
@@ -3865,9 +3923,9 @@ async function inspectGuideWallet({ walletName, networkName, provider, artifacts
3865
3923
  unusedNoteBalanceBaseUnits: null,
3866
3924
  unusedNoteBalanceTokens: null,
3867
3925
  spentNoteCount: null,
3868
- error: null,
3926
+ error: workspaceError,
3869
3927
  };
3870
- if (!result.exists) {
3928
+ if (workspaceError || !result.exists) {
3871
3929
  return result;
3872
3930
  }
3873
3931
 
@@ -4109,6 +4167,7 @@ async function handleWalletGetMeta({ args, provider }) {
4109
4167
  printJson({
4110
4168
  action: "wallet get-meta",
4111
4169
  wallet: wallet.walletName,
4170
+ ...walletLifecycleMetadata(wallet.wallet),
4112
4171
  network: walletMetadata.network,
4113
4172
  channelName: walletMetadata.channelName,
4114
4173
  l1Address: signer.address,
@@ -4202,6 +4261,156 @@ async function loadWalletChannelRegistrationState({
4202
4261
  };
4203
4262
  }
4204
4263
 
4264
+ async function resolveWalletLifecycleEpoch({
4265
+ context,
4266
+ provider,
4267
+ l1Address,
4268
+ registration = null,
4269
+ }) {
4270
+ const epochs = await readWalletLifecycleEpochs({
4271
+ context,
4272
+ provider,
4273
+ l1Address,
4274
+ });
4275
+ if (epochs.length === 0 && registration?.exists) {
4276
+ const block = await provider.getBlock("latest").catch(() => null);
4277
+ return {
4278
+ epochId: `join-registered-${String(registration.joinedAt)}-${String(registration.leafIndex)}`,
4279
+ lifecycleStatus: "active",
4280
+ joinedAtTxHash: null,
4281
+ joinedAtBlockNumber: null,
4282
+ joinedAtLogIndex: null,
4283
+ joinedAtBlockTimestamp: Number(registration.joinedAt),
4284
+ joinedAtBlockTimestampIso: Number(registration.joinedAt) > 0
4285
+ ? new Date(Number(registration.joinedAt) * 1000).toISOString()
4286
+ : null,
4287
+ exitedAtTxHash: null,
4288
+ exitedAtBlockNumber: null,
4289
+ exitedAtLogIndex: null,
4290
+ exitedAtBlockTimestamp: null,
4291
+ exitedAtBlockTimestampIso: null,
4292
+ l2Address: getAddress(registration.l2Address),
4293
+ channelTokenVaultKey: normalizeBytes32Hex(registration.channelTokenVaultKey),
4294
+ leafIndex: registration.leafIndex,
4295
+ noteReceivePubKey: {
4296
+ x: normalizeBytes32Hex(registration.noteReceivePubKey.x),
4297
+ yParity: Number(registration.noteReceivePubKey.yParity),
4298
+ },
4299
+ observedAtBlockNumber: block?.number ?? null,
4300
+ };
4301
+ }
4302
+ if (registration?.exists) {
4303
+ const active = [...epochs].reverse().find((epoch) => (
4304
+ epoch.lifecycleStatus === "active"
4305
+ && ethers.toBigInt(getAddress(epoch.l2Address)) === ethers.toBigInt(getAddress(registration.l2Address))
4306
+ && ethers.toBigInt(normalizeBytes32Hex(epoch.channelTokenVaultKey))
4307
+ === ethers.toBigInt(normalizeBytes32Hex(registration.channelTokenVaultKey))
4308
+ ));
4309
+ if (active) {
4310
+ return active;
4311
+ }
4312
+ }
4313
+ return epochs[epochs.length - 1] ?? null;
4314
+ }
4315
+
4316
+ async function readWalletLifecycleEpochs({ context, provider, l1Address }) {
4317
+ const fromBlock = Number(context.workspace.genesisBlockNumber ?? 0);
4318
+ const [registeredLogs, exitedLogs] = await Promise.all([
4319
+ context.channelManager.queryFilter(
4320
+ context.channelManager.filters.ChannelTokenVaultIdentityRegistered(l1Address),
4321
+ fromBlock,
4322
+ "latest",
4323
+ ),
4324
+ context.channelManager.queryFilter(
4325
+ context.channelManager.filters.ChannelTokenVaultIdentityExited(l1Address),
4326
+ fromBlock,
4327
+ "latest",
4328
+ ),
4329
+ ]);
4330
+ const exits = await Promise.all(exitedLogs.map((log) => walletExitFromLog({ log, provider })));
4331
+ const epochs = [];
4332
+ for (const log of registeredLogs.sort(compareLogsByPosition)) {
4333
+ const registered = await walletEpochFromRegisteredLog({ log, provider });
4334
+ const exit = exits.find((entry) => (
4335
+ compareLogPosition(entry, registered) > 0
4336
+ && ethers.toBigInt(entry.leafIndex) === ethers.toBigInt(registered.leafIndex)
4337
+ && !epochs.some((epoch) => epoch.exitedAtTxHash === entry.exitedAtTxHash)
4338
+ ));
4339
+ if (exit) {
4340
+ epochs.push({
4341
+ ...registered,
4342
+ lifecycleStatus: "exited",
4343
+ exitedAtTxHash: exit.exitedAtTxHash,
4344
+ exitedAtBlockNumber: exit.exitedAtBlockNumber,
4345
+ exitedAtLogIndex: exit.exitedAtLogIndex,
4346
+ exitedAtBlockTimestamp: exit.exitedAtBlockTimestamp,
4347
+ exitedAtBlockTimestampIso: exit.exitedAtBlockTimestampIso,
4348
+ });
4349
+ } else {
4350
+ epochs.push(registered);
4351
+ }
4352
+ }
4353
+ return epochs.sort(compareWalletEpochs);
4354
+ }
4355
+
4356
+ async function walletEpochFromRegisteredLog({ log, provider }) {
4357
+ const block = await provider.getBlock(log.blockNumber).catch(() => null);
4358
+ const args = log.args;
4359
+ return {
4360
+ epochId: walletEpochIdFromLog(log),
4361
+ lifecycleStatus: "active",
4362
+ joinedAtTxHash: log.transactionHash,
4363
+ joinedAtBlockNumber: log.blockNumber,
4364
+ joinedAtLogIndex: log.index ?? log.logIndex ?? null,
4365
+ joinedAtBlockTimestamp: block?.timestamp ?? Number(args.joinedAt ?? 0) ?? null,
4366
+ joinedAtBlockTimestampIso: block?.timestamp
4367
+ ? new Date(Number(block.timestamp) * 1000).toISOString()
4368
+ : Number(args.joinedAt ?? 0) > 0
4369
+ ? new Date(Number(args.joinedAt) * 1000).toISOString()
4370
+ : null,
4371
+ exitedAtTxHash: null,
4372
+ exitedAtBlockNumber: null,
4373
+ exitedAtLogIndex: null,
4374
+ exitedAtBlockTimestamp: null,
4375
+ exitedAtBlockTimestampIso: null,
4376
+ l2Address: getAddress(args.l2Address),
4377
+ channelTokenVaultKey: normalizeBytes32Hex(args.channelTokenVaultKey),
4378
+ leafIndex: args.leafIndex,
4379
+ noteReceivePubKey: {
4380
+ x: normalizeBytes32Hex(args.noteReceivePubKeyX),
4381
+ yParity: Number(args.noteReceivePubKeyYParity),
4382
+ },
4383
+ };
4384
+ }
4385
+
4386
+ async function walletExitFromLog({ log, provider }) {
4387
+ const block = await provider.getBlock(log.blockNumber).catch(() => null);
4388
+ return {
4389
+ exitedAtTxHash: log.transactionHash,
4390
+ exitedAtBlockNumber: log.blockNumber,
4391
+ exitedAtLogIndex: log.index ?? log.logIndex ?? null,
4392
+ exitedAtBlockTimestamp: block?.timestamp ?? null,
4393
+ exitedAtBlockTimestampIso: block?.timestamp ? new Date(Number(block.timestamp) * 1000).toISOString() : null,
4394
+ leafIndex: log.args.leafIndex,
4395
+ };
4396
+ }
4397
+
4398
+ function walletEpochIdFromLog(log) {
4399
+ return `join-${String(log.transactionHash).toLowerCase()}-${Number(log.index ?? log.logIndex ?? 0)}`;
4400
+ }
4401
+
4402
+ function compareLogPosition(left, right) {
4403
+ return Number(left.exitedAtBlockNumber ?? left.blockNumber ?? 0) - Number(right.joinedAtBlockNumber ?? right.blockNumber ?? 0)
4404
+ || Number((left.exitedAtLogIndex ?? left.index ?? left.logIndex) ?? 0)
4405
+ - Number((right.joinedAtLogIndex ?? right.index ?? right.logIndex) ?? 0);
4406
+ }
4407
+
4408
+ function compareWalletEpochs(left, right) {
4409
+ return Number(left.joinedAtBlockNumber ?? 0) - Number(right.joinedAtBlockNumber ?? 0)
4410
+ || Number(left.joinedAtLogIndex ?? 0) - Number(right.joinedAtLogIndex ?? 0)
4411
+ || String(left.epochId).localeCompare(String(right.epochId));
4412
+ }
4413
+
4205
4414
  async function handleWalletGetChannelFund({ args, provider }) {
4206
4415
  const { wallet, walletMetadata } = loadUnlockedWalletWithMetadata(args);
4207
4416
  const {
@@ -4311,6 +4520,14 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4311
4520
  { nonce: nextNonce++ },
4312
4521
  ),
4313
4522
  );
4523
+ const registered = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
4524
+ const lifecycleEpoch = await resolveWalletLifecycleEpoch({
4525
+ context,
4526
+ provider,
4527
+ l1Address: signer.address,
4528
+ registration: registered,
4529
+ });
4530
+ expect(lifecycleEpoch, "Unable to resolve the channel join epoch from emitted registration logs.");
4314
4531
  await refreshPersistedWorkspaceAfterLocalTransaction({
4315
4532
  context,
4316
4533
  provider,
@@ -4327,6 +4544,7 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4327
4544
  storageKey,
4328
4545
  leafIndex,
4329
4546
  noteReceiveKeyMaterial,
4547
+ lifecycleEpoch,
4330
4548
  rpcUrl,
4331
4549
  });
4332
4550
 
@@ -4343,6 +4561,10 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4343
4561
  l2Address: l2Identity.l2Address,
4344
4562
  l2StorageKey: storageKey,
4345
4563
  leafIndex: leafIndex.toString(),
4564
+ epochId: lifecycleEpoch.epochId,
4565
+ lifecycleStatus: lifecycleEpoch.lifecycleStatus,
4566
+ joinedAtTxHash: lifecycleEpoch.joinedAtTxHash,
4567
+ joinedAtBlockNumber: lifecycleEpoch.joinedAtBlockNumber,
4346
4568
  joinTollBaseUnits: joinToll.toString(),
4347
4569
  joinTollTokens: ethers.formatUnits(joinToll, Number(context.workspace.canonicalAssetDecimals)),
4348
4570
  noteReceivePubKey: noteReceiveKeyMaterial.noteReceivePubKey,
@@ -4359,6 +4581,7 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4359
4581
 
4360
4582
  async function handleExitChannel({ args, provider }) {
4361
4583
  const { wallet: walletContext, walletMetadata } = loadUnlockedWalletWithMetadata(args);
4584
+ requireActiveWalletLifecycle(walletContext, "channel exit");
4362
4585
  const { signer, context, channelFund, contextResult } = await loadWalletChannelFundState({
4363
4586
  walletContext,
4364
4587
  provider,
@@ -4378,7 +4601,11 @@ async function handleExitChannel({ args, provider }) {
4378
4601
  const receipt = await waitForReceipt(
4379
4602
  await context.bridgeTokenVault.connect(ownerSigner).exitChannel(ethers.toBigInt(context.workspace.channelId)),
4380
4603
  );
4381
- const cleanup = removeLocalWalletArtifacts(walletContext.walletName, walletMetadata.network);
4604
+ const lifecycleEpoch = await markWalletEpochExited({
4605
+ walletContext,
4606
+ receipt,
4607
+ provider,
4608
+ });
4382
4609
 
4383
4610
  printJson({
4384
4611
  action: "channel exit",
@@ -4396,8 +4623,12 @@ async function handleExitChannel({ args, provider }) {
4396
4623
  gasUsed: receiptGasUsed(receipt),
4397
4624
  txUrl: explorerTxUrl(network, receipt.hash),
4398
4625
  receipt: sanitizeReceipt(receipt),
4399
- removedWalletDir: cleanup.removedWalletDir ? cleanup.walletDir : null,
4400
- removedWalletSecretFile: cleanup.removedWalletSecret ? cleanup.walletSecretFile : null,
4626
+ epochId: lifecycleEpoch.epochId,
4627
+ lifecycleStatus: lifecycleEpoch.lifecycleStatus,
4628
+ exitedAtTxHash: lifecycleEpoch.exitedAtTxHash,
4629
+ exitedAtBlockNumber: lifecycleEpoch.exitedAtBlockNumber,
4630
+ exitedAtBlockTimestampIso: lifecycleEpoch.exitedAtBlockTimestampIso,
4631
+ archivedWalletDir: walletContext.walletDir,
4401
4632
  });
4402
4633
  }
4403
4634
 
@@ -4409,6 +4640,7 @@ async function handleGrothVaultMove({ args, provider, direction }) {
4409
4640
  : "wallet withdraw-channel";
4410
4641
  emitProgress(operationName, "loading");
4411
4642
  const { wallet: walletContext } = loadUnlockedWalletWithMetadata(args);
4643
+ requireActiveWalletLifecycle(walletContext, operationName);
4412
4644
  const contextResult = await loadFreshWalletChannelContext({
4413
4645
  walletContext,
4414
4646
  provider,
@@ -4634,6 +4866,7 @@ function resolveFunctionMetadataProofForExecution({
4634
4866
 
4635
4867
  async function handleMintNotes({ args, provider }) {
4636
4868
  const { wallet } = loadUnlockedWalletWithMetadata(args);
4869
+ requireActiveWalletLifecycle(wallet, "wallet mint-notes");
4637
4870
  requireWalletSpendingCapability(wallet);
4638
4871
  const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
4639
4872
  const amountInputs = parseAmountVector(requireArg(args.amounts, "--amounts"), {
@@ -4722,6 +4955,7 @@ async function handleMintNotes({ args, provider }) {
4722
4955
 
4723
4956
  async function handleRedeemNotes({ args, provider }) {
4724
4957
  const { wallet } = loadUnlockedWalletWithMetadata(args);
4958
+ requireActiveWalletLifecycle(wallet, "wallet redeem-notes");
4725
4959
  requireWalletViewingCapability(wallet);
4726
4960
  requireWalletSpendingCapability(wallet);
4727
4961
  const noteIds = parseNoteIdVector(requireArg(args.noteIds, "--note-ids"));
@@ -4890,24 +5124,39 @@ async function exportWalletGetNotesEvidenceBundle({
4890
5124
  const outputPath = path.resolve(String(requireArg(args.exportEvidence, "--export-evidence")));
4891
5125
  ensureDir(path.dirname(outputPath));
4892
5126
 
4893
- const notes = [
4894
- ...unusedTrackedNotes.map(normalizeTrackedNote),
4895
- ...spentTrackedNotes.map(normalizeTrackedNote),
4896
- ].sort((left, right) => left.commitment.localeCompare(right.commitment));
4897
-
4898
- for (const note of notes) {
4899
- validateEvidenceNotePlaintext(note, walletContext.wallet);
5127
+ const evidenceWalletContexts = loadWalletEpochContextsForEvidence({
5128
+ baseWalletContext: walletContext,
5129
+ networkName: walletMetadata.network,
5130
+ });
5131
+ const noteInputs = [];
5132
+ for (const candidateWalletContext of evidenceWalletContexts) {
5133
+ const notes = candidateWalletContext === walletContext
5134
+ ? [
5135
+ ...unusedTrackedNotes.map(normalizeTrackedNote),
5136
+ ...spentTrackedNotes.map(normalizeTrackedNote),
5137
+ ]
5138
+ : [
5139
+ ...Object.values(candidateWalletContext.wallet.notes.unused).map(normalizeTrackedNote),
5140
+ ...Object.values(candidateWalletContext.wallet.notes.spent).map(normalizeTrackedNote),
5141
+ ];
5142
+ for (const note of notes) {
5143
+ validateEvidenceNotePlaintext(note, candidateWalletContext.wallet);
5144
+ noteInputs.push({ note, walletContext: candidateWalletContext });
5145
+ }
4900
5146
  }
5147
+ noteInputs.sort((left, right) =>
5148
+ String(left.walletContext.wallet.walletEpochId ?? "").localeCompare(String(right.walletContext.wallet.walletEpochId ?? ""))
5149
+ || left.note.commitment.localeCompare(right.note.commitment));
4901
5150
 
4902
5151
  const txHashes = uniqueNonNull([
4903
- ...notes.map((note) => note.createdAtTxHash),
4904
- ...notes.map((note) => note.spentAtTxHash),
5152
+ ...noteInputs.map(({ note }) => note.createdAtTxHash),
5153
+ ...noteInputs.map(({ note }) => note.spentAtTxHash),
4905
5154
  ]);
4906
5155
  const transactionEvidence = await buildTransactionEvidenceMap({ provider, txHashes });
4907
5156
  const blockTimestampCache = buildBlockTimestampCache(transactionEvidence);
4908
- const noteRecords = notes.map((note) => buildEvidenceNoteRecord({
5157
+ const noteRecords = noteInputs.map(({ note, walletContext: noteWalletContext }) => buildEvidenceNoteRecord({
4909
5158
  note,
4910
- walletContext,
5159
+ walletContext: noteWalletContext,
4911
5160
  walletMetadata,
4912
5161
  context,
4913
5162
  transactionEvidence,
@@ -4917,6 +5166,7 @@ async function exportWalletGetNotesEvidenceBundle({
4917
5166
  const manifest = buildEvidenceManifest({
4918
5167
  outputPath,
4919
5168
  walletContext,
5169
+ walletContexts: evidenceWalletContexts,
4920
5170
  walletMetadata,
4921
5171
  context,
4922
5172
  noteRecords,
@@ -4932,7 +5182,7 @@ async function exportWalletGetNotesEvidenceBundle({
4932
5182
  addEvidenceJson(archive, "indexes/by-block-range.json", indexes.byBlockRange);
4933
5183
  addEvidenceJson(archive, "indexes/by-counterparty.json", indexes.byCounterparty);
4934
5184
  for (const record of noteRecords) {
4935
- addEvidenceJson(archive, `notes/${record.derived.commitment}.json`, record);
5185
+ addEvidenceJson(archive, evidenceNotePath(record), record);
4936
5186
  }
4937
5187
  for (const [txHash, txRecord] of Object.entries(transactionEvidence)) {
4938
5188
  addEvidenceJson(archive, `transactions/${txHash}.json`, txRecord.transaction);
@@ -4941,7 +5191,7 @@ async function exportWalletGetNotesEvidenceBundle({
4941
5191
  }
4942
5192
 
4943
5193
  assertEvidenceBundleDoesNotContainSecrets({
4944
- wallet: walletContext.wallet,
5194
+ wallets: evidenceWalletContexts.map((entry) => entry.wallet),
4945
5195
  payload: {
4946
5196
  manifest,
4947
5197
  indexes,
@@ -4958,6 +5208,7 @@ async function exportWalletGetNotesEvidenceBundle({
4958
5208
  format: WALLET_EVIDENCE_BUNDLE_FORMAT,
4959
5209
  formatVersion: WALLET_EVIDENCE_BUNDLE_FORMAT_VERSION,
4960
5210
  noteCount: noteRecords.length,
5211
+ walletEpochCount: evidenceWalletContexts.length,
4961
5212
  transactionCount: txHashes.length,
4962
5213
  containsNotePlaintext: true,
4963
5214
  containsSpendingKey: false,
@@ -4967,6 +5218,33 @@ async function exportWalletGetNotesEvidenceBundle({
4967
5218
  };
4968
5219
  }
4969
5220
 
5221
+ function loadWalletEpochContextsForEvidence({ baseWalletContext, networkName }) {
5222
+ const walletRoot = walletRootPath(baseWalletContext.walletName, networkName);
5223
+ const index = requireWalletIndex({
5224
+ walletRoot,
5225
+ walletName: baseWalletContext.walletName,
5226
+ networkName,
5227
+ });
5228
+ const contexts = [];
5229
+ for (const epoch of index.epochs) {
5230
+ const walletDir = walletEpochPathFromRoot(walletRoot, epoch.epochId);
5231
+ if (!walletConfigExists(walletDir)) {
5232
+ continue;
5233
+ }
5234
+ const context = loadWalletFromDir({
5235
+ walletName: baseWalletContext.walletName,
5236
+ networkName,
5237
+ walletDir,
5238
+ });
5239
+ contexts.push(context);
5240
+ }
5241
+ expect(
5242
+ contexts.length > 0,
5243
+ `Wallet ${baseWalletContext.walletName} on ${networkName} has no readable wallet epochs. Run wallet recover-workspace and then wallet get-notes --export-evidence again.`,
5244
+ );
5245
+ return contexts;
5246
+ }
5247
+
4970
5248
  function validateEvidenceNotePlaintext(note, wallet) {
4971
5249
  expect(
4972
5250
  note.owner && note.value !== null && note.salt && note.encryptedNoteValue,
@@ -5084,6 +5362,7 @@ function buildEvidenceNoteRecord({
5084
5362
  channelName: walletMetadata.channelName,
5085
5363
  channelId: walletContext.wallet.channelId,
5086
5364
  wallet: walletContext.walletName,
5365
+ ...walletLifecycleMetadata(walletContext.wallet),
5087
5366
  walletL1Address: walletContext.wallet.l1Address,
5088
5367
  walletL2Address: walletContext.wallet.l2Address,
5089
5368
  controller: context.workspace.controller,
@@ -5198,7 +5477,7 @@ function buildEvidenceIndexes(noteRecords) {
5198
5477
  },
5199
5478
  };
5200
5479
  for (const record of noteRecords) {
5201
- const pathName = `notes/${record.derived.commitment}.json`;
5480
+ const pathName = evidenceNotePath(record);
5202
5481
  indexes.byCommitment[record.derived.commitment] = pathName;
5203
5482
  indexes.byNullifier[record.derived.nullifier] = pathName;
5204
5483
  pushIndexEntry(indexes.byCreationTx, record.creation.txHash, pathName);
@@ -5227,6 +5506,21 @@ function buildEvidenceIndexes(noteRecords) {
5227
5506
  return indexes;
5228
5507
  }
5229
5508
 
5509
+ function evidenceNotePath(record) {
5510
+ expect(
5511
+ record.walletScope?.canonicalWalletName && record.walletScope?.epochId,
5512
+ "Evidence note path requires the current epoch-aware wallet scope.",
5513
+ );
5514
+ return [
5515
+ "wallets",
5516
+ slugifyPathComponent(record.walletScope.canonicalWalletName),
5517
+ "epochs",
5518
+ slugifyPathComponent(record.walletScope.epochId),
5519
+ "notes",
5520
+ `${record.derived.commitment}.json`,
5521
+ ].join("/");
5522
+ }
5523
+
5230
5524
  function pushIndexEntry(index, key, value) {
5231
5525
  if (!key) {
5232
5526
  return;
@@ -5240,6 +5534,7 @@ function pushIndexEntry(index, key, value) {
5240
5534
  function buildEvidenceManifest({
5241
5535
  outputPath,
5242
5536
  walletContext,
5537
+ walletContexts = [walletContext],
5243
5538
  walletMetadata,
5244
5539
  context,
5245
5540
  noteRecords,
@@ -5258,10 +5553,17 @@ function buildEvidenceManifest({
5258
5553
  wallet: walletContext.walletName,
5259
5554
  walletL1Address: walletContext.wallet.l1Address,
5260
5555
  walletL2Address: walletContext.wallet.l2Address,
5556
+ wallets: walletContexts.map((entry) => ({
5557
+ wallet: entry.walletName,
5558
+ ...walletLifecycleMetadata(entry.wallet),
5559
+ walletL1Address: entry.wallet.l1Address,
5560
+ walletL2Address: entry.wallet.l2Address,
5561
+ })),
5261
5562
  controller: context.workspace.controller,
5262
5563
  channelManager: context.workspace.channelManager,
5263
5564
  bridgeTokenVault: context.workspace.bridgeTokenVault,
5264
5565
  containsAllLocallyKnownNotes: true,
5566
+ containsAllLocalWalletEpochs: true,
5265
5567
  containsNotePlaintext: true,
5266
5568
  noteCount: noteRecords.length,
5267
5569
  transactionCount: txHashes.length,
@@ -5281,12 +5583,13 @@ function addEvidenceJson(archive, archivePath, value) {
5281
5583
  archive.addFile(archivePath, Buffer.from(`${JSON.stringify(normalizeCliOutput(value), null, 2)}\n`, "utf8"));
5282
5584
  }
5283
5585
 
5284
- function assertEvidenceBundleDoesNotContainSecrets({ wallet, payload }) {
5586
+ function assertEvidenceBundleDoesNotContainSecrets({ wallet = null, wallets = null, payload }) {
5285
5587
  const serialized = JSON.stringify(payload);
5286
- const forbiddenValues = [
5287
- wallet.l2PrivateKey,
5288
- wallet.noteReceivePrivateKey,
5289
- ].filter((value) => typeof value === "string" && value.length > 0);
5588
+ const walletList = wallets ?? (wallet ? [wallet] : []);
5589
+ const forbiddenValues = walletList.flatMap((entry) => [
5590
+ entry.l2PrivateKey,
5591
+ entry.noteReceivePrivateKey,
5592
+ ]).filter((value) => typeof value === "string" && value.length > 0);
5290
5593
  for (const value of forbiddenValues) {
5291
5594
  expect(
5292
5595
  !serialized.includes(value),
@@ -5301,6 +5604,7 @@ function uniqueNonNull(values) {
5301
5604
 
5302
5605
  async function handleTransferNotes({ args, provider }) {
5303
5606
  const { wallet } = loadUnlockedWalletWithMetadata(args);
5607
+ requireActiveWalletLifecycle(wallet, "wallet transfer-notes");
5304
5608
  requireWalletViewingCapability(wallet);
5305
5609
  requireWalletSpendingCapability(wallet);
5306
5610
  const { signer } = restoreWalletParticipant(wallet, provider);
@@ -5497,10 +5801,20 @@ function ensureWallet({
5497
5801
  storageKey,
5498
5802
  leafIndex,
5499
5803
  noteReceiveKeyMaterial,
5804
+ lifecycleEpoch,
5500
5805
  rpcUrl,
5501
5806
  }) {
5502
5807
  const walletName = walletNameForChannelAndAddress(channelContext.workspace.channelName, signerAddress);
5503
- const walletDir = walletPath(walletName, channelContext.workspace.network);
5808
+ const walletRoot = walletRootPath(walletName, channelContext.workspace.network);
5809
+ if (fs.existsSync(walletRoot)) {
5810
+ requireWalletIndex({
5811
+ walletRoot,
5812
+ walletName,
5813
+ networkName: channelContext.workspace.network,
5814
+ });
5815
+ }
5816
+ expect(lifecycleEpoch, "Current wallet workspace creation requires an on-chain wallet lifecycle epoch.");
5817
+ const walletDir = walletEpochPath(walletName, channelContext.workspace.network, lifecycleEpoch.epochId);
5504
5818
  expect(!walletConfigExists(walletDir), `Wallet ${walletName} already exists on ${channelContext.workspace.network}.`);
5505
5819
  ensureDir(walletDir);
5506
5820
  ensureDir(path.join(walletDir, "operations"));
@@ -5508,6 +5822,19 @@ function ensureWallet({
5508
5822
  const wallet = normalizeWallet({
5509
5823
  walletFormatVersion: WALLET_WORKSPACE_FORMAT_VERSION,
5510
5824
  name: walletName,
5825
+ canonicalWalletName: walletName,
5826
+ walletEpochId: lifecycleEpoch.epochId,
5827
+ lifecycleStatus: lifecycleEpoch.lifecycleStatus,
5828
+ joinedAtTxHash: lifecycleEpoch.joinedAtTxHash,
5829
+ joinedAtBlockNumber: lifecycleEpoch.joinedAtBlockNumber,
5830
+ joinedAtLogIndex: lifecycleEpoch.joinedAtLogIndex,
5831
+ joinedAtBlockTimestamp: lifecycleEpoch.joinedAtBlockTimestamp,
5832
+ joinedAtBlockTimestampIso: lifecycleEpoch.joinedAtBlockTimestampIso,
5833
+ exitedAtTxHash: lifecycleEpoch.exitedAtTxHash,
5834
+ exitedAtBlockNumber: lifecycleEpoch.exitedAtBlockNumber,
5835
+ exitedAtLogIndex: lifecycleEpoch.exitedAtLogIndex,
5836
+ exitedAtBlockTimestamp: lifecycleEpoch.exitedAtBlockTimestamp,
5837
+ exitedAtBlockTimestampIso: lifecycleEpoch.exitedAtBlockTimestampIso,
5511
5838
  network: channelContext.workspace.network,
5512
5839
  rpcUrl,
5513
5840
  chainId: channelContext.workspace.chainId,
@@ -5553,18 +5880,57 @@ function ensureWallet({
5553
5880
  };
5554
5881
  persistWalletKeys(context);
5555
5882
  persistWallet(context);
5556
- persistWalletMetadata(context);
5883
+ persistWalletIndexForContext(context);
5557
5884
  return context;
5558
5885
  }
5559
5886
 
5887
+ function applyWalletLifecycleEpoch(wallet, epoch) {
5888
+ wallet.canonicalWalletName = wallet.name;
5889
+ wallet.walletEpochId = epoch.epochId;
5890
+ wallet.lifecycleStatus = epoch.lifecycleStatus;
5891
+ wallet.joinedAtTxHash = epoch.joinedAtTxHash;
5892
+ wallet.joinedAtBlockNumber = epoch.joinedAtBlockNumber;
5893
+ wallet.joinedAtLogIndex = epoch.joinedAtLogIndex;
5894
+ wallet.joinedAtBlockTimestamp = epoch.joinedAtBlockTimestamp;
5895
+ wallet.joinedAtBlockTimestampIso = epoch.joinedAtBlockTimestampIso;
5896
+ wallet.exitedAtTxHash = epoch.exitedAtTxHash;
5897
+ wallet.exitedAtBlockNumber = epoch.exitedAtBlockNumber;
5898
+ wallet.exitedAtLogIndex = epoch.exitedAtLogIndex;
5899
+ wallet.exitedAtBlockTimestamp = epoch.exitedAtBlockTimestamp;
5900
+ wallet.exitedAtBlockTimestampIso = epoch.exitedAtBlockTimestampIso;
5901
+ }
5902
+
5903
+ function walletLifecycleMetadata(wallet) {
5904
+ expect(wallet.walletEpochId, "Current wallet workspace metadata is missing walletEpochId.");
5905
+ return {
5906
+ canonicalWalletName: wallet.canonicalWalletName ?? wallet.name,
5907
+ epochId: wallet.walletEpochId,
5908
+ lifecycleStatus: wallet.lifecycleStatus ?? "active",
5909
+ joinedAtTxHash: wallet.joinedAtTxHash ?? null,
5910
+ joinedAtBlockNumber: wallet.joinedAtBlockNumber ?? null,
5911
+ joinedAtLogIndex: wallet.joinedAtLogIndex ?? null,
5912
+ joinedAtBlockTimestamp: wallet.joinedAtBlockTimestamp ?? null,
5913
+ joinedAtBlockTimestampIso: wallet.joinedAtBlockTimestampIso ?? null,
5914
+ exitedAtTxHash: wallet.exitedAtTxHash ?? null,
5915
+ exitedAtBlockNumber: wallet.exitedAtBlockNumber ?? null,
5916
+ exitedAtLogIndex: wallet.exitedAtLogIndex ?? null,
5917
+ exitedAtBlockTimestamp: wallet.exitedAtBlockTimestamp ?? null,
5918
+ exitedAtBlockTimestampIso: wallet.exitedAtBlockTimestampIso ?? null,
5919
+ };
5920
+ }
5921
+
5560
5922
  function normalizeWallet(wallet) {
5561
5923
  assertWalletHasCurrentFormat(wallet, wallet.name ?? "unknown");
5924
+ expect(wallet.walletEpochId, "Current wallet metadata requires walletEpochId. Run wallet recover-workspace to rebuild this wallet.");
5562
5925
  const unusedNotes = Object.values(wallet.notes.unused).map(normalizeTrackedNote);
5563
5926
  unusedNotes.sort(compareNotesByValueDesc);
5564
5927
  const spentNotes = Object.values(wallet.notes.spent).map(normalizeTrackedNote);
5565
5928
 
5566
5929
  return {
5567
5930
  ...wallet,
5931
+ canonicalWalletName: wallet.canonicalWalletName ?? wallet.name,
5932
+ walletEpochId: wallet.walletEpochId,
5933
+ lifecycleStatus: wallet.lifecycleStatus ?? "active",
5568
5934
  canonicalAssetDecimals: Number(wallet.canonicalAssetDecimals),
5569
5935
  l2Nonce: Number(wallet.l2Nonce),
5570
5936
  l2PrivateKey: wallet.l2PrivateKey ? ethers.hexlify(wallet.l2PrivateKey) : null,
@@ -5824,10 +6190,16 @@ async function recoverDeliveredNotesFromEventLogs({
5824
6190
  };
5825
6191
 
5826
6192
  if (scanStartBlock > latestBlock) {
6193
+ const reconciledState = await reconcileWalletNotesWithBridgeState({
6194
+ walletContext,
6195
+ currentSnapshot: context.currentSnapshot,
6196
+ controllerAddress: context.workspace.controller,
6197
+ });
5827
6198
  walletContext.wallet.noteReceiveLastScannedBlock = latestBlock + 1;
5828
6199
  persistWallet(walletContext);
5829
6200
  return {
5830
6201
  importedNotes: [],
6202
+ reconciledState,
5831
6203
  scannedLogs: 0,
5832
6204
  scanRange,
5833
6205
  };
@@ -5838,18 +6210,67 @@ async function recoverDeliveredNotesFromEventLogs({
5838
6210
  );
5839
6211
  const commitmentExistsSlot = ethers.toBigInt(findStorageSlot(storageLayoutManifest, "PrivateStateController", "commitmentExists"));
5840
6212
  const nullifierUsedSlot = ethers.toBigInt(findStorageSlot(storageLayoutManifest, "PrivateStateController", "nullifierUsed"));
5841
- const observedLogs = await fetchLogsChunked(provider, {
6213
+ const importedNotes = [];
6214
+ let reconciledState = null;
6215
+ let scannedLogs = 0;
6216
+
6217
+ await fetchLogsChunked(provider, {
5842
6218
  address: context.workspace.channelManager,
5843
6219
  topics: [NOTE_VALUE_ENCRYPTED_TOPIC],
5844
6220
  fromBlock: scanStartBlock,
5845
6221
  toBlock: latestBlock,
6222
+ collectLogs: false,
5846
6223
  onProgress: progressAction
5847
6224
  ? createRpcLogScanProgress({ action: progressAction, label: "note-delivery events" })
5848
6225
  : null,
6226
+ onChunk: async ({ logs, chunkToBlock }) => {
6227
+ scannedLogs += logs.length;
6228
+ const importedCandidates = await recoverDeliveredNoteCandidatesFromLogs({
6229
+ logs,
6230
+ walletContext,
6231
+ context,
6232
+ noteReceivePrivateKey,
6233
+ commitmentExistsSlot,
6234
+ nullifierUsedSlot,
6235
+ });
6236
+ if (importedCandidates.length > 0) {
6237
+ importedNotes.push(...mergeTrackedNotesIntoWallet(walletContext, importedCandidates));
6238
+ reconciledState = await reconcileWalletNotesWithBridgeState({
6239
+ walletContext,
6240
+ currentSnapshot: context.currentSnapshot,
6241
+ controllerAddress: context.workspace.controller,
6242
+ });
6243
+ }
6244
+ walletContext.wallet.noteReceiveLastScannedBlock = Number(chunkToBlock) + 1;
6245
+ persistWallet(walletContext);
6246
+ },
5849
6247
  });
5850
6248
 
6249
+ reconciledState = await reconcileWalletNotesWithBridgeState({
6250
+ walletContext,
6251
+ currentSnapshot: context.currentSnapshot,
6252
+ controllerAddress: context.workspace.controller,
6253
+ });
6254
+ walletContext.wallet.noteReceiveLastScannedBlock = latestBlock + 1;
6255
+ persistWallet(walletContext);
6256
+ return {
6257
+ importedNotes,
6258
+ reconciledState,
6259
+ scannedLogs,
6260
+ scanRange,
6261
+ };
6262
+ }
6263
+
6264
+ async function recoverDeliveredNoteCandidatesFromLogs({
6265
+ logs,
6266
+ walletContext,
6267
+ context,
6268
+ noteReceivePrivateKey,
6269
+ commitmentExistsSlot,
6270
+ nullifierUsedSlot,
6271
+ }) {
5851
6272
  const importedCandidates = [];
5852
- for (const log of observedLogs) {
6273
+ for (const log of logs) {
5853
6274
  const encryptedNoteValue = extractEncryptedNoteValueFromBridgeLog(log);
5854
6275
  if (!encryptedNoteValue) {
5855
6276
  continue;
@@ -5914,21 +6335,7 @@ async function recoverDeliveredNotesFromEventLogs({
5914
6335
  }
5915
6336
  importedCandidates.push(trackedNote);
5916
6337
  }
5917
-
5918
- const importedNotes = mergeTrackedNotesIntoWallet(walletContext, importedCandidates);
5919
- const reconciledState = await reconcileWalletNotesWithBridgeState({
5920
- walletContext,
5921
- currentSnapshot: context.currentSnapshot,
5922
- controllerAddress: context.workspace.controller,
5923
- });
5924
- walletContext.wallet.noteReceiveLastScannedBlock = latestBlock + 1;
5925
- persistWallet(walletContext);
5926
- return {
5927
- importedNotes,
5928
- reconciledState,
5929
- scannedLogs: observedLogs.length,
5930
- scanRange,
5931
- };
6338
+ return importedCandidates;
5932
6339
  }
5933
6340
 
5934
6341
  function requireUsableWalletNoteReceiveRecoveryIndex({ walletContext, context, latestBlock }) {
@@ -5942,7 +6349,7 @@ function requireUsableWalletNoteReceiveRecoveryIndex({ walletContext, context, l
5942
6349
  throw new Error([
5943
6350
  `Wallet note recovery index is missing or unusable for wallet ${walletContext.walletName}.`,
5944
6351
  `Expected noteReceiveLastScannedBlock to be an integer between ${genesisBlockNumber} and ${Number(latestBlock) + 1}.`,
5945
- "Run wallet recover-workspace --from-genesis to rebuild wallet note state from channel genesis.",
6352
+ "Run wallet recover-workspace --from-genesis to restart received-note scanning from channel genesis.",
5946
6353
  ].join(" "));
5947
6354
  }
5948
6355
  return nextBlock;
@@ -5988,7 +6395,7 @@ async function ensureWalletNoteReceiveStateCurrent({
5988
6395
  throw new Error([
5989
6396
  `Wallet note recovery index is missing or unusable for wallet ${walletContext.walletName}.`,
5990
6397
  "Automatic wallet recovery uses only the saved note recovery index and will not replay from genesis.",
5991
- `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if a genesis rebuild is required.`,
6398
+ `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if received-note scanning must restart from channel genesis.`,
5992
6399
  `Details: ${indexError.message}`,
5993
6400
  ].join(" "));
5994
6401
  }
@@ -6026,7 +6433,7 @@ async function ensureWalletNoteReceiveStateCurrent({
6026
6433
  throw new Error([
6027
6434
  `Wallet workspace is not current for wallet ${walletContext.walletName}.`,
6028
6435
  "Automatic wallet recovery uses only the saved note recovery index and will not replay from genesis.",
6029
- `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if a genesis rebuild is required.`,
6436
+ `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if received-note scanning must restart from channel genesis.`,
6030
6437
  `Details: ${recoveryError.message}`,
6031
6438
  ].join(" "));
6032
6439
  }
@@ -6042,7 +6449,7 @@ async function ensureWalletNoteReceiveStateCurrent({
6042
6449
  throw new Error([
6043
6450
  `Wallet workspace is still stale after recovery-index sync for wallet ${walletContext.walletName}.`,
6044
6451
  "Automatic wallet recovery will not replay from genesis.",
6045
- `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if a genesis rebuild is required.`,
6452
+ `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if received-note scanning must restart from channel genesis.`,
6046
6453
  `Details: ${postRecoveryError.message}`,
6047
6454
  ].join(" "));
6048
6455
  }
@@ -6662,7 +7069,7 @@ async function recoverChannelWorkspaceFromIndexOnly({
6662
7069
  throw new Error([
6663
7070
  `Channel workspace is not current for ${channelName} on ${networkName}.`,
6664
7071
  "Automatic channel workspace recovery uses only the saved recovery index and will not replay from genesis.",
6665
- `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} --source rpc --from-genesis if a genesis rebuild is required.`,
7072
+ `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} first.`,
6666
7073
  `Details: ${recoveryError.message}`,
6667
7074
  cause ? `Initial freshness failure: ${cause.message}` : null,
6668
7075
  ].filter(Boolean).join(" "));
@@ -6675,7 +7082,7 @@ async function recoverChannelWorkspaceFromIndexOnly({
6675
7082
  throw new Error([
6676
7083
  `Channel workspace is still stale after recovery-index sync for ${channelName} on ${networkName}.`,
6677
7084
  "Automatic channel workspace recovery will not replay from genesis.",
6678
- `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} --source rpc --from-genesis if a genesis rebuild is required.`,
7085
+ `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} first.`,
6679
7086
  `Details: ${postRecoveryError.message}`,
6680
7087
  cause ? `Initial freshness failure: ${cause.message}` : null,
6681
7088
  ].filter(Boolean).join(" "));
@@ -6699,7 +7106,7 @@ async function requireChannelWorkspaceRecoveryIndexForAutoRefresh({
6699
7106
  throw new Error([
6700
7107
  message,
6701
7108
  "Automatic channel workspace recovery uses only the saved recovery index and will not replay from genesis.",
6702
- `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} --source rpc --from-genesis if a genesis rebuild is required.`,
7109
+ `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} first.`,
6703
7110
  cause ? `Initial freshness failure: ${cause.message}` : null,
6704
7111
  ].filter(Boolean).join(" "));
6705
7112
  };
@@ -7062,7 +7469,17 @@ async function loadJoinChannelContext({ args, network, provider }) {
7062
7469
  function loadWallet(walletName, networkName) {
7063
7470
  const normalizedWalletName = requireWalletName({ wallet: walletName });
7064
7471
  const normalizedNetworkName = requireNetworkName({ network: networkName });
7065
- const walletDir = walletPath(normalizedWalletName, normalizedNetworkName);
7472
+ const walletDir = selectedWalletEpochDir(normalizedWalletName, normalizedNetworkName);
7473
+ return loadWalletFromDir({
7474
+ walletName: normalizedWalletName,
7475
+ networkName: normalizedNetworkName,
7476
+ walletDir,
7477
+ });
7478
+ }
7479
+
7480
+ function loadWalletFromDir({ walletName, networkName, walletDir }) {
7481
+ const normalizedWalletName = requireWalletName({ wallet: walletName });
7482
+ const normalizedNetworkName = requireNetworkName({ network: networkName });
7066
7483
  if (!walletConfigExists(walletDir)) {
7067
7484
  throw cliError(CLI_ERROR_CODES.UNKNOWN_WALLET, `Unknown wallet: ${normalizedWalletName} on ${normalizedNetworkName}.`);
7068
7485
  }
@@ -7077,11 +7494,11 @@ function loadWallet(walletName, networkName) {
7077
7494
  walletName: normalizedWalletName,
7078
7495
  keyKind: "viewing",
7079
7496
  });
7080
- if (spendingKey) {
7497
+ if (spendingKey && walletSpendingKeyMatchesWallet(spendingKey.metadata, rawWallet)) {
7081
7498
  rawWallet.l2PrivateKey = spendingKey.privateKey;
7082
7499
  rawWallet.l2PublicKey = spendingKey.metadata?.l2PublicKey ?? rawWallet.l2PublicKey;
7083
7500
  }
7084
- if (viewingKey) {
7501
+ if (viewingKey && walletViewingKeyMatchesWallet(viewingKey.metadata, rawWallet)) {
7085
7502
  rawWallet.noteReceivePrivateKey = viewingKey.privateKey;
7086
7503
  }
7087
7504
  assertWalletHasRequiredKeys(rawWallet, normalizedWalletName);
@@ -7104,6 +7521,18 @@ function loadWallet(walletName, networkName) {
7104
7521
  return context;
7105
7522
  }
7106
7523
 
7524
+ function walletSpendingKeyMatchesWallet(metadata, wallet) {
7525
+ return metadata?.l2Address
7526
+ && ethers.toBigInt(getAddress(metadata.l2Address)) === ethers.toBigInt(getAddress(wallet.l2Address));
7527
+ }
7528
+
7529
+ function walletViewingKeyMatchesWallet(metadata, wallet) {
7530
+ return metadata?.noteReceivePubKey?.x
7531
+ && ethers.toBigInt(normalizeBytes32Hex(metadata.noteReceivePubKey.x))
7532
+ === ethers.toBigInt(normalizeBytes32Hex(wallet.noteReceivePubKeyX))
7533
+ && Number(metadata.noteReceivePubKey.yParity) === Number(wallet.noteReceivePubKeyYParity);
7534
+ }
7535
+
7107
7536
  function loadUnlockedWalletWithMetadata(args) {
7108
7537
  const networkName = requireNetworkName(args);
7109
7538
  const wallet = loadWallet(requireWalletName(args), networkName);
@@ -7274,6 +7703,16 @@ function requireWalletViewingCapability(walletContext) {
7274
7703
  );
7275
7704
  }
7276
7705
 
7706
+ function requireActiveWalletLifecycle(walletContext, commandName) {
7707
+ expect(
7708
+ walletContext.wallet.lifecycleStatus !== "exited",
7709
+ [
7710
+ `${commandName} cannot operate on exited wallet epoch ${walletContext.wallet.walletEpochId ?? "unknown"}.`,
7711
+ "Exited wallet epochs are read-only. Use wallet get-notes or wallet get-notes --export-evidence for historical disclosure.",
7712
+ ].join(" "),
7713
+ );
7714
+ }
7715
+
7277
7716
  function walletOperationSealSecret(walletContext) {
7278
7717
  const secret = walletContext.wallet.l2PrivateKey
7279
7718
  ?? walletContext.wallet.noteReceivePrivateKey
@@ -7328,7 +7767,7 @@ function loadBridgeResources({ chainId }) {
7328
7767
  function loadWalletMetadata(walletName, networkName) {
7329
7768
  const normalizedWalletName = requireWalletName({ wallet: walletName });
7330
7769
  const normalizedNetworkName = requireNetworkName({ network: networkName });
7331
- const walletDir = walletPath(normalizedWalletName, normalizedNetworkName);
7770
+ const walletDir = selectedWalletEpochDir(normalizedWalletName, normalizedNetworkName);
7332
7771
  if (!walletConfigExists(walletDir)) {
7333
7772
  throw cliError(CLI_ERROR_CODES.UNKNOWN_WALLET, `Unknown wallet: ${normalizedWalletName} on ${normalizedNetworkName}.`);
7334
7773
  }
@@ -7677,9 +8116,9 @@ async function buildGrothTransition({ operationDir, workspace, stateManager, vau
7677
8116
  update: {
7678
8117
  currentRootVector: normalizedRootVector(currentSnapshot.stateRoots),
7679
8118
  updatedRoot: bigintToHex32(updatedRoot),
7680
- currentUserKey: bytes32FromHex(keyHex),
8119
+ currentUserKey: normalizeBytes32Hex(keyHex),
7681
8120
  currentUserValue: currentValue,
7682
- updatedUserKey: bytes32FromHex(keyHex),
8121
+ updatedUserKey: normalizeBytes32Hex(keyHex),
7683
8122
  updatedUserValue: nextValue,
7684
8123
  },
7685
8124
  nextSnapshot,
@@ -7870,10 +8309,6 @@ function normalizeBytes12Hex(value) {
7870
8309
  return normalizeBytesHex(value, 12);
7871
8310
  }
7872
8311
 
7873
- function normalizeBytes16Hex(value) {
7874
- return normalizeBytesHex(value, 16);
7875
- }
7876
-
7877
8312
  function hashTokamakPublicInputs(values) {
7878
8313
  return keccak256(abiCoder.encode(["uint256[]"], [values]));
7879
8314
  }
@@ -8001,6 +8436,67 @@ async function fetchChannelRecoveryLogs({
8001
8436
  };
8002
8437
  }
8003
8438
 
8439
+ async function fetchChannelRecoveryEventGroupsChunked({
8440
+ provider,
8441
+ bridgeAbiManifest,
8442
+ channelInfo,
8443
+ channelManager = null,
8444
+ fromBlock,
8445
+ toBlock,
8446
+ progressAction = null,
8447
+ onChunk,
8448
+ }) {
8449
+ const resolvedChannelManager = channelManager ?? new Contract(
8450
+ channelInfo.manager,
8451
+ bridgeAbiManifest.contracts.channelManager.abi,
8452
+ provider,
8453
+ );
8454
+ const currentRootVectorObservedTopic =
8455
+ normalizeBytes32Hex(resolvedChannelManager.interface.getEvent("CurrentRootVectorObserved").topicHash);
8456
+ const bridgeTokenVault = new Contract(
8457
+ channelInfo.bridgeTokenVault,
8458
+ bridgeAbiManifest.contracts.bridgeTokenVault.abi,
8459
+ provider,
8460
+ );
8461
+ const bridgeVaultTopic = bridgeTokenVault.interface.getEvent("StorageWriteObserved").topicHash;
8462
+
8463
+ await fetchLogsChunked(provider, {
8464
+ address: channelInfo.manager,
8465
+ topics: [[
8466
+ currentRootVectorObservedTopic,
8467
+ CONTROLLER_STORAGE_KEY_OBSERVED_TOPIC,
8468
+ VAULT_STORAGE_WRITE_OBSERVED_TOPIC,
8469
+ ]],
8470
+ fromBlock,
8471
+ toBlock,
8472
+ collectLogs: false,
8473
+ onProgress: progressAction
8474
+ ? createRpcLogScanProgress({ action: progressAction, label: "channel-recovery chunks" })
8475
+ : null,
8476
+ onChunk: async ({ logs: channelManagerLogs, chunkFromBlock, chunkToBlock }) => {
8477
+ await throttleLogRequest();
8478
+ const bridgeVaultLogs = await provider.getLogs({
8479
+ address: channelInfo.bridgeTokenVault,
8480
+ topics: [bridgeVaultTopic],
8481
+ fromBlock: chunkFromBlock,
8482
+ toBlock: chunkToBlock,
8483
+ });
8484
+ const groupedValues = normalizeWorkspaceMirrorDeltaEventGroups({
8485
+ logs: [...channelManagerLogs, ...bridgeVaultLogs],
8486
+ channelInfo,
8487
+ bridgeAbiManifest,
8488
+ fromBlock: chunkFromBlock,
8489
+ toBlock: chunkToBlock,
8490
+ });
8491
+ await onChunk?.({
8492
+ groupedValues,
8493
+ chunkFromBlock,
8494
+ chunkToBlock,
8495
+ });
8496
+ },
8497
+ });
8498
+ }
8499
+
8004
8500
  async function reconstructChannelSnapshot({
8005
8501
  provider,
8006
8502
  bridgeAbiManifest,
@@ -8018,6 +8514,7 @@ async function reconstructChannelSnapshot({
8018
8514
  fromBlock = genesisBlockNumber,
8019
8515
  toBlock = null,
8020
8516
  progressAction = null,
8517
+ onCheckpoint = null,
8021
8518
  }) {
8022
8519
  let startingSnapshot = baseSnapshot;
8023
8520
  if (!startingSnapshot) {
@@ -8033,11 +8530,20 @@ async function reconstructChannelSnapshot({
8033
8530
 
8034
8531
  const latestBlock = toBlock === null ? await provider.getBlockNumber() : Number(toBlock);
8035
8532
  const scanFromBlock = Math.max(Number(genesisBlockNumber), Number(fromBlock));
8036
- const {
8037
- currentRootVectorObservedTopic,
8038
- channelManagerLogs,
8039
- bridgeVaultLogs,
8040
- } = await fetchChannelRecoveryLogs({
8533
+ let currentSnapshot = startingSnapshot;
8534
+ if (onCheckpoint && scanFromBlock <= latestBlock) {
8535
+ await onCheckpoint({
8536
+ currentSnapshot,
8537
+ scanRange: {
8538
+ fromBlock: scanFromBlock,
8539
+ toBlock: scanFromBlock - 1,
8540
+ mode: baseSnapshot ? "recovery-index-initial" : "genesis-initial",
8541
+ },
8542
+ });
8543
+ }
8544
+ const stateManager = await buildStateManager(currentSnapshot, contractCodes);
8545
+
8546
+ await fetchChannelRecoveryEventGroupsChunked({
8041
8547
  provider,
8042
8548
  bridgeAbiManifest,
8043
8549
  channelInfo,
@@ -8045,37 +8551,27 @@ async function reconstructChannelSnapshot({
8045
8551
  fromBlock: scanFromBlock,
8046
8552
  toBlock: latestBlock,
8047
8553
  progressAction,
8048
- });
8049
- const channelManagerEvents = channelManagerLogs.map((log) => {
8050
- const topic0 = log.topics[0] ? normalizeBytes32Hex(log.topics[0]) : null;
8051
- if (topic0 !== null && ethers.toBigInt(topic0) === ethers.toBigInt(currentRootVectorObservedTopic)) {
8052
- const parsed = channelManager.interface.parseLog(log);
8053
- return {
8054
- ...log,
8055
- args: parsed.args,
8056
- fragment: parsed.fragment,
8057
- };
8058
- }
8059
- return log;
8060
- });
8061
-
8062
- const groupedEvents = new Map();
8063
- for (const event of [...channelManagerEvents, ...bridgeVaultLogs]) {
8064
- const key = event.transactionHash;
8065
- const group = groupedEvents.get(key) ?? [];
8066
- group.push(event);
8067
- groupedEvents.set(key, group);
8068
- }
8069
-
8070
- const groupedValues = [...groupedEvents.values()].sort((left, right) => compareLogsByPosition(left[0], right[0]));
8071
- const currentSnapshot = await applyChannelRecoveryEventGroups({
8072
- startingSnapshot,
8073
- groupedValues,
8074
- contractCodes,
8075
- channelInfo,
8076
- controllerAddress,
8077
- l2AccountingVaultAddress,
8078
- liquidBalancesSlot,
8554
+ onChunk: async ({ groupedValues, chunkFromBlock, chunkToBlock }) => {
8555
+ currentSnapshot = await applyChannelRecoveryEventGroupsToStateManager({
8556
+ stateManager,
8557
+ fallbackSnapshot: currentSnapshot,
8558
+ groupedValues,
8559
+ channelInfo,
8560
+ controllerAddress,
8561
+ l2AccountingVaultAddress,
8562
+ liquidBalancesSlot,
8563
+ });
8564
+ await onCheckpoint?.({
8565
+ currentSnapshot,
8566
+ scanRange: {
8567
+ fromBlock: scanFromBlock,
8568
+ toBlock: chunkToBlock,
8569
+ chunkFromBlock,
8570
+ chunkToBlock,
8571
+ mode: baseSnapshot ? "recovery-index" : "genesis",
8572
+ },
8573
+ });
8574
+ },
8079
8575
  });
8080
8576
 
8081
8577
  expect(
@@ -8103,9 +8599,28 @@ async function applyChannelRecoveryEventGroups({
8103
8599
  l2AccountingVaultAddress,
8104
8600
  liquidBalancesSlot,
8105
8601
  }) {
8106
- let currentSnapshot = startingSnapshot;
8107
- const stateManager = await buildStateManager(currentSnapshot, contractCodes);
8602
+ const stateManager = await buildStateManager(startingSnapshot, contractCodes);
8603
+ return applyChannelRecoveryEventGroupsToStateManager({
8604
+ stateManager,
8605
+ fallbackSnapshot: startingSnapshot,
8606
+ groupedValues,
8607
+ channelInfo,
8608
+ controllerAddress,
8609
+ l2AccountingVaultAddress,
8610
+ liquidBalancesSlot,
8611
+ });
8612
+ }
8108
8613
 
8614
+ async function applyChannelRecoveryEventGroupsToStateManager({
8615
+ stateManager,
8616
+ fallbackSnapshot,
8617
+ groupedValues,
8618
+ channelInfo,
8619
+ controllerAddress,
8620
+ l2AccountingVaultAddress,
8621
+ liquidBalancesSlot,
8622
+ }) {
8623
+ let currentSnapshot = fallbackSnapshot;
8109
8624
  for (const group of groupedValues) {
8110
8625
  const orderedGroup = [...group].sort(compareLogsByPosition);
8111
8626
  const rootEvent = orderedGroup.find(
@@ -8352,11 +8867,14 @@ async function fetchLogsChunked(provider, {
8352
8867
  fromBlock,
8353
8868
  toBlock,
8354
8869
  initialChunkSize = DEFAULT_LOG_CHUNK_SIZE,
8870
+ collectLogs = true,
8355
8871
  onProgress = null,
8872
+ onChunk = null,
8356
8873
  }) {
8357
8874
  const normalizedFromBlock = Number(fromBlock);
8358
8875
  const resolvedToBlock = toBlock === "latest" ? await provider.getBlockNumber() : Number(toBlock);
8359
8876
  const aggregatedLogs = [];
8877
+ let logsFound = 0;
8360
8878
 
8361
8879
  if (normalizedFromBlock > resolvedToBlock) {
8362
8880
  onProgress?.({
@@ -8384,27 +8902,15 @@ async function fetchLogsChunked(provider, {
8384
8902
  let cursor = normalizedFromBlock;
8385
8903
  while (cursor <= resolvedToBlock) {
8386
8904
  const chunkToBlock = Math.min(resolvedToBlock, cursor + chunkSize - 1);
8905
+ let logs;
8387
8906
  try {
8388
8907
  await throttleLogRequest();
8389
- const logs = await provider.getLogs({
8908
+ logs = await provider.getLogs({
8390
8909
  address,
8391
8910
  topics,
8392
8911
  fromBlock: cursor,
8393
8912
  toBlock: chunkToBlock,
8394
8913
  });
8395
- aggregatedLogs.push(...logs);
8396
- onProgress?.({
8397
- status: "progress",
8398
- fromBlock: normalizedFromBlock,
8399
- toBlock: resolvedToBlock,
8400
- chunkFromBlock: cursor,
8401
- chunkToBlock,
8402
- scannedBlocks: chunkToBlock - normalizedFromBlock + 1,
8403
- totalBlocks,
8404
- logsFound: aggregatedLogs.length,
8405
- chunkLogs: logs.length,
8406
- });
8407
- cursor = chunkToBlock + 1;
8408
8914
  } catch (error) {
8409
8915
  if (isRateLimitError(error)) {
8410
8916
  throw new Error(
@@ -8417,7 +8923,36 @@ async function fetchLogsChunked(provider, {
8417
8923
  throw error;
8418
8924
  }
8419
8925
  chunkSize = suggestedChunkSize;
8926
+ continue;
8927
+ }
8928
+ logsFound += logs.length;
8929
+ if (collectLogs) {
8930
+ aggregatedLogs.push(...logs);
8420
8931
  }
8932
+ onProgress?.({
8933
+ status: "progress",
8934
+ fromBlock: normalizedFromBlock,
8935
+ toBlock: resolvedToBlock,
8936
+ chunkFromBlock: cursor,
8937
+ chunkToBlock,
8938
+ scannedBlocks: chunkToBlock - normalizedFromBlock + 1,
8939
+ totalBlocks,
8940
+ logsFound,
8941
+ chunkLogs: logs.length,
8942
+ });
8943
+ await onChunk?.({
8944
+ status: "progress",
8945
+ fromBlock: normalizedFromBlock,
8946
+ toBlock: resolvedToBlock,
8947
+ chunkFromBlock: cursor,
8948
+ chunkToBlock,
8949
+ scannedBlocks: chunkToBlock - normalizedFromBlock + 1,
8950
+ totalBlocks,
8951
+ logsFound,
8952
+ chunkLogs: logs.length,
8953
+ logs,
8954
+ });
8955
+ cursor = chunkToBlock + 1;
8421
8956
  }
8422
8957
 
8423
8958
  onProgress?.({
@@ -8426,7 +8961,7 @@ async function fetchLogsChunked(provider, {
8426
8961
  toBlock: resolvedToBlock,
8427
8962
  scannedBlocks: totalBlocks,
8428
8963
  totalBlocks,
8429
- logsFound: aggregatedLogs.length,
8964
+ logsFound,
8430
8965
  });
8431
8966
 
8432
8967
  return aggregatedLogs;
@@ -8462,7 +8997,7 @@ function assertAutoRecoveryBlockBudget({
8462
8997
  `Automatic recovery for ${label} would exceed the ${AUTO_RECOVERY_BLOCK_BUDGET}-block pre-command budget.`,
8463
8998
  `Recovery delta is ${blockDelta} blocks from ${normalizedFromBlock} to ${normalizedToBlock}.`,
8464
8999
  `Remaining automatic recovery budget is ${normalizedBudget} blocks.`,
8465
- `Run ${recoveryCommand} first; add --from-genesis only if the saved recovery index is unusable.`,
9000
+ `Run ${recoveryCommand} first.`,
8466
9001
  ].join(" "));
8467
9002
  }
8468
9003
 
@@ -8912,16 +9447,18 @@ function prepareJoinWalletSecretForName({
8912
9447
  walletName,
8913
9448
  }) {
8914
9449
  const { channelName } = parseWalletName(walletName);
8915
- const walletDir = walletPath(walletName, networkName);
9450
+ const walletRoot = walletRootPath(walletName, networkName);
9451
+ const walletIndex = fs.existsSync(walletRoot)
9452
+ ? requireWalletIndex({ walletRoot, walletName, networkName })
9453
+ : null;
9454
+ const activeEpoch = walletIndex ? activeWalletEpoch(walletIndex) : null;
8916
9455
  expect(
8917
- !walletConfigExists(walletDir),
9456
+ !activeEpoch,
8918
9457
  [
8919
9458
  `Wallet ${walletName} already exists on ${networkName}.`,
8920
- "channel join always creates a new local wallet.",
8921
- "If this wallet was previously exited on-chain, run",
8922
- `wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account ${args.account ?? "<ACCOUNT>"}`,
8923
- "once to remove the stale local wallet, then retry channel join.",
9459
+ "channel join creates a new active wallet epoch.",
8924
9460
  "Use normal wallet commands for an existing active local wallet.",
9461
+ `For exited history, keep using wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account ${args.account ?? "<ACCOUNT>"}.`,
8925
9462
  ].join(" "),
8926
9463
  );
8927
9464
  const sourcePath = path.resolve(String(requireArg(args.walletSecretPath, "--wallet-secret-path")));
@@ -8932,7 +9469,7 @@ function channelWorkspacePath(networkName, name) {
8932
9469
  return workspaceDirForName(workspaceRoot, networkName, name);
8933
9470
  }
8934
9471
 
8935
- function walletPath(name, networkName) {
9472
+ function walletRootPath(name, networkName) {
8936
9473
  const walletName = String(name);
8937
9474
  const { channelName } = parseWalletName(walletName);
8938
9475
  const normalizedNetworkName = requireNetworkName({ network: networkName });
@@ -8940,6 +9477,91 @@ function walletPath(name, networkName) {
8940
9477
  return walletDirForName(workspaceWalletsDir(workspaceDir), walletName);
8941
9478
  }
8942
9479
 
9480
+ function selectedWalletEpochDir(name, networkName) {
9481
+ const root = walletRootPath(name, networkName);
9482
+ const walletName = requireWalletName({ wallet: name });
9483
+ const normalizedNetworkName = requireNetworkName({ network: networkName });
9484
+ expect(
9485
+ fs.existsSync(root),
9486
+ cliError(CLI_ERROR_CODES.UNKNOWN_WALLET, `Unknown wallet: ${walletName} on ${normalizedNetworkName}.`),
9487
+ );
9488
+ const index = requireWalletIndex({ walletRoot: root, walletName, networkName: normalizedNetworkName });
9489
+ const selected = selectedWalletEpoch(index, walletName, normalizedNetworkName);
9490
+ return walletEpochPathFromRoot(root, selected.epochId);
9491
+ }
9492
+
9493
+ function walletEpochPath(walletName, networkName, epochId) {
9494
+ return walletEpochPathFromRoot(walletRootPath(walletName, networkName), epochId);
9495
+ }
9496
+
9497
+ function walletEpochPathFromRoot(walletRoot, epochId) {
9498
+ return path.join(walletRoot, "epochs", slugifyPathComponent(epochId));
9499
+ }
9500
+
9501
+ function walletIndexMetadataPath(walletRoot) {
9502
+ return path.join(walletRoot, "wallet-index.metadata.json");
9503
+ }
9504
+
9505
+ function readWalletIndexIfExists(walletRoot) {
9506
+ const indexPath = walletIndexMetadataPath(walletRoot);
9507
+ if (!fs.existsSync(indexPath)) {
9508
+ return null;
9509
+ }
9510
+ return normalizeWalletIndex(readJson(indexPath));
9511
+ }
9512
+
9513
+ function requireWalletIndex({ walletRoot, walletName, networkName }) {
9514
+ const index = readWalletIndexIfExists(walletRoot);
9515
+ expect(index, currentWalletIndexRequiredMessage({ walletName, networkName, walletRoot }));
9516
+ return index;
9517
+ }
9518
+
9519
+ function selectedWalletEpoch(index, walletName, networkName) {
9520
+ const selected = activeWalletEpoch(index) ?? latestWalletEpoch(index);
9521
+ expect(
9522
+ selected,
9523
+ `Wallet ${walletName} on ${networkName} has no epoch entries. Run wallet recover-workspace to rebuild the workspace in the current format.`,
9524
+ );
9525
+ return selected;
9526
+ }
9527
+
9528
+ function currentWalletIndexRequiredMessage({ walletName, networkName, walletRoot }) {
9529
+ const channelName = parseWalletName(walletName).channelName;
9530
+ return [
9531
+ `Current wallet index is required for ${walletName} on ${networkName}: ${walletRoot}.`,
9532
+ `Run wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account <ACCOUNT> to rebuild the workspace.`,
9533
+ ].join(" ");
9534
+ }
9535
+
9536
+ function activeWalletEpoch(index) {
9537
+ const activeEpochId = index?.activeEpochId ?? null;
9538
+ return activeEpochId
9539
+ ? (index.epochs ?? []).find((epoch) => epoch.epochId === activeEpochId && epoch.lifecycleStatus === "active") ?? null
9540
+ : null;
9541
+ }
9542
+
9543
+ function latestWalletEpoch(index) {
9544
+ const epochs = [...(index?.epochs ?? [])];
9545
+ epochs.sort((left, right) =>
9546
+ Number(right.joinedAtBlockNumber ?? 0) - Number(left.joinedAtBlockNumber ?? 0)
9547
+ || String(right.epochId).localeCompare(String(left.epochId)));
9548
+ return epochs[0] ?? null;
9549
+ }
9550
+
9551
+ function normalizeWalletIndex(index) {
9552
+ expect(index?.format === WALLET_INDEX_FORMAT, "Invalid wallet index format.");
9553
+ expect(Number(index.formatVersion) === WALLET_INDEX_FORMAT_VERSION, "Unsupported wallet index format version.");
9554
+ expect(Array.isArray(index.epochs), "Wallet index is missing epochs[].");
9555
+ return {
9556
+ ...index,
9557
+ epochs: index.epochs.map((epoch) => ({
9558
+ ...epoch,
9559
+ epochId: String(epoch.epochId),
9560
+ lifecycleStatus: epoch.lifecycleStatus === "active" ? "active" : "exited",
9561
+ })),
9562
+ };
9563
+ }
9564
+
8943
9565
  function accountPrivateKeyPath(networkName, accountName) {
8944
9566
  return path.join(
8945
9567
  secretRoot,
@@ -9013,7 +9635,8 @@ function resolveWalletPathCandidates(walletName) {
9013
9635
  "wallets",
9014
9636
  walletSlug,
9015
9637
  );
9016
- if (walletConfigExists(candidate)) {
9638
+ if (fs.existsSync(candidate)) {
9639
+ requireWalletIndex({ walletRoot: candidate, walletName, networkName: entry.name });
9017
9640
  candidates.push(candidate);
9018
9641
  }
9019
9642
  }
@@ -9044,12 +9667,24 @@ function listLocalWallets({ networkFilter = null, channelFilter = null } = {}) {
9044
9667
  if (!walletEntry.isDirectory()) {
9045
9668
  continue;
9046
9669
  }
9047
- const walletDir = path.join(walletsDir, walletEntry.name);
9670
+ const walletRoot = path.join(walletsDir, walletEntry.name);
9671
+ const walletIndex = requireWalletIndex({
9672
+ walletRoot,
9673
+ walletName: walletEntry.name,
9674
+ networkName: networkEntry.name,
9675
+ });
9676
+ const selectedEpoch = selectedWalletEpoch(walletIndex, walletEntry.name, networkEntry.name);
9677
+ const walletDir = walletEpochPathFromRoot(walletRoot, selectedEpoch.epochId);
9048
9678
  wallets.push({
9049
9679
  wallet: walletEntry.name,
9050
9680
  network: networkEntry.name,
9051
9681
  channelName: channelEntry.name,
9052
9682
  walletDir,
9683
+ walletRoot,
9684
+ activeEpochId: walletIndex?.activeEpochId ?? null,
9685
+ selectedEpochId: selectedEpoch?.epochId ?? null,
9686
+ lifecycleStatus: selectedEpoch.lifecycleStatus,
9687
+ epochs: walletIndex.epochs,
9053
9688
  metadataPath: walletNotesMetadataPath(walletDir),
9054
9689
  hasMetadata: fs.existsSync(walletNotesMetadataPath(walletDir)),
9055
9690
  hasEncryptedWallet: false,
@@ -9077,7 +9712,7 @@ function privateStateCliDataRoot() {
9077
9712
 
9078
9713
  function resolveExportWalletInfo({ networkName, walletName }) {
9079
9714
  resolveCliNetwork(networkName);
9080
- const walletDir = walletPath(walletName, networkName);
9715
+ const walletDir = selectedWalletEpochDir(walletName, networkName);
9081
9716
  return {
9082
9717
  wallet: walletName,
9083
9718
  network: networkName,
@@ -9092,7 +9727,7 @@ function resolveExportWalletInfo({ networkName, walletName }) {
9092
9727
  function normalizeExportWalletInfo(walletInfo) {
9093
9728
  const wallet = requireWalletName({ wallet: walletInfo.wallet });
9094
9729
  const network = requireNetworkName({ network: walletInfo.network });
9095
- const walletDir = walletInfo.walletDir ?? walletPath(wallet, network);
9730
+ const walletDir = walletInfo.walletDir ?? selectedWalletEpochDir(wallet, network);
9096
9731
  const metadataPath = walletNotesMetadataPath(walletDir);
9097
9732
  const metadata = readJsonIfExists(metadataPath);
9098
9733
  const channelName = metadata?.channelName ?? walletInfo.channelName ?? parseWalletName(wallet).channelName;
@@ -9119,6 +9754,13 @@ function walletBackupExportFilePaths(walletInfo) {
9119
9754
  const walletFiles = [
9120
9755
  walletNotesMetadataPath(walletInfo.walletDir),
9121
9756
  ];
9757
+ const walletRoot = walletRootPath(walletInfo.wallet, walletInfo.network);
9758
+ requireWalletIndex({
9759
+ walletRoot,
9760
+ walletName: walletInfo.wallet,
9761
+ networkName: walletInfo.network,
9762
+ });
9763
+ walletFiles.push(walletIndexMetadataPath(walletRoot));
9122
9764
  for (const metadataPath of [
9123
9765
  walletViewingKeyMetadataPath(walletInfo.walletDir),
9124
9766
  walletSpendingKeyMetadataPath(walletInfo.walletDir),
@@ -9172,9 +9814,18 @@ function validateWalletExportManifest(manifest) {
9172
9814
  validateWalletArchivePath(filePath);
9173
9815
  }
9174
9816
  for (const wallet of manifest.wallets) {
9175
- requireNetworkName({ network: wallet.network });
9176
- requireWalletName({ wallet: wallet.wallet });
9817
+ const networkName = requireNetworkName({ network: wallet.network });
9818
+ const walletName = requireWalletName({ wallet: wallet.wallet });
9177
9819
  requireArg(wallet.channelName, "wallets[].channelName");
9820
+ const walletRoot = walletRootPath(walletName, networkName);
9821
+ const expectedIndexPath = archivePathForLocalCliFile(walletIndexMetadataPath(walletRoot));
9822
+ expect(
9823
+ uniqueFiles.has(expectedIndexPath),
9824
+ [
9825
+ "Wallet import ZIP must include the current wallet index metadata.",
9826
+ "Run wallet recover-workspace with the current CLI, then export a new backup.",
9827
+ ].join(" "),
9828
+ );
9178
9829
  }
9179
9830
  }
9180
9831
 
@@ -9276,21 +9927,8 @@ function assertAllowedCommandKeys(args, commandName, allowedKeys, acceptedUsage)
9276
9927
  );
9277
9928
  }
9278
9929
 
9279
- function assertWalletSecretArgs(args, commandName, extraOptionKeys = [], acceptedUsage = "--wallet and --network") {
9280
- if (COMMAND_ARG_SCHEMAS[commandName]) {
9281
- assertAllowedCommandSchema(args, commandName);
9282
- return;
9283
- }
9284
- assertAllowedCommandKeys(
9285
- args,
9286
- commandName,
9287
- new Set(["command", "positional", "wallet", "network", ...extraOptionKeys]),
9288
- acceptedUsage,
9289
- );
9290
- }
9291
-
9292
9930
  function assertWalletChannelMoveArgs(args, commandName) {
9293
- assertWalletSecretArgs(args, commandName, ["amount"], "--wallet, --network, and --amount");
9931
+ assertAllowedCommandSchema(args, commandName);
9294
9932
  assertActionImpactArg(args, COMMAND_ARG_SCHEMAS[commandName]?.label ?? commandName);
9295
9933
  }
9296
9934
 
@@ -9404,7 +10042,7 @@ function assertActionImpactArg(args, commandName) {
9404
10042
  }
9405
10043
 
9406
10044
  function assertWalletGetNotesArgs(args) {
9407
- assertWalletSecretArgs(args, "wallet-get-notes");
10045
+ assertAllowedCommandSchema(args, "wallet-get-notes");
9408
10046
  if (args.exportEvidence !== undefined) {
9409
10047
  requireArg(args.exportEvidence, "--export-evidence");
9410
10048
  if (args.acknowledgeFullNotePlaintextExport !== true) {
@@ -9459,12 +10097,8 @@ function assertAccountGetBridgeFundArgs(args) {
9459
10097
  assertAllowedCommandSchema(args, "account-get-bridge-fund");
9460
10098
  }
9461
10099
 
9462
- function assertExplicitSignerCommandArgs(args, commandName) {
9463
- assertAllowedCommandSchema(args, commandName);
9464
- }
9465
-
9466
10100
  function assertRecoverWalletArgs(args) {
9467
- assertExplicitSignerCommandArgs(args, "wallet-recover-workspace");
10101
+ assertAllowedCommandSchema(args, "wallet-recover-workspace");
9468
10102
  if (args.fromGenesis !== undefined && args.fromGenesis !== true) {
9469
10103
  throw new Error("wallet recover-workspace option --from-genesis does not accept a value.");
9470
10104
  }
@@ -9476,7 +10110,7 @@ function assertJoinChannelArgs(args) {
9476
10110
  }
9477
10111
 
9478
10112
  function assertWalletGetMetaArgs(args) {
9479
- assertWalletSecretArgs(args, "wallet-get-meta");
10113
+ assertAllowedCommandSchema(args, "wallet-get-meta");
9480
10114
  }
9481
10115
 
9482
10116
  function assertAccountGetL1AddressArgs(args) {
@@ -9521,17 +10155,17 @@ function assertWithdrawBridgeArgs(args) {
9521
10155
  }
9522
10156
 
9523
10157
  function assertWalletGetChannelFundArgs(args) {
9524
- assertWalletSecretArgs(args, "wallet-get-channel-fund");
10158
+ assertAllowedCommandSchema(args, "wallet-get-channel-fund");
9525
10159
  }
9526
10160
 
9527
10161
  function assertExitChannelArgs(args) {
9528
- assertWalletSecretArgs(args, "channel-exit");
10162
+ assertAllowedCommandSchema(args, "channel-exit");
9529
10163
  }
9530
10164
 
9531
10165
  function createWalletOperationDir(walletName, networkName, suffix) {
9532
10166
  const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
9533
10167
  const operationDir = path.join(
9534
- walletPath(walletName, networkName),
10168
+ selectedWalletEpochDir(walletName, networkName),
9535
10169
  "operations",
9536
10170
  `${timestamp}-${slugifyPathComponent(suffix)}`,
9537
10171
  );
@@ -9576,8 +10210,73 @@ function persistWalletKeys(context) {
9576
10210
  }
9577
10211
  }
9578
10212
 
9579
- function persistWalletMetadata(context) {
9580
- persistWallet(context);
10213
+ function persistWalletIndexForContext(context) {
10214
+ const walletRoot = walletRootPath(context.walletName, context.wallet.network);
10215
+ ensureDir(walletRoot);
10216
+ const currentIndex = readWalletIndexIfExists(walletRoot) ?? {
10217
+ format: WALLET_INDEX_FORMAT,
10218
+ formatVersion: WALLET_INDEX_FORMAT_VERSION,
10219
+ canonicalWalletName: context.walletName,
10220
+ network: context.wallet.network,
10221
+ channelName: context.wallet.channelName,
10222
+ channelId: context.wallet.channelId,
10223
+ l1Address: context.wallet.l1Address,
10224
+ activeEpochId: null,
10225
+ epochs: [],
10226
+ };
10227
+ const epoch = walletEpochSummaryFromWallet(context.wallet);
10228
+ const epochs = [
10229
+ ...currentIndex.epochs.filter((entry) => entry.epochId !== epoch.epochId),
10230
+ epoch,
10231
+ ].sort((left, right) =>
10232
+ Number(left.joinedAtBlockNumber ?? 0) - Number(right.joinedAtBlockNumber ?? 0)
10233
+ || String(left.epochId).localeCompare(String(right.epochId)));
10234
+ const activeEpoch = epochs.find((entry) => entry.lifecycleStatus === "active") ?? null;
10235
+ const nextIndex = {
10236
+ ...currentIndex,
10237
+ canonicalWalletName: context.walletName,
10238
+ network: context.wallet.network,
10239
+ channelName: context.wallet.channelName,
10240
+ channelId: context.wallet.channelId,
10241
+ l1Address: context.wallet.l1Address,
10242
+ activeEpochId: activeEpoch?.epochId ?? null,
10243
+ epochs,
10244
+ };
10245
+ writeJson(walletIndexMetadataPath(walletRoot), nextIndex);
10246
+ }
10247
+
10248
+ async function markWalletEpochExited({ walletContext, receipt, provider }) {
10249
+ const block = receipt?.blockNumber === null || receipt?.blockNumber === undefined
10250
+ ? null
10251
+ : await provider.getBlock(receipt.blockNumber).catch(() => null);
10252
+ const exitedAtBlockTimestamp = block?.timestamp ?? null;
10253
+ walletContext.wallet.lifecycleStatus = "exited";
10254
+ walletContext.wallet.exitedAtTxHash = receipt?.hash ?? null;
10255
+ walletContext.wallet.exitedAtBlockNumber = receipt?.blockNumber ?? null;
10256
+ walletContext.wallet.exitedAtLogIndex = firstReceiptLogIndex(receipt);
10257
+ walletContext.wallet.exitedAtBlockTimestamp = exitedAtBlockTimestamp;
10258
+ walletContext.wallet.exitedAtBlockTimestampIso = exitedAtBlockTimestamp === null
10259
+ ? null
10260
+ : new Date(Number(exitedAtBlockTimestamp) * 1000).toISOString();
10261
+ persistWallet(walletContext);
10262
+ persistWalletIndexForContext(walletContext);
10263
+ return walletEpochSummaryFromWallet(walletContext.wallet);
10264
+ }
10265
+
10266
+ function firstReceiptLogIndex(receipt) {
10267
+ const first = receipt?.logs?.[0] ?? null;
10268
+ return first?.index ?? first?.logIndex ?? null;
10269
+ }
10270
+
10271
+ function walletEpochSummaryFromWallet(wallet) {
10272
+ const lifecycle = walletLifecycleMetadata(wallet);
10273
+ return {
10274
+ ...lifecycle,
10275
+ walletDirName: slugifyPathComponent(lifecycle.epochId),
10276
+ l2Address: wallet.l2Address,
10277
+ l2StorageKey: wallet.l2StorageKey,
10278
+ leafIndex: wallet.leafIndex,
10279
+ };
9581
10280
  }
9582
10281
 
9583
10282
  function sanitizeWalletForNotesMetadata(wallet) {
@@ -9633,6 +10332,7 @@ function buildWalletSpendingKeyMetadata(wallet) {
9633
10332
  walletFormatVersion: WALLET_WORKSPACE_FORMAT_VERSION,
9634
10333
  network: wallet.network,
9635
10334
  wallet: wallet.name,
10335
+ ...walletLifecycleMetadata(wallet),
9636
10336
  channelName: wallet.channelName,
9637
10337
  channelId: wallet.channelId,
9638
10338
  l1Address: wallet.l1Address,
@@ -9650,6 +10350,7 @@ function buildWalletViewingKeyMetadata(wallet) {
9650
10350
  walletFormatVersion: WALLET_WORKSPACE_FORMAT_VERSION,
9651
10351
  network: wallet.network,
9652
10352
  wallet: wallet.name,
10353
+ ...walletLifecycleMetadata(wallet),
9653
10354
  channelName: wallet.channelName,
9654
10355
  channelId: wallet.channelId,
9655
10356
  l1Address: wallet.l1Address,
@@ -10028,9 +10729,9 @@ function writeEncryptedWalletFile(filePath, plaintextBytes, walletSecret) {
10028
10729
  version: WALLET_ENCRYPTION_VERSION,
10029
10730
  algorithm: WALLET_ENCRYPTION_ALGORITHM,
10030
10731
  kdf: "scrypt",
10031
- salt: normalizeBytes16Hex(salt),
10732
+ salt: normalizeBytesHex(salt, 16),
10032
10733
  iv: normalizeBytes12Hex(iv),
10033
- tag: normalizeBytes16Hex(tag),
10734
+ tag: normalizeBytesHex(tag, 16),
10034
10735
  ciphertext: ethers.hexlify(ciphertext),
10035
10736
  };
10036
10737
  fs.writeFileSync(filePath, `${JSON.stringify(envelope, null, 2)}\n`);
@@ -10528,8 +11229,7 @@ function buildRecoveryHints(error, args = {}) {
10528
11229
  }
10529
11230
 
10530
11231
  if (message.includes("Workspace recovery index is missing or unusable")) {
10531
- hints.push(`private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName} --source rpc --from-genesis`);
10532
- hints.push(`private-state-cli wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account ${accountName} --from-genesis`);
11232
+ hints.push(`private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName}`);
10533
11233
  }
10534
11234
 
10535
11235
  if (message.includes("Wallet note recovery index is missing or unusable")) {