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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -137,9 +137,11 @@ const GROTH16_PACKAGE_NAME = "@tokamak-private-dapps/groth16";
137
137
  const TOKAMAK_ZKEVM_CLI_PACKAGE_NAME = "@tokamak-zk-evm/cli";
138
138
  const WALLET_BACKUP_EXPORT_FORMAT = "tokamak-private-state-wallet-backup-export";
139
139
  const WALLET_KEY_EXPORT_FORMAT = "tokamak-private-state-wallet-key-export";
140
+ const WALLET_INDEX_FORMAT = "tokamak-private-state-wallet-index";
140
141
  const WALLET_EVIDENCE_BUNDLE_FORMAT = "tokamak-private-state-raw-evidence-bundle";
141
142
  const WALLET_EXPORT_FORMAT_VERSION = 2;
142
- const WALLET_EVIDENCE_BUNDLE_FORMAT_VERSION = 1;
143
+ const WALLET_INDEX_FORMAT_VERSION = 1;
144
+ const WALLET_EVIDENCE_BUNDLE_FORMAT_VERSION = 2;
143
145
  const WALLET_WORKSPACE_FORMAT_VERSION = 2;
144
146
  const CHANNEL_WORKSPACE_MIRROR_PROTOCOL_VERSION = 2;
145
147
  const CHANNEL_WORKSPACE_MIRROR_MANIFEST_PATH_PREFIX =
@@ -2502,64 +2504,51 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2502
2504
  account: signer.address,
2503
2505
  });
2504
2506
  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
- }
2507
+ const lifecycleEpoch = await resolveWalletLifecycleEpoch({
2508
+ context,
2509
+ provider,
2510
+ l1Address: signer.address,
2511
+ registration,
2512
+ });
2539
2513
  expect(
2540
- ethers.toBigInt(normalizeBytes32Hex(registration.noteReceivePubKey.x))
2514
+ lifecycleEpoch,
2515
+ cliError(
2516
+ CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION,
2517
+ `No channelTokenVault registration history exists for ${signer.address}. Run channel join first.`,
2518
+ ),
2519
+ );
2520
+ const registeredNoteReceivePubKey = lifecycleEpoch.noteReceivePubKey;
2521
+ expect(
2522
+ ethers.toBigInt(normalizeBytes32Hex(registeredNoteReceivePubKey.x))
2541
2523
  === ethers.toBigInt(normalizeBytes32Hex(noteReceiveKeyMaterial.noteReceivePubKey.x)),
2542
2524
  "The existing note-receive public key X does not match the derived note-receive public key.",
2543
2525
  );
2544
2526
  expect(
2545
- Number(registration.noteReceivePubKey.yParity) === Number(noteReceiveKeyMaterial.noteReceivePubKey.yParity),
2527
+ Number(registeredNoteReceivePubKey.yParity) === Number(noteReceiveKeyMaterial.noteReceivePubKey.yParity),
2546
2528
  "The existing note-receive public key parity does not match the derived note-receive public key.",
2547
2529
  );
2548
2530
  const l2Identity = {
2549
2531
  l2PrivateKey: null,
2550
2532
  l2PublicKey: null,
2551
- l2Address: getAddress(registration.l2Address),
2533
+ l2Address: getAddress(lifecycleEpoch.l2Address),
2552
2534
  };
2553
- const storageKey = normalizeBytes32Hex(registration.channelTokenVaultKey);
2535
+ const storageKey = normalizeBytes32Hex(lifecycleEpoch.channelTokenVaultKey);
2554
2536
 
2555
- const walletDir = walletPath(walletName, context.workspace.network);
2537
+ const walletDir = walletEpochPath(walletName, context.workspace.network, lifecycleEpoch.epochId);
2556
2538
  const existingWallet = walletConfigExists(walletDir)
2557
- ? loadWallet(walletName, context.workspace.network)
2539
+ ? loadWalletFromDir({
2540
+ walletName,
2541
+ networkName: context.workspace.network,
2542
+ walletDir,
2543
+ })
2558
2544
  : null;
2559
2545
 
2560
2546
  if (existingWallet) {
2561
2547
  existingWallet.wallet.noteReceivePrivateKey = noteReceiveKeyMaterial.privateKey;
2548
+ applyWalletLifecycleEpoch(existingWallet.wallet, lifecycleEpoch);
2562
2549
  persistWalletKeys(existingWallet);
2550
+ persistWallet(existingWallet);
2551
+ persistWalletIndexForContext(existingWallet);
2563
2552
  const { recoveredDeliveryState } = await recoverWalletReceivedNotes({
2564
2553
  walletContext: existingWallet,
2565
2554
  context,
@@ -2580,7 +2569,10 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2580
2569
  l1Address: signer.address,
2581
2570
  l2Address: l2Identity.l2Address,
2582
2571
  l2StorageKey: storageKey,
2583
- leafIndex: registration.leafIndex.toString(),
2572
+ leafIndex: lifecycleEpoch.leafIndex.toString(),
2573
+ epochId: lifecycleEpoch.epochId,
2574
+ lifecycleStatus: lifecycleEpoch.lifecycleStatus,
2575
+ exitedAtTxHash: lifecycleEpoch.exitedAtTxHash,
2584
2576
  noteReceivePubKey: noteReceiveKeyMaterial.noteReceivePubKey,
2585
2577
  l2Nonce: existingWallet.wallet.l2Nonce,
2586
2578
  recoveredFromLogs: recoveredDeliveryState.importedNotes,
@@ -2590,8 +2582,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2590
2582
  return;
2591
2583
  }
2592
2584
 
2593
- fs.rmSync(walletPath(walletName, context.workspace.network), { recursive: true, force: true });
2594
-
2595
2585
  const walletContext = ensureWallet({
2596
2586
  channelContext: context,
2597
2587
  signerAddress: signer.address,
@@ -2599,8 +2589,9 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2599
2589
  l2Identity,
2600
2590
  walletSecret: noteReceiveKeyMaterial.privateKey,
2601
2591
  storageKey,
2602
- leafIndex: registration.leafIndex,
2592
+ leafIndex: lifecycleEpoch.leafIndex,
2603
2593
  noteReceiveKeyMaterial,
2594
+ lifecycleEpoch,
2604
2595
  rpcUrl,
2605
2596
  });
2606
2597
  walletContext.wallet.l2Nonce = 0;
@@ -2627,7 +2618,10 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2627
2618
  l1Address: signer.address,
2628
2619
  l2Address: l2Identity.l2Address,
2629
2620
  l2StorageKey: storageKey,
2630
- leafIndex: registration.leafIndex.toString(),
2621
+ leafIndex: lifecycleEpoch.leafIndex.toString(),
2622
+ epochId: lifecycleEpoch.epochId,
2623
+ lifecycleStatus: lifecycleEpoch.lifecycleStatus,
2624
+ exitedAtTxHash: lifecycleEpoch.exitedAtTxHash,
2631
2625
  noteReceivePubKey: noteReceiveKeyMaterial.noteReceivePubKey,
2632
2626
  l2Nonce: walletContext.wallet.l2Nonce,
2633
2627
  recoveredFromLogs: recoveredDeliveryState.importedNotes,
@@ -2636,32 +2630,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2636
2630
  });
2637
2631
  }
2638
2632
 
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
2633
  async function handleInstallZkEvm({ args }) {
2666
2634
  const selectedVersions = await resolvePrivateStateInstallRuntimeVersions(args);
2667
2635
  const tokamakCliRuntime = await installTokamakCliRuntimeForPrivateState({
@@ -3371,9 +3339,13 @@ function handleWalletImportKey({ args, keyKind }) {
3371
3339
  : walletViewingKeySecretPath(networkName, walletName);
3372
3340
  expect(!fs.existsSync(targetPath), `Refusing to overwrite existing ${keyKind} key: ${targetPath}.`);
3373
3341
  writeSecretFile(targetPath, JSON.stringify(payload, null, 2));
3374
- const walletDir = walletPath(walletName, networkName);
3375
- ensureDir(walletDir);
3376
- if (walletConfigExists(walletDir)) {
3342
+ const walletRoot = walletRootPath(walletName, networkName);
3343
+ const walletIndex = fs.existsSync(walletRoot)
3344
+ ? requireWalletIndex({ walletRoot, walletName, networkName })
3345
+ : null;
3346
+ const selectedEpoch = walletIndex ? selectedWalletEpoch(walletIndex, walletName, networkName) : null;
3347
+ if (selectedEpoch) {
3348
+ const walletDir = walletEpochPathFromRoot(walletRoot, selectedEpoch.epochId);
3377
3349
  const metadataPath = keyKind === "spending"
3378
3350
  ? walletSpendingKeyMetadataPath(walletDir)
3379
3351
  : walletViewingKeyMetadataPath(walletDir);
@@ -3842,7 +3814,13 @@ async function inspectGuideAccount({ account, networkName, network, provider, ar
3842
3814
  }
3843
3815
 
3844
3816
  async function inspectGuideWallet({ walletName, networkName, provider, artifactsInstalled }) {
3845
- const walletDir = walletPath(walletName, networkName);
3817
+ let walletDir = walletRootPath(walletName, networkName);
3818
+ let workspaceError = null;
3819
+ try {
3820
+ walletDir = selectedWalletEpochDir(walletName, networkName);
3821
+ } catch (error) {
3822
+ workspaceError = error.message;
3823
+ }
3846
3824
  const viewingKeyFile = walletViewingKeySecretPath(networkName, walletName);
3847
3825
  const spendingKeyFile = walletSpendingKeySecretPath(networkName, walletName);
3848
3826
  const result = {
@@ -3865,9 +3843,9 @@ async function inspectGuideWallet({ walletName, networkName, provider, artifacts
3865
3843
  unusedNoteBalanceBaseUnits: null,
3866
3844
  unusedNoteBalanceTokens: null,
3867
3845
  spentNoteCount: null,
3868
- error: null,
3846
+ error: workspaceError,
3869
3847
  };
3870
- if (!result.exists) {
3848
+ if (workspaceError || !result.exists) {
3871
3849
  return result;
3872
3850
  }
3873
3851
 
@@ -4109,6 +4087,7 @@ async function handleWalletGetMeta({ args, provider }) {
4109
4087
  printJson({
4110
4088
  action: "wallet get-meta",
4111
4089
  wallet: wallet.walletName,
4090
+ ...walletLifecycleMetadata(wallet.wallet),
4112
4091
  network: walletMetadata.network,
4113
4092
  channelName: walletMetadata.channelName,
4114
4093
  l1Address: signer.address,
@@ -4202,6 +4181,156 @@ async function loadWalletChannelRegistrationState({
4202
4181
  };
4203
4182
  }
4204
4183
 
4184
+ async function resolveWalletLifecycleEpoch({
4185
+ context,
4186
+ provider,
4187
+ l1Address,
4188
+ registration = null,
4189
+ }) {
4190
+ const epochs = await readWalletLifecycleEpochs({
4191
+ context,
4192
+ provider,
4193
+ l1Address,
4194
+ });
4195
+ if (epochs.length === 0 && registration?.exists) {
4196
+ const block = await provider.getBlock("latest").catch(() => null);
4197
+ return {
4198
+ epochId: `join-registered-${String(registration.joinedAt)}-${String(registration.leafIndex)}`,
4199
+ lifecycleStatus: "active",
4200
+ joinedAtTxHash: null,
4201
+ joinedAtBlockNumber: null,
4202
+ joinedAtLogIndex: null,
4203
+ joinedAtBlockTimestamp: Number(registration.joinedAt),
4204
+ joinedAtBlockTimestampIso: Number(registration.joinedAt) > 0
4205
+ ? new Date(Number(registration.joinedAt) * 1000).toISOString()
4206
+ : null,
4207
+ exitedAtTxHash: null,
4208
+ exitedAtBlockNumber: null,
4209
+ exitedAtLogIndex: null,
4210
+ exitedAtBlockTimestamp: null,
4211
+ exitedAtBlockTimestampIso: null,
4212
+ l2Address: getAddress(registration.l2Address),
4213
+ channelTokenVaultKey: normalizeBytes32Hex(registration.channelTokenVaultKey),
4214
+ leafIndex: registration.leafIndex,
4215
+ noteReceivePubKey: {
4216
+ x: normalizeBytes32Hex(registration.noteReceivePubKey.x),
4217
+ yParity: Number(registration.noteReceivePubKey.yParity),
4218
+ },
4219
+ observedAtBlockNumber: block?.number ?? null,
4220
+ };
4221
+ }
4222
+ if (registration?.exists) {
4223
+ const active = [...epochs].reverse().find((epoch) => (
4224
+ epoch.lifecycleStatus === "active"
4225
+ && ethers.toBigInt(getAddress(epoch.l2Address)) === ethers.toBigInt(getAddress(registration.l2Address))
4226
+ && ethers.toBigInt(normalizeBytes32Hex(epoch.channelTokenVaultKey))
4227
+ === ethers.toBigInt(normalizeBytes32Hex(registration.channelTokenVaultKey))
4228
+ ));
4229
+ if (active) {
4230
+ return active;
4231
+ }
4232
+ }
4233
+ return epochs[epochs.length - 1] ?? null;
4234
+ }
4235
+
4236
+ async function readWalletLifecycleEpochs({ context, provider, l1Address }) {
4237
+ const fromBlock = Number(context.workspace.genesisBlockNumber ?? 0);
4238
+ const [registeredLogs, exitedLogs] = await Promise.all([
4239
+ context.channelManager.queryFilter(
4240
+ context.channelManager.filters.ChannelTokenVaultIdentityRegistered(l1Address),
4241
+ fromBlock,
4242
+ "latest",
4243
+ ),
4244
+ context.channelManager.queryFilter(
4245
+ context.channelManager.filters.ChannelTokenVaultIdentityExited(l1Address),
4246
+ fromBlock,
4247
+ "latest",
4248
+ ),
4249
+ ]);
4250
+ const exits = await Promise.all(exitedLogs.map((log) => walletExitFromLog({ log, provider })));
4251
+ const epochs = [];
4252
+ for (const log of registeredLogs.sort(compareLogsByPosition)) {
4253
+ const registered = await walletEpochFromRegisteredLog({ log, provider });
4254
+ const exit = exits.find((entry) => (
4255
+ compareLogPosition(entry, registered) > 0
4256
+ && ethers.toBigInt(entry.leafIndex) === ethers.toBigInt(registered.leafIndex)
4257
+ && !epochs.some((epoch) => epoch.exitedAtTxHash === entry.exitedAtTxHash)
4258
+ ));
4259
+ if (exit) {
4260
+ epochs.push({
4261
+ ...registered,
4262
+ lifecycleStatus: "exited",
4263
+ exitedAtTxHash: exit.exitedAtTxHash,
4264
+ exitedAtBlockNumber: exit.exitedAtBlockNumber,
4265
+ exitedAtLogIndex: exit.exitedAtLogIndex,
4266
+ exitedAtBlockTimestamp: exit.exitedAtBlockTimestamp,
4267
+ exitedAtBlockTimestampIso: exit.exitedAtBlockTimestampIso,
4268
+ });
4269
+ } else {
4270
+ epochs.push(registered);
4271
+ }
4272
+ }
4273
+ return epochs.sort(compareWalletEpochs);
4274
+ }
4275
+
4276
+ async function walletEpochFromRegisteredLog({ log, provider }) {
4277
+ const block = await provider.getBlock(log.blockNumber).catch(() => null);
4278
+ const args = log.args;
4279
+ return {
4280
+ epochId: walletEpochIdFromLog(log),
4281
+ lifecycleStatus: "active",
4282
+ joinedAtTxHash: log.transactionHash,
4283
+ joinedAtBlockNumber: log.blockNumber,
4284
+ joinedAtLogIndex: log.index ?? log.logIndex ?? null,
4285
+ joinedAtBlockTimestamp: block?.timestamp ?? Number(args.joinedAt ?? 0) ?? null,
4286
+ joinedAtBlockTimestampIso: block?.timestamp
4287
+ ? new Date(Number(block.timestamp) * 1000).toISOString()
4288
+ : Number(args.joinedAt ?? 0) > 0
4289
+ ? new Date(Number(args.joinedAt) * 1000).toISOString()
4290
+ : null,
4291
+ exitedAtTxHash: null,
4292
+ exitedAtBlockNumber: null,
4293
+ exitedAtLogIndex: null,
4294
+ exitedAtBlockTimestamp: null,
4295
+ exitedAtBlockTimestampIso: null,
4296
+ l2Address: getAddress(args.l2Address),
4297
+ channelTokenVaultKey: normalizeBytes32Hex(args.channelTokenVaultKey),
4298
+ leafIndex: args.leafIndex,
4299
+ noteReceivePubKey: {
4300
+ x: normalizeBytes32Hex(args.noteReceivePubKeyX),
4301
+ yParity: Number(args.noteReceivePubKeyYParity),
4302
+ },
4303
+ };
4304
+ }
4305
+
4306
+ async function walletExitFromLog({ log, provider }) {
4307
+ const block = await provider.getBlock(log.blockNumber).catch(() => null);
4308
+ return {
4309
+ exitedAtTxHash: log.transactionHash,
4310
+ exitedAtBlockNumber: log.blockNumber,
4311
+ exitedAtLogIndex: log.index ?? log.logIndex ?? null,
4312
+ exitedAtBlockTimestamp: block?.timestamp ?? null,
4313
+ exitedAtBlockTimestampIso: block?.timestamp ? new Date(Number(block.timestamp) * 1000).toISOString() : null,
4314
+ leafIndex: log.args.leafIndex,
4315
+ };
4316
+ }
4317
+
4318
+ function walletEpochIdFromLog(log) {
4319
+ return `join-${String(log.transactionHash).toLowerCase()}-${Number(log.index ?? log.logIndex ?? 0)}`;
4320
+ }
4321
+
4322
+ function compareLogPosition(left, right) {
4323
+ return Number(left.exitedAtBlockNumber ?? left.blockNumber ?? 0) - Number(right.joinedAtBlockNumber ?? right.blockNumber ?? 0)
4324
+ || Number((left.exitedAtLogIndex ?? left.index ?? left.logIndex) ?? 0)
4325
+ - Number((right.joinedAtLogIndex ?? right.index ?? right.logIndex) ?? 0);
4326
+ }
4327
+
4328
+ function compareWalletEpochs(left, right) {
4329
+ return Number(left.joinedAtBlockNumber ?? 0) - Number(right.joinedAtBlockNumber ?? 0)
4330
+ || Number(left.joinedAtLogIndex ?? 0) - Number(right.joinedAtLogIndex ?? 0)
4331
+ || String(left.epochId).localeCompare(String(right.epochId));
4332
+ }
4333
+
4205
4334
  async function handleWalletGetChannelFund({ args, provider }) {
4206
4335
  const { wallet, walletMetadata } = loadUnlockedWalletWithMetadata(args);
4207
4336
  const {
@@ -4311,6 +4440,14 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4311
4440
  { nonce: nextNonce++ },
4312
4441
  ),
4313
4442
  );
4443
+ const registered = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
4444
+ const lifecycleEpoch = await resolveWalletLifecycleEpoch({
4445
+ context,
4446
+ provider,
4447
+ l1Address: signer.address,
4448
+ registration: registered,
4449
+ });
4450
+ expect(lifecycleEpoch, "Unable to resolve the channel join epoch from emitted registration logs.");
4314
4451
  await refreshPersistedWorkspaceAfterLocalTransaction({
4315
4452
  context,
4316
4453
  provider,
@@ -4327,6 +4464,7 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4327
4464
  storageKey,
4328
4465
  leafIndex,
4329
4466
  noteReceiveKeyMaterial,
4467
+ lifecycleEpoch,
4330
4468
  rpcUrl,
4331
4469
  });
4332
4470
 
@@ -4343,6 +4481,10 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4343
4481
  l2Address: l2Identity.l2Address,
4344
4482
  l2StorageKey: storageKey,
4345
4483
  leafIndex: leafIndex.toString(),
4484
+ epochId: lifecycleEpoch.epochId,
4485
+ lifecycleStatus: lifecycleEpoch.lifecycleStatus,
4486
+ joinedAtTxHash: lifecycleEpoch.joinedAtTxHash,
4487
+ joinedAtBlockNumber: lifecycleEpoch.joinedAtBlockNumber,
4346
4488
  joinTollBaseUnits: joinToll.toString(),
4347
4489
  joinTollTokens: ethers.formatUnits(joinToll, Number(context.workspace.canonicalAssetDecimals)),
4348
4490
  noteReceivePubKey: noteReceiveKeyMaterial.noteReceivePubKey,
@@ -4359,6 +4501,7 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4359
4501
 
4360
4502
  async function handleExitChannel({ args, provider }) {
4361
4503
  const { wallet: walletContext, walletMetadata } = loadUnlockedWalletWithMetadata(args);
4504
+ requireActiveWalletLifecycle(walletContext, "channel exit");
4362
4505
  const { signer, context, channelFund, contextResult } = await loadWalletChannelFundState({
4363
4506
  walletContext,
4364
4507
  provider,
@@ -4378,7 +4521,11 @@ async function handleExitChannel({ args, provider }) {
4378
4521
  const receipt = await waitForReceipt(
4379
4522
  await context.bridgeTokenVault.connect(ownerSigner).exitChannel(ethers.toBigInt(context.workspace.channelId)),
4380
4523
  );
4381
- const cleanup = removeLocalWalletArtifacts(walletContext.walletName, walletMetadata.network);
4524
+ const lifecycleEpoch = await markWalletEpochExited({
4525
+ walletContext,
4526
+ receipt,
4527
+ provider,
4528
+ });
4382
4529
 
4383
4530
  printJson({
4384
4531
  action: "channel exit",
@@ -4396,8 +4543,12 @@ async function handleExitChannel({ args, provider }) {
4396
4543
  gasUsed: receiptGasUsed(receipt),
4397
4544
  txUrl: explorerTxUrl(network, receipt.hash),
4398
4545
  receipt: sanitizeReceipt(receipt),
4399
- removedWalletDir: cleanup.removedWalletDir ? cleanup.walletDir : null,
4400
- removedWalletSecretFile: cleanup.removedWalletSecret ? cleanup.walletSecretFile : null,
4546
+ epochId: lifecycleEpoch.epochId,
4547
+ lifecycleStatus: lifecycleEpoch.lifecycleStatus,
4548
+ exitedAtTxHash: lifecycleEpoch.exitedAtTxHash,
4549
+ exitedAtBlockNumber: lifecycleEpoch.exitedAtBlockNumber,
4550
+ exitedAtBlockTimestampIso: lifecycleEpoch.exitedAtBlockTimestampIso,
4551
+ archivedWalletDir: walletContext.walletDir,
4401
4552
  });
4402
4553
  }
4403
4554
 
@@ -4409,6 +4560,7 @@ async function handleGrothVaultMove({ args, provider, direction }) {
4409
4560
  : "wallet withdraw-channel";
4410
4561
  emitProgress(operationName, "loading");
4411
4562
  const { wallet: walletContext } = loadUnlockedWalletWithMetadata(args);
4563
+ requireActiveWalletLifecycle(walletContext, operationName);
4412
4564
  const contextResult = await loadFreshWalletChannelContext({
4413
4565
  walletContext,
4414
4566
  provider,
@@ -4634,6 +4786,7 @@ function resolveFunctionMetadataProofForExecution({
4634
4786
 
4635
4787
  async function handleMintNotes({ args, provider }) {
4636
4788
  const { wallet } = loadUnlockedWalletWithMetadata(args);
4789
+ requireActiveWalletLifecycle(wallet, "wallet mint-notes");
4637
4790
  requireWalletSpendingCapability(wallet);
4638
4791
  const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
4639
4792
  const amountInputs = parseAmountVector(requireArg(args.amounts, "--amounts"), {
@@ -4722,6 +4875,7 @@ async function handleMintNotes({ args, provider }) {
4722
4875
 
4723
4876
  async function handleRedeemNotes({ args, provider }) {
4724
4877
  const { wallet } = loadUnlockedWalletWithMetadata(args);
4878
+ requireActiveWalletLifecycle(wallet, "wallet redeem-notes");
4725
4879
  requireWalletViewingCapability(wallet);
4726
4880
  requireWalletSpendingCapability(wallet);
4727
4881
  const noteIds = parseNoteIdVector(requireArg(args.noteIds, "--note-ids"));
@@ -4890,24 +5044,39 @@ async function exportWalletGetNotesEvidenceBundle({
4890
5044
  const outputPath = path.resolve(String(requireArg(args.exportEvidence, "--export-evidence")));
4891
5045
  ensureDir(path.dirname(outputPath));
4892
5046
 
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);
5047
+ const evidenceWalletContexts = loadWalletEpochContextsForEvidence({
5048
+ baseWalletContext: walletContext,
5049
+ networkName: walletMetadata.network,
5050
+ });
5051
+ const noteInputs = [];
5052
+ for (const candidateWalletContext of evidenceWalletContexts) {
5053
+ const notes = candidateWalletContext === walletContext
5054
+ ? [
5055
+ ...unusedTrackedNotes.map(normalizeTrackedNote),
5056
+ ...spentTrackedNotes.map(normalizeTrackedNote),
5057
+ ]
5058
+ : [
5059
+ ...Object.values(candidateWalletContext.wallet.notes.unused).map(normalizeTrackedNote),
5060
+ ...Object.values(candidateWalletContext.wallet.notes.spent).map(normalizeTrackedNote),
5061
+ ];
5062
+ for (const note of notes) {
5063
+ validateEvidenceNotePlaintext(note, candidateWalletContext.wallet);
5064
+ noteInputs.push({ note, walletContext: candidateWalletContext });
5065
+ }
4900
5066
  }
5067
+ noteInputs.sort((left, right) =>
5068
+ String(left.walletContext.wallet.walletEpochId ?? "").localeCompare(String(right.walletContext.wallet.walletEpochId ?? ""))
5069
+ || left.note.commitment.localeCompare(right.note.commitment));
4901
5070
 
4902
5071
  const txHashes = uniqueNonNull([
4903
- ...notes.map((note) => note.createdAtTxHash),
4904
- ...notes.map((note) => note.spentAtTxHash),
5072
+ ...noteInputs.map(({ note }) => note.createdAtTxHash),
5073
+ ...noteInputs.map(({ note }) => note.spentAtTxHash),
4905
5074
  ]);
4906
5075
  const transactionEvidence = await buildTransactionEvidenceMap({ provider, txHashes });
4907
5076
  const blockTimestampCache = buildBlockTimestampCache(transactionEvidence);
4908
- const noteRecords = notes.map((note) => buildEvidenceNoteRecord({
5077
+ const noteRecords = noteInputs.map(({ note, walletContext: noteWalletContext }) => buildEvidenceNoteRecord({
4909
5078
  note,
4910
- walletContext,
5079
+ walletContext: noteWalletContext,
4911
5080
  walletMetadata,
4912
5081
  context,
4913
5082
  transactionEvidence,
@@ -4917,6 +5086,7 @@ async function exportWalletGetNotesEvidenceBundle({
4917
5086
  const manifest = buildEvidenceManifest({
4918
5087
  outputPath,
4919
5088
  walletContext,
5089
+ walletContexts: evidenceWalletContexts,
4920
5090
  walletMetadata,
4921
5091
  context,
4922
5092
  noteRecords,
@@ -4932,7 +5102,7 @@ async function exportWalletGetNotesEvidenceBundle({
4932
5102
  addEvidenceJson(archive, "indexes/by-block-range.json", indexes.byBlockRange);
4933
5103
  addEvidenceJson(archive, "indexes/by-counterparty.json", indexes.byCounterparty);
4934
5104
  for (const record of noteRecords) {
4935
- addEvidenceJson(archive, `notes/${record.derived.commitment}.json`, record);
5105
+ addEvidenceJson(archive, evidenceNotePath(record), record);
4936
5106
  }
4937
5107
  for (const [txHash, txRecord] of Object.entries(transactionEvidence)) {
4938
5108
  addEvidenceJson(archive, `transactions/${txHash}.json`, txRecord.transaction);
@@ -4941,7 +5111,7 @@ async function exportWalletGetNotesEvidenceBundle({
4941
5111
  }
4942
5112
 
4943
5113
  assertEvidenceBundleDoesNotContainSecrets({
4944
- wallet: walletContext.wallet,
5114
+ wallets: evidenceWalletContexts.map((entry) => entry.wallet),
4945
5115
  payload: {
4946
5116
  manifest,
4947
5117
  indexes,
@@ -4958,6 +5128,7 @@ async function exportWalletGetNotesEvidenceBundle({
4958
5128
  format: WALLET_EVIDENCE_BUNDLE_FORMAT,
4959
5129
  formatVersion: WALLET_EVIDENCE_BUNDLE_FORMAT_VERSION,
4960
5130
  noteCount: noteRecords.length,
5131
+ walletEpochCount: evidenceWalletContexts.length,
4961
5132
  transactionCount: txHashes.length,
4962
5133
  containsNotePlaintext: true,
4963
5134
  containsSpendingKey: false,
@@ -4967,6 +5138,33 @@ async function exportWalletGetNotesEvidenceBundle({
4967
5138
  };
4968
5139
  }
4969
5140
 
5141
+ function loadWalletEpochContextsForEvidence({ baseWalletContext, networkName }) {
5142
+ const walletRoot = walletRootPath(baseWalletContext.walletName, networkName);
5143
+ const index = requireWalletIndex({
5144
+ walletRoot,
5145
+ walletName: baseWalletContext.walletName,
5146
+ networkName,
5147
+ });
5148
+ const contexts = [];
5149
+ for (const epoch of index.epochs) {
5150
+ const walletDir = walletEpochPathFromRoot(walletRoot, epoch.epochId);
5151
+ if (!walletConfigExists(walletDir)) {
5152
+ continue;
5153
+ }
5154
+ const context = loadWalletFromDir({
5155
+ walletName: baseWalletContext.walletName,
5156
+ networkName,
5157
+ walletDir,
5158
+ });
5159
+ contexts.push(context);
5160
+ }
5161
+ expect(
5162
+ contexts.length > 0,
5163
+ `Wallet ${baseWalletContext.walletName} on ${networkName} has no readable wallet epochs. Run wallet recover-workspace and then wallet get-notes --export-evidence again.`,
5164
+ );
5165
+ return contexts;
5166
+ }
5167
+
4970
5168
  function validateEvidenceNotePlaintext(note, wallet) {
4971
5169
  expect(
4972
5170
  note.owner && note.value !== null && note.salt && note.encryptedNoteValue,
@@ -5084,6 +5282,7 @@ function buildEvidenceNoteRecord({
5084
5282
  channelName: walletMetadata.channelName,
5085
5283
  channelId: walletContext.wallet.channelId,
5086
5284
  wallet: walletContext.walletName,
5285
+ ...walletLifecycleMetadata(walletContext.wallet),
5087
5286
  walletL1Address: walletContext.wallet.l1Address,
5088
5287
  walletL2Address: walletContext.wallet.l2Address,
5089
5288
  controller: context.workspace.controller,
@@ -5198,7 +5397,7 @@ function buildEvidenceIndexes(noteRecords) {
5198
5397
  },
5199
5398
  };
5200
5399
  for (const record of noteRecords) {
5201
- const pathName = `notes/${record.derived.commitment}.json`;
5400
+ const pathName = evidenceNotePath(record);
5202
5401
  indexes.byCommitment[record.derived.commitment] = pathName;
5203
5402
  indexes.byNullifier[record.derived.nullifier] = pathName;
5204
5403
  pushIndexEntry(indexes.byCreationTx, record.creation.txHash, pathName);
@@ -5227,6 +5426,21 @@ function buildEvidenceIndexes(noteRecords) {
5227
5426
  return indexes;
5228
5427
  }
5229
5428
 
5429
+ function evidenceNotePath(record) {
5430
+ expect(
5431
+ record.walletScope?.canonicalWalletName && record.walletScope?.epochId,
5432
+ "Evidence note path requires the current epoch-aware wallet scope.",
5433
+ );
5434
+ return [
5435
+ "wallets",
5436
+ slugifyPathComponent(record.walletScope.canonicalWalletName),
5437
+ "epochs",
5438
+ slugifyPathComponent(record.walletScope.epochId),
5439
+ "notes",
5440
+ `${record.derived.commitment}.json`,
5441
+ ].join("/");
5442
+ }
5443
+
5230
5444
  function pushIndexEntry(index, key, value) {
5231
5445
  if (!key) {
5232
5446
  return;
@@ -5240,6 +5454,7 @@ function pushIndexEntry(index, key, value) {
5240
5454
  function buildEvidenceManifest({
5241
5455
  outputPath,
5242
5456
  walletContext,
5457
+ walletContexts = [walletContext],
5243
5458
  walletMetadata,
5244
5459
  context,
5245
5460
  noteRecords,
@@ -5258,10 +5473,17 @@ function buildEvidenceManifest({
5258
5473
  wallet: walletContext.walletName,
5259
5474
  walletL1Address: walletContext.wallet.l1Address,
5260
5475
  walletL2Address: walletContext.wallet.l2Address,
5476
+ wallets: walletContexts.map((entry) => ({
5477
+ wallet: entry.walletName,
5478
+ ...walletLifecycleMetadata(entry.wallet),
5479
+ walletL1Address: entry.wallet.l1Address,
5480
+ walletL2Address: entry.wallet.l2Address,
5481
+ })),
5261
5482
  controller: context.workspace.controller,
5262
5483
  channelManager: context.workspace.channelManager,
5263
5484
  bridgeTokenVault: context.workspace.bridgeTokenVault,
5264
5485
  containsAllLocallyKnownNotes: true,
5486
+ containsAllLocalWalletEpochs: true,
5265
5487
  containsNotePlaintext: true,
5266
5488
  noteCount: noteRecords.length,
5267
5489
  transactionCount: txHashes.length,
@@ -5281,12 +5503,13 @@ function addEvidenceJson(archive, archivePath, value) {
5281
5503
  archive.addFile(archivePath, Buffer.from(`${JSON.stringify(normalizeCliOutput(value), null, 2)}\n`, "utf8"));
5282
5504
  }
5283
5505
 
5284
- function assertEvidenceBundleDoesNotContainSecrets({ wallet, payload }) {
5506
+ function assertEvidenceBundleDoesNotContainSecrets({ wallet = null, wallets = null, payload }) {
5285
5507
  const serialized = JSON.stringify(payload);
5286
- const forbiddenValues = [
5287
- wallet.l2PrivateKey,
5288
- wallet.noteReceivePrivateKey,
5289
- ].filter((value) => typeof value === "string" && value.length > 0);
5508
+ const walletList = wallets ?? (wallet ? [wallet] : []);
5509
+ const forbiddenValues = walletList.flatMap((entry) => [
5510
+ entry.l2PrivateKey,
5511
+ entry.noteReceivePrivateKey,
5512
+ ]).filter((value) => typeof value === "string" && value.length > 0);
5290
5513
  for (const value of forbiddenValues) {
5291
5514
  expect(
5292
5515
  !serialized.includes(value),
@@ -5301,6 +5524,7 @@ function uniqueNonNull(values) {
5301
5524
 
5302
5525
  async function handleTransferNotes({ args, provider }) {
5303
5526
  const { wallet } = loadUnlockedWalletWithMetadata(args);
5527
+ requireActiveWalletLifecycle(wallet, "wallet transfer-notes");
5304
5528
  requireWalletViewingCapability(wallet);
5305
5529
  requireWalletSpendingCapability(wallet);
5306
5530
  const { signer } = restoreWalletParticipant(wallet, provider);
@@ -5497,10 +5721,20 @@ function ensureWallet({
5497
5721
  storageKey,
5498
5722
  leafIndex,
5499
5723
  noteReceiveKeyMaterial,
5724
+ lifecycleEpoch,
5500
5725
  rpcUrl,
5501
5726
  }) {
5502
5727
  const walletName = walletNameForChannelAndAddress(channelContext.workspace.channelName, signerAddress);
5503
- const walletDir = walletPath(walletName, channelContext.workspace.network);
5728
+ const walletRoot = walletRootPath(walletName, channelContext.workspace.network);
5729
+ if (fs.existsSync(walletRoot)) {
5730
+ requireWalletIndex({
5731
+ walletRoot,
5732
+ walletName,
5733
+ networkName: channelContext.workspace.network,
5734
+ });
5735
+ }
5736
+ expect(lifecycleEpoch, "Current wallet workspace creation requires an on-chain wallet lifecycle epoch.");
5737
+ const walletDir = walletEpochPath(walletName, channelContext.workspace.network, lifecycleEpoch.epochId);
5504
5738
  expect(!walletConfigExists(walletDir), `Wallet ${walletName} already exists on ${channelContext.workspace.network}.`);
5505
5739
  ensureDir(walletDir);
5506
5740
  ensureDir(path.join(walletDir, "operations"));
@@ -5508,6 +5742,19 @@ function ensureWallet({
5508
5742
  const wallet = normalizeWallet({
5509
5743
  walletFormatVersion: WALLET_WORKSPACE_FORMAT_VERSION,
5510
5744
  name: walletName,
5745
+ canonicalWalletName: walletName,
5746
+ walletEpochId: lifecycleEpoch.epochId,
5747
+ lifecycleStatus: lifecycleEpoch.lifecycleStatus,
5748
+ joinedAtTxHash: lifecycleEpoch.joinedAtTxHash,
5749
+ joinedAtBlockNumber: lifecycleEpoch.joinedAtBlockNumber,
5750
+ joinedAtLogIndex: lifecycleEpoch.joinedAtLogIndex,
5751
+ joinedAtBlockTimestamp: lifecycleEpoch.joinedAtBlockTimestamp,
5752
+ joinedAtBlockTimestampIso: lifecycleEpoch.joinedAtBlockTimestampIso,
5753
+ exitedAtTxHash: lifecycleEpoch.exitedAtTxHash,
5754
+ exitedAtBlockNumber: lifecycleEpoch.exitedAtBlockNumber,
5755
+ exitedAtLogIndex: lifecycleEpoch.exitedAtLogIndex,
5756
+ exitedAtBlockTimestamp: lifecycleEpoch.exitedAtBlockTimestamp,
5757
+ exitedAtBlockTimestampIso: lifecycleEpoch.exitedAtBlockTimestampIso,
5511
5758
  network: channelContext.workspace.network,
5512
5759
  rpcUrl,
5513
5760
  chainId: channelContext.workspace.chainId,
@@ -5553,18 +5800,57 @@ function ensureWallet({
5553
5800
  };
5554
5801
  persistWalletKeys(context);
5555
5802
  persistWallet(context);
5556
- persistWalletMetadata(context);
5803
+ persistWalletIndexForContext(context);
5557
5804
  return context;
5558
5805
  }
5559
5806
 
5807
+ function applyWalletLifecycleEpoch(wallet, epoch) {
5808
+ wallet.canonicalWalletName = wallet.name;
5809
+ wallet.walletEpochId = epoch.epochId;
5810
+ wallet.lifecycleStatus = epoch.lifecycleStatus;
5811
+ wallet.joinedAtTxHash = epoch.joinedAtTxHash;
5812
+ wallet.joinedAtBlockNumber = epoch.joinedAtBlockNumber;
5813
+ wallet.joinedAtLogIndex = epoch.joinedAtLogIndex;
5814
+ wallet.joinedAtBlockTimestamp = epoch.joinedAtBlockTimestamp;
5815
+ wallet.joinedAtBlockTimestampIso = epoch.joinedAtBlockTimestampIso;
5816
+ wallet.exitedAtTxHash = epoch.exitedAtTxHash;
5817
+ wallet.exitedAtBlockNumber = epoch.exitedAtBlockNumber;
5818
+ wallet.exitedAtLogIndex = epoch.exitedAtLogIndex;
5819
+ wallet.exitedAtBlockTimestamp = epoch.exitedAtBlockTimestamp;
5820
+ wallet.exitedAtBlockTimestampIso = epoch.exitedAtBlockTimestampIso;
5821
+ }
5822
+
5823
+ function walletLifecycleMetadata(wallet) {
5824
+ expect(wallet.walletEpochId, "Current wallet workspace metadata is missing walletEpochId.");
5825
+ return {
5826
+ canonicalWalletName: wallet.canonicalWalletName ?? wallet.name,
5827
+ epochId: wallet.walletEpochId,
5828
+ lifecycleStatus: wallet.lifecycleStatus ?? "active",
5829
+ joinedAtTxHash: wallet.joinedAtTxHash ?? null,
5830
+ joinedAtBlockNumber: wallet.joinedAtBlockNumber ?? null,
5831
+ joinedAtLogIndex: wallet.joinedAtLogIndex ?? null,
5832
+ joinedAtBlockTimestamp: wallet.joinedAtBlockTimestamp ?? null,
5833
+ joinedAtBlockTimestampIso: wallet.joinedAtBlockTimestampIso ?? null,
5834
+ exitedAtTxHash: wallet.exitedAtTxHash ?? null,
5835
+ exitedAtBlockNumber: wallet.exitedAtBlockNumber ?? null,
5836
+ exitedAtLogIndex: wallet.exitedAtLogIndex ?? null,
5837
+ exitedAtBlockTimestamp: wallet.exitedAtBlockTimestamp ?? null,
5838
+ exitedAtBlockTimestampIso: wallet.exitedAtBlockTimestampIso ?? null,
5839
+ };
5840
+ }
5841
+
5560
5842
  function normalizeWallet(wallet) {
5561
5843
  assertWalletHasCurrentFormat(wallet, wallet.name ?? "unknown");
5844
+ expect(wallet.walletEpochId, "Current wallet metadata requires walletEpochId. Run wallet recover-workspace to rebuild this wallet.");
5562
5845
  const unusedNotes = Object.values(wallet.notes.unused).map(normalizeTrackedNote);
5563
5846
  unusedNotes.sort(compareNotesByValueDesc);
5564
5847
  const spentNotes = Object.values(wallet.notes.spent).map(normalizeTrackedNote);
5565
5848
 
5566
5849
  return {
5567
5850
  ...wallet,
5851
+ canonicalWalletName: wallet.canonicalWalletName ?? wallet.name,
5852
+ walletEpochId: wallet.walletEpochId,
5853
+ lifecycleStatus: wallet.lifecycleStatus ?? "active",
5568
5854
  canonicalAssetDecimals: Number(wallet.canonicalAssetDecimals),
5569
5855
  l2Nonce: Number(wallet.l2Nonce),
5570
5856
  l2PrivateKey: wallet.l2PrivateKey ? ethers.hexlify(wallet.l2PrivateKey) : null,
@@ -7062,7 +7348,17 @@ async function loadJoinChannelContext({ args, network, provider }) {
7062
7348
  function loadWallet(walletName, networkName) {
7063
7349
  const normalizedWalletName = requireWalletName({ wallet: walletName });
7064
7350
  const normalizedNetworkName = requireNetworkName({ network: networkName });
7065
- const walletDir = walletPath(normalizedWalletName, normalizedNetworkName);
7351
+ const walletDir = selectedWalletEpochDir(normalizedWalletName, normalizedNetworkName);
7352
+ return loadWalletFromDir({
7353
+ walletName: normalizedWalletName,
7354
+ networkName: normalizedNetworkName,
7355
+ walletDir,
7356
+ });
7357
+ }
7358
+
7359
+ function loadWalletFromDir({ walletName, networkName, walletDir }) {
7360
+ const normalizedWalletName = requireWalletName({ wallet: walletName });
7361
+ const normalizedNetworkName = requireNetworkName({ network: networkName });
7066
7362
  if (!walletConfigExists(walletDir)) {
7067
7363
  throw cliError(CLI_ERROR_CODES.UNKNOWN_WALLET, `Unknown wallet: ${normalizedWalletName} on ${normalizedNetworkName}.`);
7068
7364
  }
@@ -7077,11 +7373,11 @@ function loadWallet(walletName, networkName) {
7077
7373
  walletName: normalizedWalletName,
7078
7374
  keyKind: "viewing",
7079
7375
  });
7080
- if (spendingKey) {
7376
+ if (spendingKey && walletSpendingKeyMatchesWallet(spendingKey.metadata, rawWallet)) {
7081
7377
  rawWallet.l2PrivateKey = spendingKey.privateKey;
7082
7378
  rawWallet.l2PublicKey = spendingKey.metadata?.l2PublicKey ?? rawWallet.l2PublicKey;
7083
7379
  }
7084
- if (viewingKey) {
7380
+ if (viewingKey && walletViewingKeyMatchesWallet(viewingKey.metadata, rawWallet)) {
7085
7381
  rawWallet.noteReceivePrivateKey = viewingKey.privateKey;
7086
7382
  }
7087
7383
  assertWalletHasRequiredKeys(rawWallet, normalizedWalletName);
@@ -7104,6 +7400,18 @@ function loadWallet(walletName, networkName) {
7104
7400
  return context;
7105
7401
  }
7106
7402
 
7403
+ function walletSpendingKeyMatchesWallet(metadata, wallet) {
7404
+ return metadata?.l2Address
7405
+ && ethers.toBigInt(getAddress(metadata.l2Address)) === ethers.toBigInt(getAddress(wallet.l2Address));
7406
+ }
7407
+
7408
+ function walletViewingKeyMatchesWallet(metadata, wallet) {
7409
+ return metadata?.noteReceivePubKey?.x
7410
+ && ethers.toBigInt(normalizeBytes32Hex(metadata.noteReceivePubKey.x))
7411
+ === ethers.toBigInt(normalizeBytes32Hex(wallet.noteReceivePubKeyX))
7412
+ && Number(metadata.noteReceivePubKey.yParity) === Number(wallet.noteReceivePubKeyYParity);
7413
+ }
7414
+
7107
7415
  function loadUnlockedWalletWithMetadata(args) {
7108
7416
  const networkName = requireNetworkName(args);
7109
7417
  const wallet = loadWallet(requireWalletName(args), networkName);
@@ -7274,6 +7582,16 @@ function requireWalletViewingCapability(walletContext) {
7274
7582
  );
7275
7583
  }
7276
7584
 
7585
+ function requireActiveWalletLifecycle(walletContext, commandName) {
7586
+ expect(
7587
+ walletContext.wallet.lifecycleStatus !== "exited",
7588
+ [
7589
+ `${commandName} cannot operate on exited wallet epoch ${walletContext.wallet.walletEpochId ?? "unknown"}.`,
7590
+ "Exited wallet epochs are read-only. Use wallet get-notes or wallet get-notes --export-evidence for historical disclosure.",
7591
+ ].join(" "),
7592
+ );
7593
+ }
7594
+
7277
7595
  function walletOperationSealSecret(walletContext) {
7278
7596
  const secret = walletContext.wallet.l2PrivateKey
7279
7597
  ?? walletContext.wallet.noteReceivePrivateKey
@@ -7328,7 +7646,7 @@ function loadBridgeResources({ chainId }) {
7328
7646
  function loadWalletMetadata(walletName, networkName) {
7329
7647
  const normalizedWalletName = requireWalletName({ wallet: walletName });
7330
7648
  const normalizedNetworkName = requireNetworkName({ network: networkName });
7331
- const walletDir = walletPath(normalizedWalletName, normalizedNetworkName);
7649
+ const walletDir = selectedWalletEpochDir(normalizedWalletName, normalizedNetworkName);
7332
7650
  if (!walletConfigExists(walletDir)) {
7333
7651
  throw cliError(CLI_ERROR_CODES.UNKNOWN_WALLET, `Unknown wallet: ${normalizedWalletName} on ${normalizedNetworkName}.`);
7334
7652
  }
@@ -8912,16 +9230,18 @@ function prepareJoinWalletSecretForName({
8912
9230
  walletName,
8913
9231
  }) {
8914
9232
  const { channelName } = parseWalletName(walletName);
8915
- const walletDir = walletPath(walletName, networkName);
9233
+ const walletRoot = walletRootPath(walletName, networkName);
9234
+ const walletIndex = fs.existsSync(walletRoot)
9235
+ ? requireWalletIndex({ walletRoot, walletName, networkName })
9236
+ : null;
9237
+ const activeEpoch = walletIndex ? activeWalletEpoch(walletIndex) : null;
8916
9238
  expect(
8917
- !walletConfigExists(walletDir),
9239
+ !activeEpoch,
8918
9240
  [
8919
9241
  `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.",
9242
+ "channel join creates a new active wallet epoch.",
8924
9243
  "Use normal wallet commands for an existing active local wallet.",
9244
+ `For exited history, keep using wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account ${args.account ?? "<ACCOUNT>"}.`,
8925
9245
  ].join(" "),
8926
9246
  );
8927
9247
  const sourcePath = path.resolve(String(requireArg(args.walletSecretPath, "--wallet-secret-path")));
@@ -8932,7 +9252,7 @@ function channelWorkspacePath(networkName, name) {
8932
9252
  return workspaceDirForName(workspaceRoot, networkName, name);
8933
9253
  }
8934
9254
 
8935
- function walletPath(name, networkName) {
9255
+ function walletRootPath(name, networkName) {
8936
9256
  const walletName = String(name);
8937
9257
  const { channelName } = parseWalletName(walletName);
8938
9258
  const normalizedNetworkName = requireNetworkName({ network: networkName });
@@ -8940,6 +9260,91 @@ function walletPath(name, networkName) {
8940
9260
  return walletDirForName(workspaceWalletsDir(workspaceDir), walletName);
8941
9261
  }
8942
9262
 
9263
+ function selectedWalletEpochDir(name, networkName) {
9264
+ const root = walletRootPath(name, networkName);
9265
+ const walletName = requireWalletName({ wallet: name });
9266
+ const normalizedNetworkName = requireNetworkName({ network: networkName });
9267
+ expect(
9268
+ fs.existsSync(root),
9269
+ cliError(CLI_ERROR_CODES.UNKNOWN_WALLET, `Unknown wallet: ${walletName} on ${normalizedNetworkName}.`),
9270
+ );
9271
+ const index = requireWalletIndex({ walletRoot: root, walletName, networkName: normalizedNetworkName });
9272
+ const selected = selectedWalletEpoch(index, walletName, normalizedNetworkName);
9273
+ return walletEpochPathFromRoot(root, selected.epochId);
9274
+ }
9275
+
9276
+ function walletEpochPath(walletName, networkName, epochId) {
9277
+ return walletEpochPathFromRoot(walletRootPath(walletName, networkName), epochId);
9278
+ }
9279
+
9280
+ function walletEpochPathFromRoot(walletRoot, epochId) {
9281
+ return path.join(walletRoot, "epochs", slugifyPathComponent(epochId));
9282
+ }
9283
+
9284
+ function walletIndexMetadataPath(walletRoot) {
9285
+ return path.join(walletRoot, "wallet-index.metadata.json");
9286
+ }
9287
+
9288
+ function readWalletIndexIfExists(walletRoot) {
9289
+ const indexPath = walletIndexMetadataPath(walletRoot);
9290
+ if (!fs.existsSync(indexPath)) {
9291
+ return null;
9292
+ }
9293
+ return normalizeWalletIndex(readJson(indexPath));
9294
+ }
9295
+
9296
+ function requireWalletIndex({ walletRoot, walletName, networkName }) {
9297
+ const index = readWalletIndexIfExists(walletRoot);
9298
+ expect(index, currentWalletIndexRequiredMessage({ walletName, networkName, walletRoot }));
9299
+ return index;
9300
+ }
9301
+
9302
+ function selectedWalletEpoch(index, walletName, networkName) {
9303
+ const selected = activeWalletEpoch(index) ?? latestWalletEpoch(index);
9304
+ expect(
9305
+ selected,
9306
+ `Wallet ${walletName} on ${networkName} has no epoch entries. Run wallet recover-workspace to rebuild the workspace in the current format.`,
9307
+ );
9308
+ return selected;
9309
+ }
9310
+
9311
+ function currentWalletIndexRequiredMessage({ walletName, networkName, walletRoot }) {
9312
+ const channelName = parseWalletName(walletName).channelName;
9313
+ return [
9314
+ `Current wallet index is required for ${walletName} on ${networkName}: ${walletRoot}.`,
9315
+ `Run wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account <ACCOUNT> to rebuild the workspace.`,
9316
+ ].join(" ");
9317
+ }
9318
+
9319
+ function activeWalletEpoch(index) {
9320
+ const activeEpochId = index?.activeEpochId ?? null;
9321
+ return activeEpochId
9322
+ ? (index.epochs ?? []).find((epoch) => epoch.epochId === activeEpochId && epoch.lifecycleStatus === "active") ?? null
9323
+ : null;
9324
+ }
9325
+
9326
+ function latestWalletEpoch(index) {
9327
+ const epochs = [...(index?.epochs ?? [])];
9328
+ epochs.sort((left, right) =>
9329
+ Number(right.joinedAtBlockNumber ?? 0) - Number(left.joinedAtBlockNumber ?? 0)
9330
+ || String(right.epochId).localeCompare(String(left.epochId)));
9331
+ return epochs[0] ?? null;
9332
+ }
9333
+
9334
+ function normalizeWalletIndex(index) {
9335
+ expect(index?.format === WALLET_INDEX_FORMAT, "Invalid wallet index format.");
9336
+ expect(Number(index.formatVersion) === WALLET_INDEX_FORMAT_VERSION, "Unsupported wallet index format version.");
9337
+ expect(Array.isArray(index.epochs), "Wallet index is missing epochs[].");
9338
+ return {
9339
+ ...index,
9340
+ epochs: index.epochs.map((epoch) => ({
9341
+ ...epoch,
9342
+ epochId: String(epoch.epochId),
9343
+ lifecycleStatus: epoch.lifecycleStatus === "active" ? "active" : "exited",
9344
+ })),
9345
+ };
9346
+ }
9347
+
8943
9348
  function accountPrivateKeyPath(networkName, accountName) {
8944
9349
  return path.join(
8945
9350
  secretRoot,
@@ -9013,7 +9418,8 @@ function resolveWalletPathCandidates(walletName) {
9013
9418
  "wallets",
9014
9419
  walletSlug,
9015
9420
  );
9016
- if (walletConfigExists(candidate)) {
9421
+ if (fs.existsSync(candidate)) {
9422
+ requireWalletIndex({ walletRoot: candidate, walletName, networkName: entry.name });
9017
9423
  candidates.push(candidate);
9018
9424
  }
9019
9425
  }
@@ -9044,12 +9450,24 @@ function listLocalWallets({ networkFilter = null, channelFilter = null } = {}) {
9044
9450
  if (!walletEntry.isDirectory()) {
9045
9451
  continue;
9046
9452
  }
9047
- const walletDir = path.join(walletsDir, walletEntry.name);
9453
+ const walletRoot = path.join(walletsDir, walletEntry.name);
9454
+ const walletIndex = requireWalletIndex({
9455
+ walletRoot,
9456
+ walletName: walletEntry.name,
9457
+ networkName: networkEntry.name,
9458
+ });
9459
+ const selectedEpoch = selectedWalletEpoch(walletIndex, walletEntry.name, networkEntry.name);
9460
+ const walletDir = walletEpochPathFromRoot(walletRoot, selectedEpoch.epochId);
9048
9461
  wallets.push({
9049
9462
  wallet: walletEntry.name,
9050
9463
  network: networkEntry.name,
9051
9464
  channelName: channelEntry.name,
9052
9465
  walletDir,
9466
+ walletRoot,
9467
+ activeEpochId: walletIndex?.activeEpochId ?? null,
9468
+ selectedEpochId: selectedEpoch?.epochId ?? null,
9469
+ lifecycleStatus: selectedEpoch.lifecycleStatus,
9470
+ epochs: walletIndex.epochs,
9053
9471
  metadataPath: walletNotesMetadataPath(walletDir),
9054
9472
  hasMetadata: fs.existsSync(walletNotesMetadataPath(walletDir)),
9055
9473
  hasEncryptedWallet: false,
@@ -9077,7 +9495,7 @@ function privateStateCliDataRoot() {
9077
9495
 
9078
9496
  function resolveExportWalletInfo({ networkName, walletName }) {
9079
9497
  resolveCliNetwork(networkName);
9080
- const walletDir = walletPath(walletName, networkName);
9498
+ const walletDir = selectedWalletEpochDir(walletName, networkName);
9081
9499
  return {
9082
9500
  wallet: walletName,
9083
9501
  network: networkName,
@@ -9092,7 +9510,7 @@ function resolveExportWalletInfo({ networkName, walletName }) {
9092
9510
  function normalizeExportWalletInfo(walletInfo) {
9093
9511
  const wallet = requireWalletName({ wallet: walletInfo.wallet });
9094
9512
  const network = requireNetworkName({ network: walletInfo.network });
9095
- const walletDir = walletInfo.walletDir ?? walletPath(wallet, network);
9513
+ const walletDir = walletInfo.walletDir ?? selectedWalletEpochDir(wallet, network);
9096
9514
  const metadataPath = walletNotesMetadataPath(walletDir);
9097
9515
  const metadata = readJsonIfExists(metadataPath);
9098
9516
  const channelName = metadata?.channelName ?? walletInfo.channelName ?? parseWalletName(wallet).channelName;
@@ -9119,6 +9537,13 @@ function walletBackupExportFilePaths(walletInfo) {
9119
9537
  const walletFiles = [
9120
9538
  walletNotesMetadataPath(walletInfo.walletDir),
9121
9539
  ];
9540
+ const walletRoot = walletRootPath(walletInfo.wallet, walletInfo.network);
9541
+ requireWalletIndex({
9542
+ walletRoot,
9543
+ walletName: walletInfo.wallet,
9544
+ networkName: walletInfo.network,
9545
+ });
9546
+ walletFiles.push(walletIndexMetadataPath(walletRoot));
9122
9547
  for (const metadataPath of [
9123
9548
  walletViewingKeyMetadataPath(walletInfo.walletDir),
9124
9549
  walletSpendingKeyMetadataPath(walletInfo.walletDir),
@@ -9172,9 +9597,18 @@ function validateWalletExportManifest(manifest) {
9172
9597
  validateWalletArchivePath(filePath);
9173
9598
  }
9174
9599
  for (const wallet of manifest.wallets) {
9175
- requireNetworkName({ network: wallet.network });
9176
- requireWalletName({ wallet: wallet.wallet });
9600
+ const networkName = requireNetworkName({ network: wallet.network });
9601
+ const walletName = requireWalletName({ wallet: wallet.wallet });
9177
9602
  requireArg(wallet.channelName, "wallets[].channelName");
9603
+ const walletRoot = walletRootPath(walletName, networkName);
9604
+ const expectedIndexPath = archivePathForLocalCliFile(walletIndexMetadataPath(walletRoot));
9605
+ expect(
9606
+ uniqueFiles.has(expectedIndexPath),
9607
+ [
9608
+ "Wallet import ZIP must include the current wallet index metadata.",
9609
+ "Run wallet recover-workspace with the current CLI, then export a new backup.",
9610
+ ].join(" "),
9611
+ );
9178
9612
  }
9179
9613
  }
9180
9614
 
@@ -9276,21 +9710,8 @@ function assertAllowedCommandKeys(args, commandName, allowedKeys, acceptedUsage)
9276
9710
  );
9277
9711
  }
9278
9712
 
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
9713
  function assertWalletChannelMoveArgs(args, commandName) {
9293
- assertWalletSecretArgs(args, commandName, ["amount"], "--wallet, --network, and --amount");
9714
+ assertAllowedCommandSchema(args, commandName);
9294
9715
  assertActionImpactArg(args, COMMAND_ARG_SCHEMAS[commandName]?.label ?? commandName);
9295
9716
  }
9296
9717
 
@@ -9404,7 +9825,7 @@ function assertActionImpactArg(args, commandName) {
9404
9825
  }
9405
9826
 
9406
9827
  function assertWalletGetNotesArgs(args) {
9407
- assertWalletSecretArgs(args, "wallet-get-notes");
9828
+ assertAllowedCommandSchema(args, "wallet-get-notes");
9408
9829
  if (args.exportEvidence !== undefined) {
9409
9830
  requireArg(args.exportEvidence, "--export-evidence");
9410
9831
  if (args.acknowledgeFullNotePlaintextExport !== true) {
@@ -9459,12 +9880,8 @@ function assertAccountGetBridgeFundArgs(args) {
9459
9880
  assertAllowedCommandSchema(args, "account-get-bridge-fund");
9460
9881
  }
9461
9882
 
9462
- function assertExplicitSignerCommandArgs(args, commandName) {
9463
- assertAllowedCommandSchema(args, commandName);
9464
- }
9465
-
9466
9883
  function assertRecoverWalletArgs(args) {
9467
- assertExplicitSignerCommandArgs(args, "wallet-recover-workspace");
9884
+ assertAllowedCommandSchema(args, "wallet-recover-workspace");
9468
9885
  if (args.fromGenesis !== undefined && args.fromGenesis !== true) {
9469
9886
  throw new Error("wallet recover-workspace option --from-genesis does not accept a value.");
9470
9887
  }
@@ -9476,7 +9893,7 @@ function assertJoinChannelArgs(args) {
9476
9893
  }
9477
9894
 
9478
9895
  function assertWalletGetMetaArgs(args) {
9479
- assertWalletSecretArgs(args, "wallet-get-meta");
9896
+ assertAllowedCommandSchema(args, "wallet-get-meta");
9480
9897
  }
9481
9898
 
9482
9899
  function assertAccountGetL1AddressArgs(args) {
@@ -9521,17 +9938,17 @@ function assertWithdrawBridgeArgs(args) {
9521
9938
  }
9522
9939
 
9523
9940
  function assertWalletGetChannelFundArgs(args) {
9524
- assertWalletSecretArgs(args, "wallet-get-channel-fund");
9941
+ assertAllowedCommandSchema(args, "wallet-get-channel-fund");
9525
9942
  }
9526
9943
 
9527
9944
  function assertExitChannelArgs(args) {
9528
- assertWalletSecretArgs(args, "channel-exit");
9945
+ assertAllowedCommandSchema(args, "channel-exit");
9529
9946
  }
9530
9947
 
9531
9948
  function createWalletOperationDir(walletName, networkName, suffix) {
9532
9949
  const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
9533
9950
  const operationDir = path.join(
9534
- walletPath(walletName, networkName),
9951
+ selectedWalletEpochDir(walletName, networkName),
9535
9952
  "operations",
9536
9953
  `${timestamp}-${slugifyPathComponent(suffix)}`,
9537
9954
  );
@@ -9576,8 +9993,73 @@ function persistWalletKeys(context) {
9576
9993
  }
9577
9994
  }
9578
9995
 
9579
- function persistWalletMetadata(context) {
9580
- persistWallet(context);
9996
+ function persistWalletIndexForContext(context) {
9997
+ const walletRoot = walletRootPath(context.walletName, context.wallet.network);
9998
+ ensureDir(walletRoot);
9999
+ const currentIndex = readWalletIndexIfExists(walletRoot) ?? {
10000
+ format: WALLET_INDEX_FORMAT,
10001
+ formatVersion: WALLET_INDEX_FORMAT_VERSION,
10002
+ canonicalWalletName: context.walletName,
10003
+ network: context.wallet.network,
10004
+ channelName: context.wallet.channelName,
10005
+ channelId: context.wallet.channelId,
10006
+ l1Address: context.wallet.l1Address,
10007
+ activeEpochId: null,
10008
+ epochs: [],
10009
+ };
10010
+ const epoch = walletEpochSummaryFromWallet(context.wallet);
10011
+ const epochs = [
10012
+ ...currentIndex.epochs.filter((entry) => entry.epochId !== epoch.epochId),
10013
+ epoch,
10014
+ ].sort((left, right) =>
10015
+ Number(left.joinedAtBlockNumber ?? 0) - Number(right.joinedAtBlockNumber ?? 0)
10016
+ || String(left.epochId).localeCompare(String(right.epochId)));
10017
+ const activeEpoch = epochs.find((entry) => entry.lifecycleStatus === "active") ?? null;
10018
+ const nextIndex = {
10019
+ ...currentIndex,
10020
+ canonicalWalletName: context.walletName,
10021
+ network: context.wallet.network,
10022
+ channelName: context.wallet.channelName,
10023
+ channelId: context.wallet.channelId,
10024
+ l1Address: context.wallet.l1Address,
10025
+ activeEpochId: activeEpoch?.epochId ?? null,
10026
+ epochs,
10027
+ };
10028
+ writeJson(walletIndexMetadataPath(walletRoot), nextIndex);
10029
+ }
10030
+
10031
+ async function markWalletEpochExited({ walletContext, receipt, provider }) {
10032
+ const block = receipt?.blockNumber === null || receipt?.blockNumber === undefined
10033
+ ? null
10034
+ : await provider.getBlock(receipt.blockNumber).catch(() => null);
10035
+ const exitedAtBlockTimestamp = block?.timestamp ?? null;
10036
+ walletContext.wallet.lifecycleStatus = "exited";
10037
+ walletContext.wallet.exitedAtTxHash = receipt?.hash ?? null;
10038
+ walletContext.wallet.exitedAtBlockNumber = receipt?.blockNumber ?? null;
10039
+ walletContext.wallet.exitedAtLogIndex = firstReceiptLogIndex(receipt);
10040
+ walletContext.wallet.exitedAtBlockTimestamp = exitedAtBlockTimestamp;
10041
+ walletContext.wallet.exitedAtBlockTimestampIso = exitedAtBlockTimestamp === null
10042
+ ? null
10043
+ : new Date(Number(exitedAtBlockTimestamp) * 1000).toISOString();
10044
+ persistWallet(walletContext);
10045
+ persistWalletIndexForContext(walletContext);
10046
+ return walletEpochSummaryFromWallet(walletContext.wallet);
10047
+ }
10048
+
10049
+ function firstReceiptLogIndex(receipt) {
10050
+ const first = receipt?.logs?.[0] ?? null;
10051
+ return first?.index ?? first?.logIndex ?? null;
10052
+ }
10053
+
10054
+ function walletEpochSummaryFromWallet(wallet) {
10055
+ const lifecycle = walletLifecycleMetadata(wallet);
10056
+ return {
10057
+ ...lifecycle,
10058
+ walletDirName: slugifyPathComponent(lifecycle.epochId),
10059
+ l2Address: wallet.l2Address,
10060
+ l2StorageKey: wallet.l2StorageKey,
10061
+ leafIndex: wallet.leafIndex,
10062
+ };
9581
10063
  }
9582
10064
 
9583
10065
  function sanitizeWalletForNotesMetadata(wallet) {
@@ -9633,6 +10115,7 @@ function buildWalletSpendingKeyMetadata(wallet) {
9633
10115
  walletFormatVersion: WALLET_WORKSPACE_FORMAT_VERSION,
9634
10116
  network: wallet.network,
9635
10117
  wallet: wallet.name,
10118
+ ...walletLifecycleMetadata(wallet),
9636
10119
  channelName: wallet.channelName,
9637
10120
  channelId: wallet.channelId,
9638
10121
  l1Address: wallet.l1Address,
@@ -9650,6 +10133,7 @@ function buildWalletViewingKeyMetadata(wallet) {
9650
10133
  walletFormatVersion: WALLET_WORKSPACE_FORMAT_VERSION,
9651
10134
  network: wallet.network,
9652
10135
  wallet: wallet.name,
10136
+ ...walletLifecycleMetadata(wallet),
9653
10137
  channelName: wallet.channelName,
9654
10138
  channelId: wallet.channelId,
9655
10139
  l1Address: wallet.l1Address,