@tokamak-private-dapps/private-state-cli 1.2.1 → 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.
@@ -7,11 +7,11 @@ import process from "node:process";
7
7
  import readline from "node:readline/promises";
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { createRequire } from "node:module";
10
+ import { pathToFileURL } from "node:url";
10
11
  import AdmZip from "adm-zip";
11
12
  import {
12
13
  createHash,
13
14
  createCipheriv,
14
- createDecipheriv,
15
15
  randomBytes,
16
16
  scryptSync,
17
17
  } from "node:crypto";
@@ -61,7 +61,6 @@ import {
61
61
  workspaceDirForName,
62
62
  workspaceWalletsDir,
63
63
  walletDirForName,
64
- walletMetadataPathForDir,
65
64
  walletNameForChannelAndAddress,
66
65
  } from "./lib/private-state-cli-shared.mjs";
67
66
  import {
@@ -131,11 +130,19 @@ const secretRoot = path.resolve(os.homedir(), "tokamak-private-channels", "secre
131
130
  const flatDeploymentArtifactPathsByChainId = new Map();
132
131
  const PRIVATE_STATE_UNINSTALL_CONFIRMATION =
133
132
  "I understand that the wallet secrets deleted due to this decision cannot be recovered";
133
+ const ACTION_IMPACT_CONFIRMATION =
134
+ "I understand the public and private impact of this action";
134
135
  const PRIVATE_STATE_CLI_PACKAGE_NAME = privateStateCliPackageJson.name;
135
136
  const GROTH16_PACKAGE_NAME = "@tokamak-private-dapps/groth16";
136
137
  const TOKAMAK_ZKEVM_CLI_PACKAGE_NAME = "@tokamak-zk-evm/cli";
137
- const WALLET_EXPORT_FORMAT = "tokamak-private-state-wallet-export";
138
- const WALLET_EXPORT_FORMAT_VERSION = 1;
138
+ const WALLET_BACKUP_EXPORT_FORMAT = "tokamak-private-state-wallet-backup-export";
139
+ const WALLET_KEY_EXPORT_FORMAT = "tokamak-private-state-wallet-key-export";
140
+ const WALLET_INDEX_FORMAT = "tokamak-private-state-wallet-index";
141
+ const WALLET_EVIDENCE_BUNDLE_FORMAT = "tokamak-private-state-raw-evidence-bundle";
142
+ const WALLET_EXPORT_FORMAT_VERSION = 2;
143
+ const WALLET_INDEX_FORMAT_VERSION = 1;
144
+ const WALLET_EVIDENCE_BUNDLE_FORMAT_VERSION = 2;
145
+ const WALLET_WORKSPACE_FORMAT_VERSION = 2;
139
146
  const CHANNEL_WORKSPACE_MIRROR_PROTOCOL_VERSION = 2;
140
147
  const CHANNEL_WORKSPACE_MIRROR_MANIFEST_PATH_PREFIX =
141
148
  ".well-known/tokamak-private-state/channel-workspace";
@@ -151,8 +158,6 @@ let activeCliArgs = {};
151
158
  const CLI_ERROR_CODES = Object.freeze({
152
159
  MISSING_RPC_URL: "MISSING_RPC_URL",
153
160
  UNKNOWN_WALLET: "UNKNOWN_WALLET",
154
- MISSING_WALLET_SECRET: "MISSING_WALLET_SECRET",
155
- WALLET_DECRYPT_FAILED: "WALLET_DECRYPT_FAILED",
156
161
  MISSING_DEPLOYMENT_ARTIFACTS: "MISSING_DEPLOYMENT_ARTIFACTS",
157
162
  MISSING_CHANNEL_REGISTRATION: "MISSING_CHANNEL_REGISTRATION",
158
163
  STALE_WORKSPACE: "STALE_WORKSPACE",
@@ -247,6 +252,206 @@ function printImmutableChannelPolicyWarning({
247
252
  console.error(details.join("\n"));
248
253
  }
249
254
 
255
+ const ACTION_IMPACT_SUMMARIES = Object.freeze({
256
+ "account-deposit-bridge": {
257
+ display: "account deposit-bridge",
258
+ l1PublicEvent: "Yes. ERC-20 approval and bridge vault funding transactions are public L1 events.",
259
+ privateNoteState: "No. This action only moves canonical tokens into the shared bridge vault.",
260
+ publicFields: ({ l1Address, amountInput, bridgeTokenVault }) => [
261
+ `L1 account: ${l1Address}`,
262
+ `Bridge token vault: ${bridgeTokenVault}`,
263
+ `Amount: ${amountInput}`,
264
+ "Approval and funding transaction hashes, block numbers, and event logs.",
265
+ ],
266
+ notPublic: [
267
+ "No private note owner, value, salt, counterparty, or note provenance is created by this action.",
268
+ ],
269
+ noteProvenance: "Not applicable for this bridge-edge action.",
270
+ cexWarning: "Do not use a centralized-exchange controlled address as a self-custody bridge source.",
271
+ policy: "No channel policy is accepted by this action.",
272
+ },
273
+ "account-withdraw-bridge": {
274
+ display: "account withdraw-bridge",
275
+ l1PublicEvent: "Yes. The bridge withdrawal transaction and claim event are public L1 data.",
276
+ privateNoteState: "No. This action claims shared bridge-vault balance to the local L1 account.",
277
+ publicFields: ({ l1Address, amountInput, bridgeTokenVault }) => [
278
+ `L1 recipient/account: ${l1Address}`,
279
+ `Bridge token vault: ${bridgeTokenVault}`,
280
+ `Amount: ${amountInput}`,
281
+ "Withdrawal transaction hash, block number, and event log.",
282
+ ],
283
+ notPublic: [
284
+ "The private note path that produced any prior channel balance is not reconstructed from this event alone.",
285
+ ],
286
+ noteProvenance: "Public observers cannot reconstruct prior internal note provenance from this withdrawal alone.",
287
+ cexWarning: "Do not use a centralized-exchange deposit address as the direct bridge withdrawal target unless the user has explicitly accepted the compliance implications. Prefer a self-custody L1 wallet.",
288
+ policy: "No channel policy is accepted by this action.",
289
+ },
290
+ "channel-join": {
291
+ display: "channel join",
292
+ l1PublicEvent: "Yes. Channel join and token-vault registration transactions are public L1 data.",
293
+ privateNoteState: "No. This action registers identity and note-receive metadata; it does not create or spend notes.",
294
+ publicFields: ({ l1Address, l2Address, noteReceivePubKey, joinToll, channelName, channelId }) => [
295
+ `Channel: ${channelName} (${channelId})`,
296
+ `L1 account: ${l1Address}`,
297
+ `L2 address: ${l2Address}`,
298
+ `Note-receive public key: ${noteReceivePubKey}`,
299
+ `Join toll: ${joinToll}`,
300
+ ],
301
+ notPublic: [
302
+ "Wallet secret, L2 spending private key, note-receive private key, and future note plaintext.",
303
+ ],
304
+ noteProvenance: "Future note provenance is not made public by joining.",
305
+ policy: "Joining accepts the displayed immutable channel policy snapshot.",
306
+ },
307
+ "wallet-deposit-channel": {
308
+ display: "wallet deposit-channel",
309
+ l1PublicEvent: "Yes. The proof-backed channel accounting transaction is public L1 data.",
310
+ privateNoteState: "No. This action increases liquid channel accounting balance; it does not create notes.",
311
+ publicFields: ({ l1Address, l2Address, amountInput, channelName, channelId }) => [
312
+ `Channel: ${channelName} (${channelId})`,
313
+ `L1 submitter/account: ${l1Address}`,
314
+ `Registered L2 address: ${l2Address}`,
315
+ `Amount: ${amountInput}`,
316
+ "Transaction hash, accepted proof surface, and accounting root update.",
317
+ ],
318
+ notPublic: [
319
+ "No note owner, value, salt, counterparty, or note provenance is created by this action.",
320
+ ],
321
+ noteProvenance: "Not applicable; this action does not transfer note ownership.",
322
+ policy: "This action uses the channel policy snapshot accepted by the registered wallet.",
323
+ },
324
+ "wallet-withdraw-channel": {
325
+ display: "wallet withdraw-channel",
326
+ l1PublicEvent: "Yes. The proof-backed channel accounting transaction is public L1 data.",
327
+ privateNoteState: "No. This action decreases liquid channel accounting balance; it does not spend notes directly.",
328
+ publicFields: ({ l1Address, l2Address, amountInput, channelName, channelId }) => [
329
+ `Channel: ${channelName} (${channelId})`,
330
+ `L1 submitter/account: ${l1Address}`,
331
+ `Registered L2 address: ${l2Address}`,
332
+ `Amount: ${amountInput}`,
333
+ "Transaction hash, accepted proof surface, and accounting root update.",
334
+ ],
335
+ notPublic: [
336
+ "Any prior private note path that produced the liquid balance is not reconstructed from this action alone.",
337
+ ],
338
+ noteProvenance: "Public observers cannot reconstruct prior internal note provenance from this withdrawal-channel action alone.",
339
+ policy: "This action uses the channel policy snapshot accepted by the registered wallet.",
340
+ },
341
+ "wallet-mint-notes": {
342
+ display: "wallet mint-notes",
343
+ l1PublicEvent: "Yes. executeChannelTransaction, accepted transition, commitments, encrypted note events, and root updates are public L1 data.",
344
+ privateNoteState: "Yes. This action creates private-state notes tracked by the local wallet.",
345
+ publicFields: ({ l1Address, l2Address, amounts, channelName, channelId }) => [
346
+ `Channel: ${channelName} (${channelId})`,
347
+ `L1 submitter/account: ${l1Address}`,
348
+ `Registered L2 address: ${l2Address}`,
349
+ `Requested note amounts: ${amounts}`,
350
+ "New commitments, encrypted note-delivery events, transaction hash, and root updates.",
351
+ ],
352
+ notPublic: [
353
+ "Note owner, value, salt, plaintext note contents, and later note provenance are not public by default.",
354
+ ],
355
+ noteProvenance: "Public observers cannot reconstruct later note provenance without user-controlled disclosure.",
356
+ policy: "This action uses the channel policy snapshot accepted by the registered wallet.",
357
+ },
358
+ "wallet-transfer-notes": {
359
+ display: "wallet transfer-notes",
360
+ l1PublicEvent: "Yes. executeChannelTransaction, nullifiers, output commitments, encrypted note events, and root updates are public L1 data.",
361
+ privateNoteState: "Yes. This action spends selected input notes and creates output notes.",
362
+ publicFields: ({ l1Address, l2Address, noteIds, amounts, channelName, channelId }) => [
363
+ `Channel: ${channelName} (${channelId})`,
364
+ `L1 submitter/account: ${l1Address}`,
365
+ `Registered L2 address: ${l2Address}`,
366
+ `Input note commitments: ${noteIds}`,
367
+ `Output amounts supplied to the CLI: ${amounts}`,
368
+ "Input nullifiers, output commitments, encrypted note-delivery events, transaction hash, and root updates.",
369
+ ],
370
+ notPublic: [
371
+ "Sender-recipient relationship, recipient note plaintext, and note provenance are not public by default.",
372
+ ],
373
+ noteProvenance: "Public observers cannot reconstruct private note counterparty relationships or provenance from public contract state alone.",
374
+ policy: "This action uses the channel policy snapshot accepted by the registered wallet.",
375
+ },
376
+ "wallet-redeem-notes": {
377
+ display: "wallet redeem-notes",
378
+ l1PublicEvent: "Yes. executeChannelTransaction, nullifier usage, accounting update, and root updates are public L1 data.",
379
+ privateNoteState: "Yes. This action consumes selected notes and credits liquid channel accounting balance.",
380
+ publicFields: ({ l1Address, l2Address, noteIds, channelName, channelId }) => [
381
+ `Channel: ${channelName} (${channelId})`,
382
+ `L1 submitter/account: ${l1Address}`,
383
+ `Registered L2 address: ${l2Address}`,
384
+ `Input note commitments: ${noteIds}`,
385
+ "Input nullifiers, accounting update, transaction hash, and root updates.",
386
+ ],
387
+ notPublic: [
388
+ "The prior path by which the redeemed note was received is not public by default.",
389
+ ],
390
+ noteProvenance: "Public observers cannot reconstruct prior internal note provenance from this redeem action alone.",
391
+ policy: "This action uses the channel policy snapshot accepted by the registered wallet.",
392
+ },
393
+ });
394
+
395
+ async function requireActionImpactAcknowledgement(commandId, args, details = {}) {
396
+ const summary = ACTION_IMPACT_SUMMARIES[commandId];
397
+ if (!summary) {
398
+ throw new Error(`Missing action-impact summary for ${commandId}.`);
399
+ }
400
+ printActionImpactSummary(summary, details);
401
+ if (args.acknowledgeActionImpact === true) {
402
+ return;
403
+ }
404
+ if (args.acknowledgeActionImpact !== undefined) {
405
+ throw new Error(`${summary.display} option --acknowledge-action-impact does not accept a value.`);
406
+ }
407
+ if (!process.stdin.isTTY) {
408
+ throw new Error(`${summary.display} requires --acknowledge-action-impact after reviewing the action-impact warning.`);
409
+ }
410
+ const prompt = [
411
+ `Type exactly: ${ACTION_IMPACT_CONFIRMATION}`,
412
+ "> ",
413
+ ].join("\n");
414
+ const rl = readline.createInterface({
415
+ input: process.stdin,
416
+ output: process.stderr,
417
+ terminal: process.stdin.isTTY && process.stderr.isTTY,
418
+ });
419
+ try {
420
+ const answer = await rl.question(prompt);
421
+ if (answer !== ACTION_IMPACT_CONFIRMATION) {
422
+ throw new Error(`${summary.display} action-impact confirmation did not match. No transaction was submitted.`);
423
+ }
424
+ } finally {
425
+ rl.close();
426
+ }
427
+ }
428
+
429
+ function printActionImpactSummary(summary, details) {
430
+ const lines = [
431
+ `ACTION IMPACT SUMMARY: ${summary.display}`,
432
+ `- L1 public event: ${summary.l1PublicEvent}`,
433
+ `- Private note state change: ${summary.privateNoteState}`,
434
+ "- Public addresses and amounts:",
435
+ ...normalizeImpactLines(summary.publicFields, details).map((line) => ` - ${line}`),
436
+ "- Not public by default:",
437
+ ...normalizeImpactLines(summary.notPublic, details).map((line) => ` - ${line}`),
438
+ `- Note provenance: ${summary.noteProvenance}`,
439
+ `- Illegal-use prohibition: Do not use this command for money laundering, sanctions evasion, terrorist financing, illegal gambling, criminal-proceeds concealment, or regulatory evasion.`,
440
+ `- Secret recovery: Losing wallet secrets, viewing keys, or spending keys can prevent note discovery or note use. The CLI cannot recover lost secrets.`,
441
+ `- Channel policy: ${summary.policy}`,
442
+ ];
443
+ if (summary.cexWarning) {
444
+ lines.push(`- CEX address warning: ${summary.cexWarning}`);
445
+ }
446
+ lines.push(`- Confirmation: pass --acknowledge-action-impact or type the exact confirmation phrase when prompted.`);
447
+ console.error(lines.join("\n"));
448
+ }
449
+
450
+ function normalizeImpactLines(value, details) {
451
+ const resolved = typeof value === "function" ? value(details) : value;
452
+ return Array.isArray(resolved) ? resolved : [resolved];
453
+ }
454
+
250
455
  function normalizeDAppPolicySnapshot({
251
456
  dappId,
252
457
  metadataDigest,
@@ -386,6 +591,12 @@ async function main() {
386
591
  return;
387
592
  }
388
593
 
594
+ if (args.command === "investigator") {
595
+ assertInvestigatorArgs(args);
596
+ handleInvestigator();
597
+ return;
598
+ }
599
+
389
600
  if (args.command === "account-get-l1-address") {
390
601
  assertAccountGetL1AddressArgs(args);
391
602
  handleAccountGetL1Address({ args });
@@ -404,15 +615,39 @@ async function main() {
404
615
  return;
405
616
  }
406
617
 
407
- if (args.command === "wallet-export") {
408
- assertWalletExportArgs(args);
409
- handleWalletExport({ args });
618
+ if (args.command === "wallet-export-backup") {
619
+ assertWalletExportBackupArgs(args);
620
+ handleWalletExportBackup({ args });
621
+ return;
622
+ }
623
+
624
+ if (args.command === "wallet-export-viewing-key") {
625
+ assertWalletExportKeyArgs(args, "wallet-export-viewing-key");
626
+ handleWalletExportKey({ args, keyKind: "viewing" });
627
+ return;
628
+ }
629
+
630
+ if (args.command === "wallet-export-spending-key") {
631
+ assertWalletExportKeyArgs(args, "wallet-export-spending-key");
632
+ handleWalletExportKey({ args, keyKind: "spending" });
633
+ return;
634
+ }
635
+
636
+ if (args.command === "wallet-import-backup") {
637
+ assertWalletImportBackupArgs(args);
638
+ handleWalletImportBackup({ args });
639
+ return;
640
+ }
641
+
642
+ if (args.command === "wallet-import-viewing-key") {
643
+ assertWalletImportKeyArgs(args, "wallet-import-viewing-key");
644
+ handleWalletImportKey({ args, keyKind: "viewing" });
410
645
  return;
411
646
  }
412
647
 
413
- if (args.command === "wallet-import") {
414
- assertWalletImportArgs(args);
415
- handleWalletImport({ args });
648
+ if (args.command === "wallet-import-spending-key") {
649
+ assertWalletImportKeyArgs(args, "wallet-import-spending-key");
650
+ handleWalletImportKey({ args, keyKind: "spending" });
416
651
  return;
417
652
  }
418
653
 
@@ -2161,6 +2396,11 @@ async function handleDepositBridge({ args, network, provider }) {
2161
2396
  const bridgeVaultContext = await loadBridgeVaultContext({ provider, chainId: network.chainId });
2162
2397
  const amountInput = requireArg(args.amount, "--amount");
2163
2398
  const amount = parseTokenAmount(amountInput, Number(bridgeVaultContext.canonicalAssetDecimals));
2399
+ await requireActionImpactAcknowledgement("account-deposit-bridge", args, {
2400
+ l1Address: signer.address,
2401
+ amountInput,
2402
+ bridgeTokenVault: bridgeVaultContext.bridgeTokenVaultAddress,
2403
+ });
2164
2404
  const bridgeTokenVault = new Contract(
2165
2405
  bridgeVaultContext.bridgeTokenVaultAddress,
2166
2406
  bridgeVaultContext.bridgeAbiManifest.contracts.bridgeTokenVault.abi,
@@ -2223,10 +2463,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2223
2463
  const channelName = requireArg(args.channelName, "--channel-name");
2224
2464
  const signer = requireL1Signer(args, provider);
2225
2465
  const walletName = walletNameForChannelAndAddress(channelName, signer.address);
2226
- const walletSecret = resolveWalletSecretForName({
2227
- networkName: network.name,
2228
- walletName,
2229
- });
2230
2466
  const bridgeResources = loadBridgeResources({ chainId: network.chainId });
2231
2467
  const initialized = await syncChannelWorkspace({
2232
2468
  workspaceName: channelName,
@@ -2260,11 +2496,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2260
2496
  provider,
2261
2497
  ),
2262
2498
  };
2263
- const l2Identity = await deriveParticipantIdentityFromSigner({
2264
- channelName,
2265
- walletSecret,
2266
- signer,
2267
- });
2268
2499
  const noteReceiveKeyMaterial = await deriveNoteReceiveKeyMaterial({
2269
2500
  signer,
2270
2501
  chainId: network.chainId,
@@ -2272,82 +2503,52 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2272
2503
  channelName,
2273
2504
  account: signer.address,
2274
2505
  });
2275
- const storageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
2276
- const leafIndex = deriveChannelTokenVaultLeafIndex(storageKey);
2277
2506
  const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
2278
-
2279
- if (!registration.exists) {
2280
- const cleanup = removeLocalWalletArtifacts(walletName, context.workspace.network);
2281
- if (cleanup.removed) {
2282
- printJson({
2283
- action: "wallet recover-workspace",
2284
- status: "stale-wallet-removed",
2285
- wallet: walletName,
2286
- removedWalletDir: cleanup.removedWalletDir ? cleanup.walletDir : null,
2287
- removedWalletSecretFile: cleanup.removedWalletSecret ? cleanup.walletSecretFile : null,
2288
- walletSecretSource: resolvedWalletSecretSource(args),
2289
- walletSecretFile: resolvedWalletSecretFile(network.name, walletName),
2290
- workspace: context.workspaceName,
2291
- channelName: context.workspace.channelName,
2292
- channelId: context.workspace.channelId,
2293
- l1Address: signer.address,
2294
- l2Address: l2Identity.l2Address,
2295
- l2StorageKey: storageKey,
2296
- leafIndex: leafIndex.toString(),
2297
- reason: "The local wallet existed, but the L1 address is no longer registered in the channel.",
2298
- nextAction: buildRecoverWalletRemovedNextAction({
2299
- channelName,
2300
- networkName: network.name,
2301
- accountName: args.account,
2302
- }),
2303
- });
2304
- return;
2305
- }
2306
- expect(
2307
- false,
2308
- cliError(
2309
- CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION,
2310
- `No channelTokenVault registration exists for ${signer.address}. Run channel join first.`,
2311
- ),
2312
- );
2313
- }
2314
- expect(
2315
- ethers.toBigInt(getAddress(registration.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address)),
2316
- "The existing channel registration L2 address does not match the derived L2 address.",
2317
- );
2318
- expect(
2319
- ethers.toBigInt(normalizeBytes32Hex(registration.channelTokenVaultKey))
2320
- === ethers.toBigInt(normalizeBytes32Hex(storageKey)),
2321
- "The existing channel registration key does not match the derived channelTokenVault key.",
2322
- );
2507
+ const lifecycleEpoch = await resolveWalletLifecycleEpoch({
2508
+ context,
2509
+ provider,
2510
+ l1Address: signer.address,
2511
+ registration,
2512
+ });
2323
2513
  expect(
2324
- ethers.toBigInt(registration.leafIndex) === ethers.toBigInt(leafIndex),
2325
- "The existing channel registration leaf index does not match the derived leaf index.",
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
+ ),
2326
2519
  );
2520
+ const registeredNoteReceivePubKey = lifecycleEpoch.noteReceivePubKey;
2327
2521
  expect(
2328
- ethers.toBigInt(normalizeBytes32Hex(registration.noteReceivePubKey.x))
2522
+ ethers.toBigInt(normalizeBytes32Hex(registeredNoteReceivePubKey.x))
2329
2523
  === ethers.toBigInt(normalizeBytes32Hex(noteReceiveKeyMaterial.noteReceivePubKey.x)),
2330
2524
  "The existing note-receive public key X does not match the derived note-receive public key.",
2331
2525
  );
2332
2526
  expect(
2333
- Number(registration.noteReceivePubKey.yParity) === Number(noteReceiveKeyMaterial.noteReceivePubKey.yParity),
2527
+ Number(registeredNoteReceivePubKey.yParity) === Number(noteReceiveKeyMaterial.noteReceivePubKey.yParity),
2334
2528
  "The existing note-receive public key parity does not match the derived note-receive public key.",
2335
2529
  );
2336
-
2337
- const existingWallet = tryLoadRecoverableWallet({
2338
- walletName,
2339
- walletSecret,
2340
- signerAddress: signer.address,
2341
- signerPrivateKey: signer.privateKey,
2342
- l2Identity,
2343
- storageKey,
2344
- leafIndex,
2345
- rpcUrl,
2346
- channelContext: context,
2347
- noteReceiveKeyMaterial,
2348
- });
2530
+ const l2Identity = {
2531
+ l2PrivateKey: null,
2532
+ l2PublicKey: null,
2533
+ l2Address: getAddress(lifecycleEpoch.l2Address),
2534
+ };
2535
+ const storageKey = normalizeBytes32Hex(lifecycleEpoch.channelTokenVaultKey);
2536
+
2537
+ const walletDir = walletEpochPath(walletName, context.workspace.network, lifecycleEpoch.epochId);
2538
+ const existingWallet = walletConfigExists(walletDir)
2539
+ ? loadWalletFromDir({
2540
+ walletName,
2541
+ networkName: context.workspace.network,
2542
+ walletDir,
2543
+ })
2544
+ : null;
2349
2545
 
2350
2546
  if (existingWallet) {
2547
+ existingWallet.wallet.noteReceivePrivateKey = noteReceiveKeyMaterial.privateKey;
2548
+ applyWalletLifecycleEpoch(existingWallet.wallet, lifecycleEpoch);
2549
+ persistWalletKeys(existingWallet);
2550
+ persistWallet(existingWallet);
2551
+ persistWalletIndexForContext(existingWallet);
2351
2552
  const { recoveredDeliveryState } = await recoverWalletReceivedNotes({
2352
2553
  walletContext: existingWallet,
2353
2554
  context,
@@ -2362,15 +2563,16 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2362
2563
  status: "already-recovered",
2363
2564
  wallet: walletName,
2364
2565
  walletDir: existingWallet.walletDir,
2365
- walletSecretSource: resolvedWalletSecretSource(args),
2366
- walletSecretFile: resolvedWalletSecretFile(network.name, walletName),
2367
2566
  workspace: context.workspaceName,
2368
2567
  channelName: context.workspace.channelName,
2369
2568
  channelId: context.workspace.channelId,
2370
2569
  l1Address: signer.address,
2371
2570
  l2Address: l2Identity.l2Address,
2372
2571
  l2StorageKey: storageKey,
2373
- leafIndex: registration.leafIndex.toString(),
2572
+ leafIndex: lifecycleEpoch.leafIndex.toString(),
2573
+ epochId: lifecycleEpoch.epochId,
2574
+ lifecycleStatus: lifecycleEpoch.lifecycleStatus,
2575
+ exitedAtTxHash: lifecycleEpoch.exitedAtTxHash,
2374
2576
  noteReceivePubKey: noteReceiveKeyMaterial.noteReceivePubKey,
2375
2577
  l2Nonce: existingWallet.wallet.l2Nonce,
2376
2578
  recoveredFromLogs: recoveredDeliveryState.importedNotes,
@@ -2380,17 +2582,16 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2380
2582
  return;
2381
2583
  }
2382
2584
 
2383
- fs.rmSync(walletPath(walletName, context.workspace.network), { recursive: true, force: true });
2384
-
2385
2585
  const walletContext = ensureWallet({
2386
2586
  channelContext: context,
2387
2587
  signerAddress: signer.address,
2388
2588
  signerPrivateKey: signer.privateKey,
2389
2589
  l2Identity,
2390
- walletSecret,
2590
+ walletSecret: noteReceiveKeyMaterial.privateKey,
2391
2591
  storageKey,
2392
- leafIndex: registration.leafIndex,
2592
+ leafIndex: lifecycleEpoch.leafIndex,
2393
2593
  noteReceiveKeyMaterial,
2594
+ lifecycleEpoch,
2394
2595
  rpcUrl,
2395
2596
  });
2396
2597
  walletContext.wallet.l2Nonce = 0;
@@ -2411,15 +2612,16 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2411
2612
  status: "recovered",
2412
2613
  wallet: walletName,
2413
2614
  walletDir: walletContext.walletDir,
2414
- walletSecretSource: resolvedWalletSecretSource(args),
2415
- walletSecretFile: resolvedWalletSecretFile(network.name, walletName),
2416
2615
  workspace: context.workspaceName,
2417
2616
  channelName: context.workspace.channelName,
2418
2617
  channelId: context.workspace.channelId,
2419
2618
  l1Address: signer.address,
2420
2619
  l2Address: l2Identity.l2Address,
2421
2620
  l2StorageKey: storageKey,
2422
- leafIndex: registration.leafIndex.toString(),
2621
+ leafIndex: lifecycleEpoch.leafIndex.toString(),
2622
+ epochId: lifecycleEpoch.epochId,
2623
+ lifecycleStatus: lifecycleEpoch.lifecycleStatus,
2624
+ exitedAtTxHash: lifecycleEpoch.exitedAtTxHash,
2423
2625
  noteReceivePubKey: noteReceiveKeyMaterial.noteReceivePubKey,
2424
2626
  l2Nonce: walletContext.wallet.l2Nonce,
2425
2627
  recoveredFromLogs: recoveredDeliveryState.importedNotes,
@@ -2428,161 +2630,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2428
2630
  });
2429
2631
  }
2430
2632
 
2431
- function tryLoadRecoverableWallet({
2432
- walletName,
2433
- walletSecret,
2434
- signerAddress,
2435
- signerPrivateKey,
2436
- l2Identity,
2437
- storageKey,
2438
- leafIndex,
2439
- rpcUrl,
2440
- channelContext,
2441
- noteReceiveKeyMaterial,
2442
- }) {
2443
- const walletDir = walletPath(walletName, channelContext.workspace.network);
2444
- if (!walletConfigExists(walletDir)) {
2445
- return null;
2446
- }
2447
-
2448
- try {
2449
- const walletMetadata = loadWalletMetadata(walletName, channelContext.workspace.network);
2450
- const walletContext = loadWallet(walletName, walletSecret, channelContext.workspace.network);
2451
- assertWalletMatchesMetadata(walletContext, walletMetadata);
2452
- assertExistingRecoverableWallet({
2453
- walletContext,
2454
- walletMetadata,
2455
- signerAddress,
2456
- signerPrivateKey,
2457
- l2Identity,
2458
- storageKey,
2459
- leafIndex,
2460
- rpcUrl,
2461
- channelContext,
2462
- noteReceiveKeyMaterial,
2463
- });
2464
- return walletContext;
2465
- } catch {
2466
- return null;
2467
- }
2468
- }
2469
-
2470
- function assertExistingRecoverableWallet({
2471
- walletContext,
2472
- walletMetadata,
2473
- signerAddress,
2474
- signerPrivateKey,
2475
- l2Identity,
2476
- storageKey,
2477
- leafIndex,
2478
- rpcUrl,
2479
- channelContext,
2480
- noteReceiveKeyMaterial,
2481
- }) {
2482
- const wallet = walletContext.wallet;
2483
- expect(
2484
- walletMetadata.network === channelContext.workspace.network,
2485
- `Wallet ${walletContext.walletName} metadata network does not match the requested network.`,
2486
- );
2487
- expect(
2488
- walletMetadata.channelName === channelContext.workspace.channelName,
2489
- `Wallet ${walletContext.walletName} metadata channel does not match the requested channel.`,
2490
- );
2491
- expect(
2492
- walletMetadata.rpcUrl === rpcUrl,
2493
- `Wallet ${walletContext.walletName} metadata rpcUrl does not match the requested runtime RPC URL.`,
2494
- );
2495
- expect(
2496
- normalizePrivateKey(wallet.l1PrivateKey) === normalizePrivateKey(signerPrivateKey),
2497
- `Wallet ${walletContext.walletName} does not decrypt to the requested L1 private key.`,
2498
- );
2499
- expect(
2500
- ethers.toBigInt(getAddress(wallet.l1Address)) === ethers.toBigInt(getAddress(signerAddress)),
2501
- `Wallet ${walletContext.walletName} L1 address does not match the requested signer.`,
2502
- );
2503
- expect(
2504
- ethers.toBigInt(getAddress(wallet.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address)),
2505
- `Wallet ${walletContext.walletName} L2 address does not match the derived channel identity.`,
2506
- );
2507
- expect(
2508
- ethers.toBigInt(normalizeBytes32Hex(wallet.l2StorageKey))
2509
- === ethers.toBigInt(normalizeBytes32Hex(storageKey)),
2510
- `Wallet ${walletContext.walletName} storage key does not match the derived registration key.`,
2511
- );
2512
- expect(
2513
- ethers.toBigInt(wallet.leafIndex) === ethers.toBigInt(leafIndex),
2514
- `Wallet ${walletContext.walletName} leaf index does not match the derived registration leaf index.`,
2515
- );
2516
- expect(
2517
- ethers.toBigInt(wallet.channelId) === ethers.toBigInt(channelContext.workspace.channelId),
2518
- `Wallet ${walletContext.walletName} channel ID does not match the requested channel.`,
2519
- );
2520
- expect(
2521
- wallet.channelName === channelContext.workspace.channelName,
2522
- `Wallet ${walletContext.walletName} channel name does not match the requested channel.`,
2523
- );
2524
- expect(
2525
- wallet.network === channelContext.workspace.network,
2526
- `Wallet ${walletContext.walletName} network does not match the requested network.`,
2527
- );
2528
- expect(
2529
- wallet.rpcUrl === rpcUrl,
2530
- `Wallet ${walletContext.walletName} rpcUrl does not match the requested runtime RPC URL.`,
2531
- );
2532
- expect(
2533
- ethers.toBigInt(getAddress(wallet.channelManager)) === ethers.toBigInt(getAddress(channelContext.workspace.channelManager)),
2534
- `Wallet ${walletContext.walletName} channel manager does not match the recovered workspace.`,
2535
- );
2536
- expect(
2537
- ethers.toBigInt(getAddress(wallet.bridgeTokenVault)) === ethers.toBigInt(getAddress(channelContext.workspace.bridgeTokenVault)),
2538
- `Wallet ${walletContext.walletName} bridge token vault does not match the recovered workspace.`,
2539
- );
2540
- expect(
2541
- ethers.toBigInt(getAddress(wallet.controller)) === ethers.toBigInt(getAddress(channelContext.workspace.controller)),
2542
- `Wallet ${walletContext.walletName} controller does not match the recovered workspace.`,
2543
- );
2544
- expect(
2545
- ethers.toBigInt(getAddress(wallet.l2AccountingVault))
2546
- === ethers.toBigInt(getAddress(channelContext.workspace.l2AccountingVault)),
2547
- `Wallet ${walletContext.walletName} L2 accounting vault does not match the recovered workspace.`,
2548
- );
2549
- expect(
2550
- ethers.toBigInt(normalizeBytes32Hex(wallet.noteReceivePubKeyX))
2551
- === ethers.toBigInt(normalizeBytes32Hex(noteReceiveKeyMaterial.noteReceivePubKey.x)),
2552
- `Wallet ${walletContext.walletName} note-receive public key X does not match the derived key.`,
2553
- );
2554
- expect(
2555
- Number(wallet.noteReceivePubKeyYParity) === Number(noteReceiveKeyMaterial.noteReceivePubKey.yParity),
2556
- `Wallet ${walletContext.walletName} note-receive public key parity does not match the derived key.`,
2557
- );
2558
- }
2559
-
2560
- function removeLocalWalletArtifacts(walletName, networkName) {
2561
- const walletDir = walletPath(walletName, networkName);
2562
- const walletSecretFile = walletSecretPath(networkName, walletName);
2563
- const walletSecretDir = path.dirname(walletSecretFile);
2564
- const removedWalletDir = fs.existsSync(walletDir);
2565
- const removedWalletSecret = fs.existsSync(walletSecretFile) || fs.existsSync(walletSecretDir);
2566
- if (removedWalletDir) {
2567
- fs.rmSync(walletDir, { recursive: true, force: true });
2568
- }
2569
- if (removedWalletSecret) {
2570
- fs.rmSync(walletSecretDir, { recursive: true, force: true });
2571
- }
2572
- return {
2573
- walletDir,
2574
- walletSecretFile,
2575
- removed: removedWalletDir || removedWalletSecret,
2576
- removedWalletDir,
2577
- removedWalletSecret,
2578
- };
2579
- }
2580
-
2581
- function buildRecoverWalletRemovedNextAction({ channelName, networkName, accountName }) {
2582
- const account = accountName ? String(accountName) : "<ACCOUNT>";
2583
- return `channel join --channel-name ${channelName} --network ${networkName} --account ${account} --wallet-secret-path <PATH>`;
2584
- }
2585
-
2586
2633
  async function handleInstallZkEvm({ args }) {
2587
2634
  const selectedVersions = await resolvePrivateStateInstallRuntimeVersions(args);
2588
2635
  const tokamakCliRuntime = await installTokamakCliRuntimeForPrivateState({
@@ -2880,6 +2927,68 @@ async function handleDoctor({ args }) {
2880
2927
  }
2881
2928
  }
2882
2929
 
2930
+ function handleInvestigator() {
2931
+ const htmlPath = resolveInvestigatorIndexPath();
2932
+ const fileUrl = pathToFileURL(htmlPath).href;
2933
+ const browser = openFileInDefaultBrowser(fileUrl);
2934
+ printJson({
2935
+ action: "investigator",
2936
+ htmlPath,
2937
+ fileUrl,
2938
+ browserOpened: browser.opened,
2939
+ browserOpenCommand: browser.command,
2940
+ browserOpenError: browser.error,
2941
+ nextSteps: [
2942
+ "Create a raw evidence ZIP with wallet get-notes --export-evidence and --acknowledge-full-note-plaintext-export.",
2943
+ "Load the raw evidence ZIP in the browser investigator.",
2944
+ "Filter the raw bundle and export a user-consent disclosure ZIP.",
2945
+ "Do not submit the raw evidence ZIP unless full wallet-history disclosure is intended.",
2946
+ ],
2947
+ });
2948
+ }
2949
+
2950
+ function resolveInvestigatorIndexPath() {
2951
+ const candidates = [
2952
+ path.join(privateStateCliPackageRoot, "investigator", "index.html"),
2953
+ path.resolve(privateStateCliPackageRoot, "..", "investigator", "index.html"),
2954
+ ];
2955
+ const htmlPath = candidates.find((candidate) => fs.existsSync(candidate));
2956
+ if (!htmlPath) {
2957
+ throw new Error(
2958
+ [
2959
+ "Missing investigator HTML asset.",
2960
+ `Checked: ${candidates.join(", ")}`,
2961
+ "Reinstall the private-state CLI package or run from a complete repository checkout.",
2962
+ ].join(" "),
2963
+ );
2964
+ }
2965
+ return htmlPath;
2966
+ }
2967
+
2968
+ function openFileInDefaultBrowser(fileUrl) {
2969
+ const opener = defaultBrowserOpenCommand(fileUrl);
2970
+ const result = spawnSync(opener.command, opener.args, {
2971
+ stdio: "ignore",
2972
+ windowsHide: true,
2973
+ });
2974
+ return {
2975
+ command: [opener.command, ...opener.args].join(" "),
2976
+ opened: result.status === 0,
2977
+ status: result.status,
2978
+ error: result.error?.message ?? null,
2979
+ };
2980
+ }
2981
+
2982
+ function defaultBrowserOpenCommand(fileUrl) {
2983
+ if (process.platform === "darwin") {
2984
+ return { command: "open", args: [fileUrl] };
2985
+ }
2986
+ if (process.platform === "win32") {
2987
+ return { command: "cmd", args: ["/c", "start", "", fileUrl] };
2988
+ }
2989
+ return { command: "xdg-open", args: [fileUrl] };
2990
+ }
2991
+
2883
2992
  async function handleTransactionFees({ network, provider, rpcUrl }) {
2884
2993
  const feeAsset = loadTransactionFeeAsset();
2885
2994
  const feeData = await provider.getFeeData();
@@ -3079,24 +3188,19 @@ function handleListLocalWallets({ args }) {
3079
3188
  });
3080
3189
  }
3081
3190
 
3082
- function handleWalletExport({ args }) {
3191
+ function handleWalletExportBackup({ args }) {
3083
3192
  const outputPath = path.resolve(String(requireArg(args.output, "--output")));
3084
3193
  expect(!fs.existsSync(outputPath), `Export output already exists: ${outputPath}.`);
3085
3194
  ensureDir(path.dirname(outputPath));
3086
3195
 
3087
- const includeNotes = args.includeNotes === true;
3088
- const wallets = args.all === true
3089
- ? listLocalWallets({ networkFilter: "mainnet" }).filter((wallet) => wallet.hasEncryptedWallet)
3090
- : [resolveExportWalletInfo({
3091
- networkName: requireNetworkName(args),
3092
- walletName: requireWalletName(args),
3093
- })];
3196
+ const wallets = [resolveExportWalletInfo({
3197
+ networkName: requireNetworkName(args),
3198
+ walletName: requireWalletName(args),
3199
+ })];
3094
3200
 
3095
3201
  expect(
3096
3202
  wallets.length > 0,
3097
- args.all === true
3098
- ? "No local mainnet wallets are available to export."
3099
- : "No local wallet is available to export.",
3203
+ "No local wallet is available to export.",
3100
3204
  );
3101
3205
 
3102
3206
  const archive = new AdmZip();
@@ -3109,7 +3213,7 @@ function handleWalletExport({ args }) {
3109
3213
  channelName: normalized.channelName,
3110
3214
  wallet: normalized.wallet,
3111
3215
  });
3112
- for (const filePath of walletExportFilePaths(normalized, { includeNotes })) {
3216
+ for (const filePath of walletBackupExportFilePaths(normalized)) {
3113
3217
  const archivePath = archivePathForLocalCliFile(filePath);
3114
3218
  if (!files.has(archivePath)) {
3115
3219
  files.set(archivePath, filePath);
@@ -3118,44 +3222,65 @@ function handleWalletExport({ args }) {
3118
3222
  }
3119
3223
 
3120
3224
  const manifest = {
3121
- format: WALLET_EXPORT_FORMAT,
3225
+ format: WALLET_BACKUP_EXPORT_FORMAT,
3122
3226
  formatVersion: WALLET_EXPORT_FORMAT_VERSION,
3123
3227
  createdAt: new Date().toISOString(),
3124
3228
  cliPackage: PRIVATE_STATE_CLI_PACKAGE_NAME,
3125
3229
  cliVersion: privateStateCliPackageJson.version,
3126
- exportMode: args.all === true ? "all-mainnet" : "single-wallet",
3127
- includeNotes,
3128
- notes: includeNotes
3129
- ? [
3130
- "Includes the channel workspace cache required for immediate wallet command use when the cache is still chain-aligned.",
3131
- ]
3132
- : [
3133
- "Includes wallet identity, encrypted wallet state, metadata, and wallet-local secret only.",
3134
- "Run channel recover-workspace after import before wallet commands need channel state.",
3135
- ],
3230
+ exportMode: "backup",
3231
+ notes: [
3232
+ "Includes wallet note-tracking metadata, public key metadata, and channel workspace cache.",
3233
+ "Excludes spending keys, viewing keys, key derivation material, owner, value, and salt.",
3234
+ ],
3136
3235
  wallets: exportedWallets,
3137
3236
  files: [...files.keys()].sort(),
3138
3237
  };
3139
3238
 
3140
3239
  archive.addFile("manifest.json", Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"));
3141
3240
  for (const archivePath of manifest.files) {
3142
- archive.addFile(archivePath, fs.readFileSync(files.get(archivePath)));
3241
+ const filePath = files.get(archivePath);
3242
+ validateBackupExportFile(filePath);
3243
+ archive.addFile(archivePath, fs.readFileSync(filePath));
3143
3244
  }
3144
3245
  archive.writeZip(outputPath);
3145
3246
  protectSecretFile(outputPath, "wallet export ZIP");
3146
3247
 
3147
3248
  printJson({
3148
- action: "wallet export",
3249
+ action: "wallet export backup",
3149
3250
  output: outputPath,
3150
3251
  exportMode: manifest.exportMode,
3151
- includeNotes,
3152
3252
  walletCount: exportedWallets.length,
3153
3253
  fileCount: manifest.files.length,
3154
3254
  wallets: exportedWallets.map(({ network, channelName, wallet }) => ({ network, channelName, wallet })),
3155
3255
  });
3156
3256
  }
3157
3257
 
3158
- function handleWalletImport({ args }) {
3258
+ function handleWalletExportKey({ args, keyKind }) {
3259
+ const outputPath = path.resolve(String(requireArg(args.output, "--output")));
3260
+ expect(!fs.existsSync(outputPath), `Export output already exists: ${outputPath}.`);
3261
+ ensureDir(path.dirname(outputPath));
3262
+ const networkName = requireNetworkName(args);
3263
+ const walletName = requireWalletName(args);
3264
+ const wallet = loadWallet(walletName, networkName);
3265
+ const secretPath = keyKind === "spending"
3266
+ ? walletSpendingKeySecretPath(networkName, walletName)
3267
+ : walletViewingKeySecretPath(networkName, walletName);
3268
+ expect(fs.existsSync(secretPath), `Wallet ${walletName} is missing its ${keyKind} key.`);
3269
+ const payload = JSON.parse(readSecretFile(secretPath, `${keyKind} key`));
3270
+ validateWalletKeyPayload(payload, keyKind);
3271
+ fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
3272
+ protectSecretFile(outputPath, `${keyKind} key export`);
3273
+ printJson({
3274
+ action: `wallet export ${keyKind}-key`,
3275
+ wallet: wallet.walletName,
3276
+ network: networkName,
3277
+ output: outputPath,
3278
+ keyKind,
3279
+ metadata: payload.metadata,
3280
+ });
3281
+ }
3282
+
3283
+ function handleWalletImportBackup({ args }) {
3159
3284
  const inputPath = path.resolve(String(requireArg(args.input, "--input")));
3160
3285
  expect(fs.existsSync(inputPath), `Import ZIP does not exist: ${inputPath}.`);
3161
3286
 
@@ -3191,16 +3316,55 @@ function handleWalletImport({ args }) {
3191
3316
  commitWalletImportFiles({ targetRoot, plannedWrites });
3192
3317
 
3193
3318
  printJson({
3194
- action: "wallet import",
3319
+ action: "wallet import backup",
3195
3320
  input: inputPath,
3196
3321
  exportMode: manifest.exportMode,
3197
- includeNotes: Boolean(manifest.includeNotes),
3198
3322
  walletCount: manifest.wallets.length,
3199
3323
  fileCount: plannedWrites.length,
3200
3324
  wallets: manifest.wallets.map(({ network, channelName, wallet }) => ({ network, channelName, wallet })),
3201
- nextStep: manifest.includeNotes
3202
- ? "Wallet commands can run immediately if the imported channel workspace cache is still chain-aligned."
3203
- : "Run channel recover-workspace before wallet commands need channel state.",
3325
+ nextStep: "Import viewing-key and spending-key files separately when the wallet needs those capabilities.",
3326
+ });
3327
+ }
3328
+
3329
+ function handleWalletImportKey({ args, keyKind }) {
3330
+ const inputPath = path.resolve(String(requireArg(args.input, "--input")));
3331
+ expect(fs.existsSync(inputPath), `Key import file does not exist: ${inputPath}.`);
3332
+ const payload = JSON.parse(readImportSecretSourceFile(inputPath, "--input"));
3333
+ validateWalletKeyPayload(payload, keyKind);
3334
+ const metadata = payload.metadata;
3335
+ const networkName = requireNetworkName({ network: metadata.network });
3336
+ const walletName = requireWalletName({ wallet: metadata.wallet });
3337
+ const targetPath = keyKind === "spending"
3338
+ ? walletSpendingKeySecretPath(networkName, walletName)
3339
+ : walletViewingKeySecretPath(networkName, walletName);
3340
+ expect(!fs.existsSync(targetPath), `Refusing to overwrite existing ${keyKind} key: ${targetPath}.`);
3341
+ writeSecretFile(targetPath, JSON.stringify(payload, null, 2));
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);
3349
+ const metadataPath = keyKind === "spending"
3350
+ ? walletSpendingKeyMetadataPath(walletDir)
3351
+ : walletViewingKeyMetadataPath(walletDir);
3352
+ if (fs.existsSync(metadataPath)) {
3353
+ expect(
3354
+ JSON.stringify(readJson(metadataPath)) === JSON.stringify(normalizeCliOutput(metadata)),
3355
+ `Refusing to overwrite mismatched ${keyKind} key metadata: ${metadataPath}.`,
3356
+ );
3357
+ } else {
3358
+ writeJson(metadataPath, metadata);
3359
+ }
3360
+ }
3361
+ printJson({
3362
+ action: `wallet import ${keyKind}-key`,
3363
+ input: inputPath,
3364
+ network: networkName,
3365
+ wallet: walletName,
3366
+ keyKind,
3367
+ metadata,
3204
3368
  });
3205
3369
  }
3206
3370
 
@@ -3217,17 +3381,58 @@ function readWalletImportArchive(inputPath) {
3217
3381
  }
3218
3382
  }
3219
3383
 
3220
- function commitWalletImportFiles({ targetRoot, plannedWrites }) {
3221
- const stagingRoot = fs.mkdtempSync(path.join(targetRoot, ".wallet-import-"));
3222
- const committedPaths = [];
3223
- try {
3224
- for (const write of plannedWrites) {
3225
- write.stagingPath = path.resolve(stagingRoot, write.archivePath);
3226
- expectPathWithinRoot(write.stagingPath, stagingRoot, `Unsafe staging target for ${write.archivePath}.`);
3227
- ensureDir(path.dirname(write.stagingPath));
3228
- fs.writeFileSync(write.stagingPath, write.data);
3229
- applyImportedWalletFileMode(write.archivePath, write.stagingPath);
3230
- }
3384
+ function validateBackupExportFile(filePath) {
3385
+ if (path.basename(filePath) !== "wallet-notes.metadata.json") {
3386
+ return;
3387
+ }
3388
+ const metadata = readJson(filePath);
3389
+ const forbidden = findForbiddenBackupMetadataPaths(metadata);
3390
+ expect(
3391
+ forbidden.length === 0,
3392
+ `wallet export backup refuses to export plaintext note secrets or key material: ${forbidden.join(", ")}.`,
3393
+ );
3394
+ }
3395
+
3396
+ function findForbiddenBackupMetadataPaths(value, pathParts = []) {
3397
+ const forbiddenNames = new Set([
3398
+ "owner",
3399
+ "value",
3400
+ "salt",
3401
+ "l1PrivateKey",
3402
+ "l2PrivateKey",
3403
+ "noteReceivePrivateKey",
3404
+ "walletSecret",
3405
+ "seedSignature",
3406
+ ]);
3407
+ if (Array.isArray(value)) {
3408
+ return value.flatMap((entry, index) => findForbiddenBackupMetadataPaths(entry, [...pathParts, String(index)]));
3409
+ }
3410
+ if (!value || typeof value !== "object") {
3411
+ return [];
3412
+ }
3413
+ const found = [];
3414
+ for (const [key, entry] of Object.entries(value)) {
3415
+ const nextPath = [...pathParts, key];
3416
+ if (forbiddenNames.has(key) && entry !== undefined && entry !== null) {
3417
+ found.push(nextPath.join("."));
3418
+ continue;
3419
+ }
3420
+ found.push(...findForbiddenBackupMetadataPaths(entry, nextPath));
3421
+ }
3422
+ return found;
3423
+ }
3424
+
3425
+ function commitWalletImportFiles({ targetRoot, plannedWrites }) {
3426
+ const stagingRoot = fs.mkdtempSync(path.join(targetRoot, ".wallet-import-"));
3427
+ const committedPaths = [];
3428
+ try {
3429
+ for (const write of plannedWrites) {
3430
+ write.stagingPath = path.resolve(stagingRoot, write.archivePath);
3431
+ expectPathWithinRoot(write.stagingPath, stagingRoot, `Unsafe staging target for ${write.archivePath}.`);
3432
+ ensureDir(path.dirname(write.stagingPath));
3433
+ fs.writeFileSync(write.stagingPath, write.data);
3434
+ applyImportedWalletFileMode(write.archivePath, write.stagingPath);
3435
+ }
3231
3436
 
3232
3437
  for (const write of plannedWrites) {
3233
3438
  expect(!fs.existsSync(write.targetPath), `Refusing to overwrite existing file: ${write.targetPath}.`);
@@ -3609,15 +3814,25 @@ async function inspectGuideAccount({ account, networkName, network, provider, ar
3609
3814
  }
3610
3815
 
3611
3816
  async function inspectGuideWallet({ walletName, networkName, provider, artifactsInstalled }) {
3612
- 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
+ }
3824
+ const viewingKeyFile = walletViewingKeySecretPath(networkName, walletName);
3825
+ const spendingKeyFile = walletSpendingKeySecretPath(networkName, walletName);
3613
3826
  const result = {
3614
3827
  wallet: walletName,
3615
3828
  network: networkName,
3616
3829
  walletDir,
3617
3830
  exists: walletConfigExists(walletDir),
3618
- metadataExists: fs.existsSync(walletMetadataPath(walletDir)),
3619
- secretFile: walletSecretPath(networkName, walletName),
3620
- secretFileExists: fs.existsSync(walletSecretPath(networkName, walletName)),
3831
+ metadataExists: fs.existsSync(walletNotesMetadataPath(walletDir)),
3832
+ viewingKeyFile,
3833
+ viewingKeyFileExists: fs.existsSync(viewingKeyFile),
3834
+ spendingKeyFile,
3835
+ spendingKeyFileExists: fs.existsSync(spendingKeyFile),
3621
3836
  channelName: null,
3622
3837
  l1Address: null,
3623
3838
  l2Address: null,
@@ -3628,14 +3843,14 @@ async function inspectGuideWallet({ walletName, networkName, provider, artifacts
3628
3843
  unusedNoteBalanceBaseUnits: null,
3629
3844
  unusedNoteBalanceTokens: null,
3630
3845
  spentNoteCount: null,
3631
- error: null,
3846
+ error: workspaceError,
3632
3847
  };
3633
- if (!result.exists) {
3848
+ if (workspaceError || !result.exists) {
3634
3849
  return result;
3635
3850
  }
3636
3851
 
3637
3852
  try {
3638
- const walletContext = loadWallet(walletName, resolveWalletDefaultSecret(networkName, walletName), networkName);
3853
+ const walletContext = loadWallet(walletName, networkName);
3639
3854
  const walletMetadata = loadWalletMetadata(walletName, networkName);
3640
3855
  assertWalletMatchesMetadata(walletContext, walletMetadata);
3641
3856
  result.channelName = walletContext.wallet.channelName;
@@ -3643,10 +3858,13 @@ async function inspectGuideWallet({ walletName, networkName, provider, artifacts
3643
3858
  result.l2Address = getAddress(walletContext.wallet.l2Address);
3644
3859
  result.unusedNoteCount = Object.keys(walletContext.wallet.notes.unused).length;
3645
3860
  result.spentNoteCount = Object.keys(walletContext.wallet.notes.spent).length;
3646
- const unusedNoteBalance = Object.values(walletContext.wallet.notes.unused)
3647
- .reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n);
3648
- result.unusedNoteBalanceBaseUnits = unusedNoteBalance.toString();
3649
- result.unusedNoteBalanceTokens = ethers.formatUnits(unusedNoteBalance, Number(walletContext.wallet.canonicalAssetDecimals));
3861
+ const unusedValues = Object.values(walletContext.wallet.notes.unused).map((note) => note.value);
3862
+ if (unusedValues.every((value) => value !== null)) {
3863
+ const unusedNoteBalance = Object.values(walletContext.wallet.notes.unused)
3864
+ .reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n);
3865
+ result.unusedNoteBalanceBaseUnits = unusedNoteBalance.toString();
3866
+ result.unusedNoteBalanceTokens = ethers.formatUnits(unusedNoteBalance, Number(walletContext.wallet.canonicalAssetDecimals));
3867
+ }
3650
3868
 
3651
3869
  if (provider && artifactsInstalled && walletChannelWorkspaceIsReady(walletContext)) {
3652
3870
  const context = await loadWorkspaceContext(walletContext.wallet.channelName, networkName, provider);
@@ -3717,7 +3935,7 @@ function applyGuideNextAction(guide) {
3717
3935
  const channelName = guide.selectors.channelName ?? guide.state.channel?.channelName ?? "<CHANNEL>";
3718
3936
  const account = guide.selectors.account ?? "<ACCOUNT>";
3719
3937
  setGuideNextAction(guide, {
3720
- command: `channel join --channel-name ${channelName} --network ${guide.selectors.network} --account ${account} --wallet-secret-path <PATH>`,
3938
+ command: `channel join --channel-name ${channelName} --network ${guide.selectors.network} --account ${account} --wallet-secret-path <PATH> --acknowledge-action-impact`,
3721
3939
  why: "The selected local wallet does not exist. Join the channel to create the wallet and register the channel L2 identity.",
3722
3940
  });
3723
3941
  return;
@@ -3726,7 +3944,7 @@ function applyGuideNextAction(guide) {
3726
3944
  const channelName = guide.state.wallet.channelName ?? guide.selectors.channelName ?? "<CHANNEL>";
3727
3945
  const account = guide.selectors.account ?? "<ACCOUNT>";
3728
3946
  setGuideNextAction(guide, {
3729
- command: `channel join --channel-name ${channelName} --network ${guide.selectors.network} --account ${account} --wallet-secret-path <PATH>`,
3947
+ command: `channel join --channel-name ${channelName} --network ${guide.selectors.network} --account ${account} --wallet-secret-path <PATH> --acknowledge-action-impact`,
3730
3948
  why: "The local wallet exists, but the corresponding L1 address is not registered in the channel.",
3731
3949
  });
3732
3950
  return;
@@ -3743,32 +3961,32 @@ function applyGuideNextAction(guide) {
3743
3961
  if (guide.state.wallet?.exists && bridgeBalance === 0n && (channelBalance === null || channelBalance === 0n) && unusedNotes === 0) {
3744
3962
  const account = guide.selectors.account ?? "<ACCOUNT>";
3745
3963
  setGuideNextAction(guide, {
3746
- command: `account deposit-bridge --amount <TOKENS> --network ${guide.selectors.network} --account ${account}`,
3964
+ command: `account deposit-bridge --amount <TOKENS> --network ${guide.selectors.network} --account ${account} --acknowledge-action-impact`,
3747
3965
  why: "The wallet is joined, but there is no bridge balance, channel balance, or local unused note to spend.",
3748
3966
  });
3749
3967
  return;
3750
3968
  }
3751
3969
  if (guide.state.wallet?.exists && bridgeBalance !== null && bridgeBalance > 0n && channelBalance === 0n) {
3752
3970
  setGuideNextAction(guide, {
3753
- command: `wallet deposit-channel --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amount <TOKENS>`,
3971
+ command: `wallet deposit-channel --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amount <TOKENS> --acknowledge-action-impact`,
3754
3972
  why: "The account has funds in the shared bridge vault, but the wallet has no channel L2 accounting balance.",
3755
3973
  });
3756
3974
  return;
3757
3975
  }
3758
3976
  if (guide.state.wallet?.exists && channelBalance !== null && channelBalance > 0n && unusedNotes === 0) {
3759
3977
  setGuideNextAction(guide, {
3760
- command: `wallet mint-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amounts <A,B> [--tx-submitter <ACCOUNT>]`,
3978
+ command: `wallet mint-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amounts <A,B> --acknowledge-action-impact [--tx-submitter <ACCOUNT>]`,
3761
3979
  why: "The wallet has channel L2 balance and no unused private notes yet. Use --tx-submitter for stronger transaction-submission privacy.",
3762
3980
  });
3763
3981
  return;
3764
3982
  }
3765
3983
  if (guide.state.wallet?.exists && unusedNotes !== null && unusedNotes > 0) {
3766
3984
  setGuideNextAction(guide, {
3767
- command: `wallet transfer-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID,ID> --recipients <ADDR,ADDR> --amounts <A,B> [--tx-submitter <ACCOUNT>]`,
3985
+ command: `wallet transfer-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID,ID> --recipients <ADDR,ADDR> --amounts <A,B> --acknowledge-action-impact [--tx-submitter <ACCOUNT>]`,
3768
3986
  why: "The wallet has unused private notes. It can transfer or redeem those notes. Use --tx-submitter for stronger transaction-submission privacy.",
3769
3987
  candidates: [
3770
3988
  `wallet get-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network}`,
3771
- `wallet redeem-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID> [--tx-submitter <ACCOUNT>]`,
3989
+ `wallet redeem-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID> --acknowledge-action-impact [--tx-submitter <ACCOUNT>]`,
3772
3990
  ],
3773
3991
  });
3774
3992
  return;
@@ -3869,6 +4087,7 @@ async function handleWalletGetMeta({ args, provider }) {
3869
4087
  printJson({
3870
4088
  action: "wallet get-meta",
3871
4089
  wallet: wallet.walletName,
4090
+ ...walletLifecycleMetadata(wallet.wallet),
3872
4091
  network: walletMetadata.network,
3873
4092
  channelName: walletMetadata.channelName,
3874
4093
  l1Address: signer.address,
@@ -3879,6 +4098,12 @@ async function handleWalletGetMeta({ args, provider }) {
3879
4098
  registeredL2Address: registration.exists ? getAddress(registration.l2Address) : null,
3880
4099
  registeredL2StorageKey: registration.exists ? normalizeBytes32Hex(registration.channelTokenVaultKey) : null,
3881
4100
  registeredLeafIndex: registration.exists ? registration.leafIndex.toString() : null,
4101
+ registeredNoteReceivePubKey: registration.exists
4102
+ ? {
4103
+ x: normalizeBytes32Hex(registration.noteReceivePubKey.x),
4104
+ yParity: Number(registration.noteReceivePubKey.yParity),
4105
+ }
4106
+ : null,
3882
4107
  });
3883
4108
  }
3884
4109
 
@@ -3924,7 +4149,8 @@ async function loadWalletChannelRegistrationState({
3924
4149
  provider,
3925
4150
  requireRegistration = false,
3926
4151
  }) {
3927
- const { signer, l2Identity } = restoreWalletParticipant(walletContext, provider);
4152
+ const signer = requireWalletOwnerSigner(walletContext, provider);
4153
+ const l2Identity = restoreParticipantIdentityFromWallet(walletContext.wallet);
3928
4154
  const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
3929
4155
  const expectedStorageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
3930
4156
  const matchesWallet = registration.exists
@@ -3955,6 +4181,156 @@ async function loadWalletChannelRegistrationState({
3955
4181
  };
3956
4182
  }
3957
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
+
3958
4334
  async function handleWalletGetChannelFund({ args, provider }) {
3959
4335
  const { wallet, walletMetadata } = loadUnlockedWalletWithMetadata(args);
3960
4336
  const {
@@ -4025,13 +4401,9 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4025
4401
  const storageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
4026
4402
  const leafIndex = deriveChannelTokenVaultLeafIndex(storageKey);
4027
4403
 
4028
- const resolvedLeafIndex = leafIndex;
4029
4404
  let approveReceipt = null;
4030
4405
  let receipt = null;
4031
- let joinToll = 0n;
4032
- let status = null;
4033
-
4034
- joinToll = ethers.toBigInt(await context.channelManager.joinToll());
4406
+ const joinToll = ethers.toBigInt(await context.channelManager.joinToll());
4035
4407
  const asset = new Contract(
4036
4408
  context.workspace.canonicalAsset,
4037
4409
  context.bridgeAbiManifest.contracts.erc20.abi,
@@ -4045,6 +4417,14 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4045
4417
  channelManager: context.workspace.channelManager,
4046
4418
  policySnapshot: context.workspace.policySnapshot,
4047
4419
  });
4420
+ await requireActionImpactAcknowledgement("channel-join", args, {
4421
+ l1Address: signer.address,
4422
+ l2Address: l2Identity.l2Address,
4423
+ noteReceivePubKey: JSON.stringify(noteReceiveKeyMaterial.noteReceivePubKey),
4424
+ joinToll: ethers.formatUnits(joinToll, Number(context.workspace.canonicalAssetDecimals)),
4425
+ channelName: context.workspace.channelName,
4426
+ channelId: context.workspace.channelId,
4427
+ });
4048
4428
  if (joinToll !== 0n) {
4049
4429
  approveReceipt = await waitForReceipt(
4050
4430
  await asset.approve(context.workspace.bridgeTokenVault, joinToll, { nonce: nextNonce++ }),
@@ -4060,8 +4440,14 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4060
4440
  { nonce: nextNonce++ },
4061
4441
  ),
4062
4442
  );
4063
- status = "joined";
4064
-
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.");
4065
4451
  await refreshPersistedWorkspaceAfterLocalTransaction({
4066
4452
  context,
4067
4453
  provider,
@@ -4076,8 +4462,9 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4076
4462
  l2Identity,
4077
4463
  walletSecret,
4078
4464
  storageKey,
4079
- leafIndex: resolvedLeafIndex,
4465
+ leafIndex,
4080
4466
  noteReceiveKeyMaterial,
4467
+ lifecycleEpoch,
4081
4468
  rpcUrl,
4082
4469
  });
4083
4470
 
@@ -4085,14 +4472,19 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4085
4472
  action: "channel join",
4086
4473
  workspace: context.workspaceName,
4087
4474
  wallet: walletContext.walletName,
4088
- walletSecretSource: resolvedWalletSecretSource(args),
4089
- walletSecretFile: resolvedWalletSecretFile(network.name, walletContext.walletName),
4475
+ walletSecretSource: "wallet-secret-path-one-time-derivation",
4476
+ walletSecretStored: false,
4477
+ walletSecretRecoveryWarning: "Keep the wallet secret source backed up. If the spending-key file is lost and this wallet secret source is also lost, the CLI cannot rederive the spending key; notes for this wallet cannot be spent, transferred, or redeemed through the normal note flow.",
4090
4478
  channelName: context.workspace.channelName,
4091
4479
  channelId: context.workspace.channelId,
4092
4480
  l1Address: signer.address,
4093
4481
  l2Address: l2Identity.l2Address,
4094
4482
  l2StorageKey: storageKey,
4095
- leafIndex: resolvedLeafIndex.toString(),
4483
+ leafIndex: leafIndex.toString(),
4484
+ epochId: lifecycleEpoch.epochId,
4485
+ lifecycleStatus: lifecycleEpoch.lifecycleStatus,
4486
+ joinedAtTxHash: lifecycleEpoch.joinedAtTxHash,
4487
+ joinedAtBlockNumber: lifecycleEpoch.joinedAtBlockNumber,
4096
4488
  joinTollBaseUnits: joinToll.toString(),
4097
4489
  joinTollTokens: ethers.formatUnits(joinToll, Number(context.workspace.canonicalAssetDecimals)),
4098
4490
  noteReceivePubKey: noteReceiveKeyMaterial.noteReceivePubKey,
@@ -4103,31 +4495,37 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4103
4495
  txUrl: receipt ? explorerTxUrl(network, receipt.hash) : null,
4104
4496
  approveReceipt: approveReceipt ? sanitizeReceipt(approveReceipt) : null,
4105
4497
  receipt: receipt ? sanitizeReceipt(receipt) : null,
4106
- status,
4498
+ status: "joined",
4107
4499
  });
4108
4500
  }
4109
4501
 
4110
4502
  async function handleExitChannel({ args, provider }) {
4111
4503
  const { wallet: walletContext, walletMetadata } = loadUnlockedWalletWithMetadata(args);
4504
+ requireActiveWalletLifecycle(walletContext, "channel exit");
4112
4505
  const { signer, context, channelFund, contextResult } = await loadWalletChannelFundState({
4113
4506
  walletContext,
4114
4507
  provider,
4115
4508
  progressAction: "channel exit",
4116
4509
  });
4510
+ const ownerSigner = requireWalletOwnerSigner(walletContext, provider);
4117
4511
  const network = contextResult.network;
4118
4512
  expect(
4119
4513
  channelFund === 0n,
4120
4514
  [
4121
- `The current channel fund for ${signer.address} is ${channelFund.toString()}.`,
4515
+ `The current channel fund for ${ownerSigner.address} is ${channelFund.toString()}.`,
4122
4516
  "channel exit requires a zero channel balance.",
4123
4517
  "Run wallet withdraw-channel first, then retry channel exit.",
4124
4518
  ].join(" "),
4125
4519
  );
4126
- const [refundAmount, refundBps] = await context.channelManager.getExitTollRefundQuote(signer.address);
4520
+ const [refundAmount, refundBps] = await context.channelManager.getExitTollRefundQuote(ownerSigner.address);
4127
4521
  const receipt = await waitForReceipt(
4128
- await context.bridgeTokenVault.connect(signer).exitChannel(ethers.toBigInt(context.workspace.channelId)),
4522
+ await context.bridgeTokenVault.connect(ownerSigner).exitChannel(ethers.toBigInt(context.workspace.channelId)),
4129
4523
  );
4130
- const cleanup = removeLocalWalletArtifacts(walletContext.walletName, walletMetadata.network);
4524
+ const lifecycleEpoch = await markWalletEpochExited({
4525
+ walletContext,
4526
+ receipt,
4527
+ provider,
4528
+ });
4131
4529
 
4132
4530
  printJson({
4133
4531
  action: "channel exit",
@@ -4135,7 +4533,7 @@ async function handleExitChannel({ args, provider }) {
4135
4533
  network: walletMetadata.network,
4136
4534
  channelName: walletMetadata.channelName,
4137
4535
  channelId: context.workspace.channelId,
4138
- l1Address: signer.address,
4536
+ l1Address: ownerSigner.address,
4139
4537
  currentUserValue: channelFund.toString(),
4140
4538
  refundAmountBaseUnits: refundAmount.toString(),
4141
4539
  refundAmountTokens: ethers.formatUnits(refundAmount, Number(context.workspace.canonicalAssetDecimals)),
@@ -4145,8 +4543,12 @@ async function handleExitChannel({ args, provider }) {
4145
4543
  gasUsed: receiptGasUsed(receipt),
4146
4544
  txUrl: explorerTxUrl(network, receipt.hash),
4147
4545
  receipt: sanitizeReceipt(receipt),
4148
- removedWalletDir: cleanup.removedWalletDir ? cleanup.walletDir : null,
4149
- 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,
4150
4552
  });
4151
4553
  }
4152
4554
 
@@ -4158,6 +4560,7 @@ async function handleGrothVaultMove({ args, provider, direction }) {
4158
4560
  : "wallet withdraw-channel";
4159
4561
  emitProgress(operationName, "loading");
4160
4562
  const { wallet: walletContext } = loadUnlockedWalletWithMetadata(args);
4563
+ requireActiveWalletLifecycle(walletContext, operationName);
4161
4564
  const contextResult = await loadFreshWalletChannelContext({
4162
4565
  walletContext,
4163
4566
  provider,
@@ -4174,6 +4577,13 @@ async function handleGrothVaultMove({ args, provider, direction }) {
4174
4577
  const { signer, l2Identity } = restoreWalletParticipant(walletContext, provider);
4175
4578
  const amountInput = requireArg(args.amount, "--amount");
4176
4579
  const amount = parseTokenAmount(amountInput, Number(context.workspace.canonicalAssetDecimals));
4580
+ await requireActionImpactAcknowledgement(args.command, args, {
4581
+ l1Address: signer.address,
4582
+ l2Address: l2Identity.l2Address,
4583
+ amountInput,
4584
+ channelName: context.workspace.channelName,
4585
+ channelId: context.workspace.channelId,
4586
+ });
4177
4587
  const storageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
4178
4588
  const bridgeTokenVault = new Contract(
4179
4589
  context.workspace.bridgeTokenVault,
@@ -4256,7 +4666,7 @@ async function handleGrothVaultMove({ args, provider, direction }) {
4256
4666
  writeJson(path.join(operationDir, `${operationName}-receipt.json`), sanitizeReceipt(receipt));
4257
4667
  writeJson(path.join(operationDir, "state_snapshot.json"), transition.nextSnapshot);
4258
4668
  writeJson(path.join(operationDir, "state_snapshot.normalized.json"), transition.nextSnapshot);
4259
- sealWalletOperationDir(operationDir, walletContext.walletSecret);
4669
+ sealWalletOperationDir(operationDir, walletOperationSealSecret(walletContext));
4260
4670
 
4261
4671
  context.currentSnapshot = transition.nextSnapshot;
4262
4672
  await refreshPersistedWorkspaceAfterLocalTransaction({
@@ -4291,6 +4701,11 @@ async function handleWithdrawBridge({ args, network, provider }) {
4291
4701
  const bridgeVaultContext = await loadBridgeVaultContext({ provider, chainId });
4292
4702
  const amountInput = requireArg(args.amount, "--amount");
4293
4703
  const amount = parseTokenAmount(amountInput, Number(bridgeVaultContext.canonicalAssetDecimals));
4704
+ await requireActionImpactAcknowledgement("account-withdraw-bridge", args, {
4705
+ l1Address: signer.address,
4706
+ amountInput,
4707
+ bridgeTokenVault: bridgeVaultContext.bridgeTokenVaultAddress,
4708
+ });
4294
4709
  const bridgeTokenVault = new Contract(
4295
4710
  bridgeVaultContext.bridgeTokenVaultAddress,
4296
4711
  bridgeVaultContext.bridgeAbiManifest.contracts.bridgeTokenVault.abi,
@@ -4371,6 +4786,8 @@ function resolveFunctionMetadataProofForExecution({
4371
4786
 
4372
4787
  async function handleMintNotes({ args, provider }) {
4373
4788
  const { wallet } = loadUnlockedWalletWithMetadata(args);
4789
+ requireActiveWalletLifecycle(wallet, "wallet mint-notes");
4790
+ requireWalletSpendingCapability(wallet);
4374
4791
  const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
4375
4792
  const amountInputs = parseAmountVector(requireArg(args.amounts, "--amounts"), {
4376
4793
  allowZeroEntries: true,
@@ -4400,6 +4817,19 @@ async function handleMintNotes({ args, provider }) {
4400
4817
  `${channelFund.toString()}. Run wallet get-channel-fund to inspect the available balance.`,
4401
4818
  ].join(" "),
4402
4819
  );
4820
+ const { signer, l2Identity } = restoreWalletParticipant(wallet, provider);
4821
+ const { txSubmitter } = resolveTxSubmitterSigner({
4822
+ args,
4823
+ ownerSigner: signer,
4824
+ provider,
4825
+ });
4826
+ await requireActionImpactAcknowledgement("wallet-mint-notes", args, {
4827
+ l1Address: txSubmitter.address,
4828
+ l2Address: l2Identity.l2Address,
4829
+ amounts: baseUnitAmounts.map(({ amountInput }) => amountInput).join(", "),
4830
+ channelName: wallet.wallet.channelName,
4831
+ channelId: wallet.wallet.channelId,
4832
+ });
4403
4833
  const templatePayload = buildMintNotesTemplatePayload({
4404
4834
  wallet,
4405
4835
  baseUnitAmounts: baseUnitAmounts.map(({ amountBaseUnits }) => amountBaseUnits),
@@ -4432,6 +4862,7 @@ async function handleMintNotes({ args, provider }) {
4432
4862
  sourceFunction: templatePayload.method,
4433
4863
  sourceTxHash: execution.receipt.hash,
4434
4864
  bridgeCommitmentKeys: execution.noteLifecycle.outputCommitmentKeys,
4865
+ sourceBlockNumber: execution.receipt.blockNumber,
4435
4866
  }),
4436
4867
  gasUsed: receiptGasUsed(execution.receipt),
4437
4868
  txUrl: explorerTxUrl(contextResult.network, execution.receipt.hash),
@@ -4444,6 +4875,9 @@ async function handleMintNotes({ args, provider }) {
4444
4875
 
4445
4876
  async function handleRedeemNotes({ args, provider }) {
4446
4877
  const { wallet } = loadUnlockedWalletWithMetadata(args);
4878
+ requireActiveWalletLifecycle(wallet, "wallet redeem-notes");
4879
+ requireWalletViewingCapability(wallet);
4880
+ requireWalletSpendingCapability(wallet);
4447
4881
  const noteIds = parseNoteIdVector(requireArg(args.noteIds, "--note-ids"));
4448
4882
  const preparedContextResult = await loadFreshWalletChannelContext({
4449
4883
  walletContext: wallet,
@@ -4458,6 +4892,19 @@ async function handleRedeemNotes({ args, provider }) {
4458
4892
  preConsumedBlockDelta: preparedContextResult.autoRecoveryBlockDelta,
4459
4893
  });
4460
4894
  const inputNotes = loadWalletUnusedInputNotes(wallet, noteIds);
4895
+ const { signer, l2Identity } = restoreWalletParticipant(wallet, provider);
4896
+ const { txSubmitter } = resolveTxSubmitterSigner({
4897
+ args,
4898
+ ownerSigner: signer,
4899
+ provider,
4900
+ });
4901
+ await requireActionImpactAcknowledgement("wallet-redeem-notes", args, {
4902
+ l1Address: txSubmitter.address,
4903
+ l2Address: l2Identity.l2Address,
4904
+ noteIds: noteIds.join(", "),
4905
+ channelName: wallet.wallet.channelName,
4906
+ channelId: wallet.wallet.channelId,
4907
+ });
4461
4908
  const templatePayload = buildRedeemNotesTemplatePayload({
4462
4909
  wallet,
4463
4910
  inputNotes,
@@ -4513,13 +4960,20 @@ async function handleWalletGetNotes({ args, provider }) {
4513
4960
  progressAction: "wallet get-notes",
4514
4961
  });
4515
4962
  const context = contextResult.context;
4516
- const noteReceiveFreshness = await ensureWalletNoteReceiveStateCurrent({
4517
- walletContext: wallet,
4518
- context,
4519
- provider,
4520
- progressAction: "wallet get-notes",
4521
- preConsumedBlockDelta: contextResult.autoRecoveryBlockDelta,
4522
- });
4963
+ const noteReceiveFreshness = wallet.wallet.noteReceivePrivateKey
4964
+ ? await ensureWalletNoteReceiveStateCurrent({
4965
+ walletContext: wallet,
4966
+ context,
4967
+ provider,
4968
+ progressAction: "wallet get-notes",
4969
+ preConsumedBlockDelta: contextResult.autoRecoveryBlockDelta,
4970
+ })
4971
+ : {
4972
+ nextBlock: wallet.wallet.noteReceiveLastScannedBlock,
4973
+ latestBlock: await provider.getBlockNumber(),
4974
+ recoveredWalletWorkspace: false,
4975
+ recoveredDeliveryState: null,
4976
+ };
4523
4977
 
4524
4978
  const unusedTrackedNotes = wallet.wallet.notes.unusedOrder
4525
4979
  .map((commitment) => wallet.wallet.notes.unused[commitment])
@@ -4539,8 +4993,20 @@ async function handleWalletGetNotes({ args, provider }) {
4539
4993
  canonicalAssetDecimals,
4540
4994
  })));
4541
4995
 
4542
- const unusedTotal = unusedTrackedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n);
4543
- const spentTotal = spentTrackedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n);
4996
+ const canComputeTotals = [...unusedTrackedNotes, ...spentTrackedNotes].every((note) => note.value !== null);
4997
+ const unusedTotal = canComputeTotals ? unusedTrackedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n) : null;
4998
+ const spentTotal = canComputeTotals ? spentTrackedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n) : null;
4999
+ const evidenceExport = args.exportEvidence
5000
+ ? await exportWalletGetNotesEvidenceBundle({
5001
+ args,
5002
+ provider,
5003
+ walletContext: wallet,
5004
+ walletMetadata,
5005
+ context,
5006
+ unusedTrackedNotes,
5007
+ spentTrackedNotes,
5008
+ })
5009
+ : null;
4544
5010
 
4545
5011
  printJson({
4546
5012
  action: "wallet get-notes",
@@ -4550,22 +5016,517 @@ async function handleWalletGetNotes({ args, provider }) {
4550
5016
  controller: wallet.wallet.controller,
4551
5017
  unusedNotes,
4552
5018
  spentNotes,
4553
- unusedTotalBaseUnits: unusedTotal.toString(),
4554
- unusedTotalTokens: ethers.formatUnits(unusedTotal, canonicalAssetDecimals),
4555
- spentTotalBaseUnits: spentTotal.toString(),
4556
- spentTotalTokens: ethers.formatUnits(spentTotal, canonicalAssetDecimals),
5019
+ unusedTotalBaseUnits: unusedTotal?.toString() ?? null,
5020
+ unusedTotalTokens: unusedTotal === null ? null : ethers.formatUnits(unusedTotal, canonicalAssetDecimals),
5021
+ spentTotalBaseUnits: spentTotal?.toString() ?? null,
5022
+ spentTotalTokens: spentTotal === null ? null : ethers.formatUnits(spentTotal, canonicalAssetDecimals),
4557
5023
  bridgeStatusMismatches: [...unusedNotes, ...spentNotes].filter((note) => !note.walletStatusMatchesBridge).length,
4558
5024
  noteReceiveLastScannedBlock: noteReceiveFreshness.nextBlock,
4559
5025
  latestBlock: noteReceiveFreshness.latestBlock,
5026
+ viewingKeyAvailable: Boolean(wallet.wallet.noteReceivePrivateKey),
4560
5027
  recoveredWalletWorkspace: noteReceiveFreshness.recoveredWalletWorkspace,
4561
5028
  recoveredFromLogs: noteReceiveFreshness.recoveredDeliveryState?.importedNotes ?? 0,
4562
5029
  scannedDeliveryLogs: noteReceiveFreshness.recoveredDeliveryState?.scannedLogs ?? 0,
4563
5030
  noteReceiveScanRange: noteReceiveFreshness.recoveredDeliveryState?.scanRange ?? null,
5031
+ evidenceExport,
5032
+ });
5033
+ }
5034
+
5035
+ async function exportWalletGetNotesEvidenceBundle({
5036
+ args,
5037
+ provider,
5038
+ walletContext,
5039
+ walletMetadata,
5040
+ context,
5041
+ unusedTrackedNotes,
5042
+ spentTrackedNotes,
5043
+ }) {
5044
+ const outputPath = path.resolve(String(requireArg(args.exportEvidence, "--export-evidence")));
5045
+ ensureDir(path.dirname(outputPath));
5046
+
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
+ }
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));
5070
+
5071
+ const txHashes = uniqueNonNull([
5072
+ ...noteInputs.map(({ note }) => note.createdAtTxHash),
5073
+ ...noteInputs.map(({ note }) => note.spentAtTxHash),
5074
+ ]);
5075
+ const transactionEvidence = await buildTransactionEvidenceMap({ provider, txHashes });
5076
+ const blockTimestampCache = buildBlockTimestampCache(transactionEvidence);
5077
+ const noteRecords = noteInputs.map(({ note, walletContext: noteWalletContext }) => buildEvidenceNoteRecord({
5078
+ note,
5079
+ walletContext: noteWalletContext,
5080
+ walletMetadata,
5081
+ context,
5082
+ transactionEvidence,
5083
+ blockTimestampCache,
5084
+ }));
5085
+ const indexes = buildEvidenceIndexes(noteRecords);
5086
+ const manifest = buildEvidenceManifest({
5087
+ outputPath,
5088
+ walletContext,
5089
+ walletContexts: evidenceWalletContexts,
5090
+ walletMetadata,
5091
+ context,
5092
+ noteRecords,
5093
+ txHashes,
5094
+ });
5095
+
5096
+ const archive = new AdmZip();
5097
+ addEvidenceJson(archive, "manifest.json", manifest);
5098
+ addEvidenceJson(archive, "indexes/by-commitment.json", indexes.byCommitment);
5099
+ addEvidenceJson(archive, "indexes/by-nullifier.json", indexes.byNullifier);
5100
+ addEvidenceJson(archive, "indexes/by-creation-tx.json", indexes.byCreationTx);
5101
+ addEvidenceJson(archive, "indexes/by-spend-tx.json", indexes.bySpendTx);
5102
+ addEvidenceJson(archive, "indexes/by-block-range.json", indexes.byBlockRange);
5103
+ addEvidenceJson(archive, "indexes/by-counterparty.json", indexes.byCounterparty);
5104
+ for (const record of noteRecords) {
5105
+ addEvidenceJson(archive, evidenceNotePath(record), record);
5106
+ }
5107
+ for (const [txHash, txRecord] of Object.entries(transactionEvidence)) {
5108
+ addEvidenceJson(archive, `transactions/${txHash}.json`, txRecord.transaction);
5109
+ addEvidenceJson(archive, `receipts/${txHash}.json`, txRecord.receipt);
5110
+ addEvidenceJson(archive, `events/${txHash}.json`, txRecord.events);
5111
+ }
5112
+
5113
+ assertEvidenceBundleDoesNotContainSecrets({
5114
+ wallets: evidenceWalletContexts.map((entry) => entry.wallet),
5115
+ payload: {
5116
+ manifest,
5117
+ indexes,
5118
+ noteRecords,
5119
+ transactionEvidence,
5120
+ },
5121
+ });
5122
+ fs.rmSync(outputPath, { force: true });
5123
+ archive.writeZip(outputPath);
5124
+ protectSecretFile(outputPath, "wallet evidence export ZIP");
5125
+
5126
+ return {
5127
+ output: outputPath,
5128
+ format: WALLET_EVIDENCE_BUNDLE_FORMAT,
5129
+ formatVersion: WALLET_EVIDENCE_BUNDLE_FORMAT_VERSION,
5130
+ noteCount: noteRecords.length,
5131
+ walletEpochCount: evidenceWalletContexts.length,
5132
+ transactionCount: txHashes.length,
5133
+ containsNotePlaintext: true,
5134
+ containsSpendingKey: false,
5135
+ containsViewingKey: false,
5136
+ containsWalletSecret: false,
5137
+ warning: "Local full-note evidence bundle. Do not submit as-is unless full wallet-history disclosure is intended.",
5138
+ };
5139
+ }
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
+
5168
+ function validateEvidenceNotePlaintext(note, wallet) {
5169
+ expect(
5170
+ note.owner && note.value !== null && note.salt && note.encryptedNoteValue,
5171
+ [
5172
+ `Cannot export evidence for note ${note.commitment} because plaintext note data is incomplete.`,
5173
+ "Import the wallet viewing key and run wallet recover-workspace before exporting evidence.",
5174
+ ].join(" "),
5175
+ );
5176
+ expect(
5177
+ getAddress(note.owner) === getAddress(wallet.l2Address),
5178
+ `Cannot export evidence for note ${note.commitment}: owner does not match wallet L2 address.`,
5179
+ );
5180
+ const recomputedSalt = computeEncryptedNoteSalt(note.encryptedNoteValue);
5181
+ expect(
5182
+ ethers.toBigInt(recomputedSalt) === ethers.toBigInt(note.salt),
5183
+ `Cannot export evidence for note ${note.commitment}: encrypted note salt mismatch.`,
5184
+ );
5185
+ const plaintext = normalizePlaintextNote(note);
5186
+ expect(
5187
+ ethers.toBigInt(computeNoteCommitment(plaintext)) === ethers.toBigInt(note.commitment),
5188
+ `Cannot export evidence for note ${note.commitment}: commitment mismatch.`,
5189
+ );
5190
+ expect(
5191
+ ethers.toBigInt(computeNullifier(plaintext)) === ethers.toBigInt(note.nullifier),
5192
+ `Cannot export evidence for note ${note.commitment}: nullifier mismatch.`,
5193
+ );
5194
+ }
5195
+
5196
+ async function buildTransactionEvidenceMap({ provider, txHashes }) {
5197
+ const entries = {};
5198
+ for (const txHash of txHashes) {
5199
+ const [transaction, receipt] = await Promise.all([
5200
+ provider.getTransaction(txHash).catch(() => null),
5201
+ provider.getTransactionReceipt(txHash).catch(() => null),
5202
+ ]);
5203
+ const blockNumber = receipt?.blockNumber ?? transaction?.blockNumber ?? null;
5204
+ const block = blockNumber === null ? null : await provider.getBlock(blockNumber).catch(() => null);
5205
+ entries[txHash] = {
5206
+ transaction: sanitizeTransactionEvidence(transaction, block),
5207
+ receipt: receipt ? sanitizeReceipt(receipt) : null,
5208
+ events: receipt ? sanitizeReceiptEvents(receipt) : [],
5209
+ };
5210
+ }
5211
+ return entries;
5212
+ }
5213
+
5214
+ function sanitizeTransactionEvidence(transaction, block) {
5215
+ if (!transaction) {
5216
+ return null;
5217
+ }
5218
+ return normalizeCliOutput(serializeBigInts({
5219
+ hash: transaction.hash,
5220
+ from: transaction.from,
5221
+ to: transaction.to,
5222
+ nonce: transaction.nonce,
5223
+ data: transaction.data,
5224
+ value: transaction.value,
5225
+ chainId: transaction.chainId,
5226
+ blockHash: transaction.blockHash,
5227
+ blockNumber: transaction.blockNumber,
5228
+ blockTimestamp: block?.timestamp ?? null,
5229
+ blockTimestampIso: block?.timestamp ? new Date(Number(block.timestamp) * 1000).toISOString() : null,
5230
+ }));
5231
+ }
5232
+
5233
+ function sanitizeReceiptEvents(receipt) {
5234
+ return (receipt.logs ?? []).map((log) => normalizeCliOutput(serializeBigInts({
5235
+ address: log.address,
5236
+ blockHash: log.blockHash,
5237
+ blockNumber: log.blockNumber,
5238
+ transactionHash: log.transactionHash,
5239
+ transactionIndex: log.transactionIndex,
5240
+ logIndex: log.index ?? log.logIndex ?? null,
5241
+ topics: log.topics,
5242
+ data: log.data,
5243
+ })));
5244
+ }
5245
+
5246
+ function buildBlockTimestampCache(transactionEvidence) {
5247
+ const cache = {};
5248
+ for (const txRecord of Object.values(transactionEvidence)) {
5249
+ const tx = txRecord.transaction;
5250
+ if (tx?.blockNumber !== null && tx?.blockNumber !== undefined) {
5251
+ cache[Number(tx.blockNumber)] = {
5252
+ timestamp: tx.blockTimestamp ?? null,
5253
+ iso: tx.blockTimestampIso ?? null,
5254
+ };
5255
+ }
5256
+ }
5257
+ return cache;
5258
+ }
5259
+
5260
+ function buildEvidenceNoteRecord({
5261
+ note,
5262
+ walletContext,
5263
+ walletMetadata,
5264
+ context,
5265
+ transactionEvidence,
5266
+ blockTimestampCache,
5267
+ }) {
5268
+ const creationBlockNumber = note.createdAtBlockNumber
5269
+ ?? transactionEvidence[note.createdAtTxHash]?.transaction?.blockNumber
5270
+ ?? null;
5271
+ const spentBlockNumber = note.spentAtBlockNumber
5272
+ ?? transactionEvidence[note.spentAtTxHash]?.transaction?.blockNumber
5273
+ ?? null;
5274
+ const scheme = note.encryptedNoteValue ? unpackEncryptedNoteValue(note.encryptedNoteValue).scheme : null;
5275
+ return normalizeCliOutput({
5276
+ recordType: "note-evidence",
5277
+ recordVersion: 1,
5278
+ noteId: note.commitment,
5279
+ walletScope: {
5280
+ network: walletMetadata.network,
5281
+ chainId: walletContext.wallet.chainId,
5282
+ channelName: walletMetadata.channelName,
5283
+ channelId: walletContext.wallet.channelId,
5284
+ wallet: walletContext.walletName,
5285
+ ...walletLifecycleMetadata(walletContext.wallet),
5286
+ walletL1Address: walletContext.wallet.l1Address,
5287
+ walletL2Address: walletContext.wallet.l2Address,
5288
+ controller: context.workspace.controller,
5289
+ },
5290
+ plaintext: {
5291
+ owner: note.owner,
5292
+ value: note.value,
5293
+ salt: note.salt,
5294
+ },
5295
+ derived: {
5296
+ commitment: note.commitment,
5297
+ nullifier: note.nullifier,
5298
+ commitmentStorageKey: note.bridgeCommitmentKey,
5299
+ nullifierStorageKey: note.bridgeNullifierKey,
5300
+ },
5301
+ encryptedDelivery: {
5302
+ encryptedNoteValue: note.encryptedNoteValue,
5303
+ saltDerivation: "poseidon(encryptedNoteValue)",
5304
+ scheme: encryptedNoteSchemeLabel(scheme),
5305
+ event: {
5306
+ txHash: note.createdAtTxHash,
5307
+ blockNumber: creationBlockNumber,
5308
+ blockTimestamp: blockTimestampCache[Number(creationBlockNumber)]?.timestamp ?? null,
5309
+ blockTimestampIso: blockTimestampCache[Number(creationBlockNumber)]?.iso ?? null,
5310
+ logIndex: note.createdAtLogIndex,
5311
+ contract: context.workspace.channelManager,
5312
+ },
5313
+ },
5314
+ creation: {
5315
+ txHash: note.createdAtTxHash,
5316
+ blockNumber: creationBlockNumber,
5317
+ blockTimestamp: blockTimestampCache[Number(creationBlockNumber)]?.timestamp ?? null,
5318
+ blockTimestampIso: blockTimestampCache[Number(creationBlockNumber)]?.iso ?? null,
5319
+ function: note.createdByFunction,
5320
+ outputIndex: note.createdOutputIndex,
5321
+ acceptedTransition: acceptedTransitionReference(note.createdAtTxHash),
5322
+ },
5323
+ spend: {
5324
+ status: note.status,
5325
+ txHash: note.spentAtTxHash,
5326
+ blockNumber: spentBlockNumber,
5327
+ blockTimestamp: blockTimestampCache[Number(spentBlockNumber)]?.timestamp ?? null,
5328
+ blockTimestampIso: blockTimestampCache[Number(spentBlockNumber)]?.iso ?? null,
5329
+ function: note.spentByFunction,
5330
+ inputIndex: note.spentInputIndex,
5331
+ acceptedTransition: note.spentAtTxHash ? acceptedTransitionReference(note.spentAtTxHash) : null,
5332
+ },
5333
+ relationshipHints: {
5334
+ direction: note.counterpartyDirection ?? inferEvidenceDirection(note, scheme),
5335
+ counterpartyL2Address: note.counterpartyL2Address,
5336
+ counterpartyL1Address: null,
5337
+ confidence: note.counterpartyConfidence ?? (note.counterpartyL2Address ? "direct-local-metadata" : "unavailable"),
5338
+ },
5339
+ verificationClaims: {
5340
+ commitmentRecomputesFromPlaintext: true,
5341
+ nullifierRecomputesFromPlaintext: true,
5342
+ ownerMatchesWalletL2Address: true,
5343
+ spendingKeyIncluded: false,
5344
+ viewingKeyIncluded: false,
5345
+ walletSecretIncluded: false,
5346
+ fullWalletHistoryRequiredForFinalDisclosure: false,
5347
+ },
5348
+ });
5349
+ }
5350
+
5351
+ function acceptedTransitionReference(txHash) {
5352
+ if (!txHash) {
5353
+ return null;
5354
+ }
5355
+ return {
5356
+ txHash,
5357
+ transactionPath: `transactions/${txHash}.json`,
5358
+ receiptPath: `receipts/${txHash}.json`,
5359
+ eventsPath: `events/${txHash}.json`,
5360
+ proofCalldataLocation: "transactions[].data",
5361
+ localProofArtifactIncluded: false,
5362
+ };
5363
+ }
5364
+
5365
+ function encryptedNoteSchemeLabel(scheme) {
5366
+ if (scheme === ENCRYPTED_NOTE_SCHEME_SELF_MINT) {
5367
+ return "self-mint";
5368
+ }
5369
+ if (scheme === ENCRYPTED_NOTE_SCHEME_TRANSFER) {
5370
+ return "transfer";
5371
+ }
5372
+ return "unknown";
5373
+ }
5374
+
5375
+ function inferEvidenceDirection(note, scheme) {
5376
+ if (scheme === ENCRYPTED_NOTE_SCHEME_SELF_MINT) {
5377
+ return "self-mint";
5378
+ }
5379
+ if (note.spentByFunction?.startsWith("transferNotes")) {
5380
+ return "sent";
5381
+ }
5382
+ if (note.createdByFunction?.startsWith("transferNotes")) {
5383
+ return "received";
5384
+ }
5385
+ return "unknown";
5386
+ }
5387
+
5388
+ function buildEvidenceIndexes(noteRecords) {
5389
+ const indexes = {
5390
+ byCommitment: {},
5391
+ byNullifier: {},
5392
+ byCreationTx: {},
5393
+ bySpendTx: {},
5394
+ byBlockRange: [],
5395
+ byCounterparty: {
5396
+ unavailable: [],
5397
+ },
5398
+ };
5399
+ for (const record of noteRecords) {
5400
+ const pathName = evidenceNotePath(record);
5401
+ indexes.byCommitment[record.derived.commitment] = pathName;
5402
+ indexes.byNullifier[record.derived.nullifier] = pathName;
5403
+ pushIndexEntry(indexes.byCreationTx, record.creation.txHash, pathName);
5404
+ pushIndexEntry(indexes.bySpendTx, record.spend.txHash, pathName);
5405
+ indexes.byBlockRange.push({
5406
+ commitment: record.derived.commitment,
5407
+ createdAtBlockNumber: record.creation.blockNumber,
5408
+ spentAtBlockNumber: record.spend.blockNumber,
5409
+ path: pathName,
5410
+ });
5411
+ const counterparty = record.relationshipHints.counterpartyL2Address;
5412
+ if (counterparty) {
5413
+ if (!indexes.byCounterparty[counterparty]) {
5414
+ indexes.byCounterparty[counterparty] = { sent: [], received: [], both: [] };
5415
+ }
5416
+ const direction = record.relationshipHints.direction === "received" ? "received" : "sent";
5417
+ indexes.byCounterparty[counterparty][direction].push(pathName);
5418
+ indexes.byCounterparty[counterparty].both.push(pathName);
5419
+ } else {
5420
+ indexes.byCounterparty.unavailable.push(pathName);
5421
+ }
5422
+ }
5423
+ indexes.byBlockRange.sort((left, right) =>
5424
+ Number(left.createdAtBlockNumber ?? Number.MAX_SAFE_INTEGER)
5425
+ - Number(right.createdAtBlockNumber ?? Number.MAX_SAFE_INTEGER));
5426
+ return indexes;
5427
+ }
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
+
5444
+ function pushIndexEntry(index, key, value) {
5445
+ if (!key) {
5446
+ return;
5447
+ }
5448
+ if (!index[key]) {
5449
+ index[key] = [];
5450
+ }
5451
+ index[key].push(value);
5452
+ }
5453
+
5454
+ function buildEvidenceManifest({
5455
+ outputPath,
5456
+ walletContext,
5457
+ walletContexts = [walletContext],
5458
+ walletMetadata,
5459
+ context,
5460
+ noteRecords,
5461
+ txHashes,
5462
+ }) {
5463
+ return normalizeCliOutput({
5464
+ format: WALLET_EVIDENCE_BUNDLE_FORMAT,
5465
+ formatVersion: WALLET_EVIDENCE_BUNDLE_FORMAT_VERSION,
5466
+ bundleType: "local-full-note-evidence",
5467
+ generatedAt: new Date().toISOString(),
5468
+ outputFileName: path.basename(outputPath),
5469
+ network: walletMetadata.network,
5470
+ chainId: walletContext.wallet.chainId,
5471
+ channelName: walletMetadata.channelName,
5472
+ channelId: walletContext.wallet.channelId,
5473
+ wallet: walletContext.walletName,
5474
+ walletL1Address: walletContext.wallet.l1Address,
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
+ })),
5482
+ controller: context.workspace.controller,
5483
+ channelManager: context.workspace.channelManager,
5484
+ bridgeTokenVault: context.workspace.bridgeTokenVault,
5485
+ containsAllLocallyKnownNotes: true,
5486
+ containsAllLocalWalletEpochs: true,
5487
+ containsNotePlaintext: true,
5488
+ noteCount: noteRecords.length,
5489
+ transactionCount: txHashes.length,
5490
+ intendedUse: "Input for private-state-cli investigator; not a default exchange submission package.",
5491
+ warning: "DO_NOT_SUBMIT_AS_IS unless full wallet-history disclosure is intended.",
5492
+ excludedSecrets: {
5493
+ spendingKey: true,
5494
+ viewingKey: true,
5495
+ walletSecret: true,
5496
+ accountPrivateKey: true,
5497
+ keyFiles: true,
5498
+ },
4564
5499
  });
4565
5500
  }
4566
5501
 
5502
+ function addEvidenceJson(archive, archivePath, value) {
5503
+ archive.addFile(archivePath, Buffer.from(`${JSON.stringify(normalizeCliOutput(value), null, 2)}\n`, "utf8"));
5504
+ }
5505
+
5506
+ function assertEvidenceBundleDoesNotContainSecrets({ wallet = null, wallets = null, payload }) {
5507
+ const serialized = JSON.stringify(payload);
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);
5513
+ for (const value of forbiddenValues) {
5514
+ expect(
5515
+ !serialized.includes(value),
5516
+ "Evidence export refused to write authority-bearing wallet secret material.",
5517
+ );
5518
+ }
5519
+ }
5520
+
5521
+ function uniqueNonNull(values) {
5522
+ return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))];
5523
+ }
5524
+
4567
5525
  async function handleTransferNotes({ args, provider }) {
4568
5526
  const { wallet } = loadUnlockedWalletWithMetadata(args);
5527
+ requireActiveWalletLifecycle(wallet, "wallet transfer-notes");
5528
+ requireWalletViewingCapability(wallet);
5529
+ requireWalletSpendingCapability(wallet);
4569
5530
  const { signer } = restoreWalletParticipant(wallet, provider);
4570
5531
  const preparedContextResult = await loadFreshWalletChannelContext({
4571
5532
  walletContext: wallet,
@@ -4603,6 +5564,19 @@ async function handleTransferNotes({ args, provider }) {
4603
5564
  "The sum of --amounts must equal the sum of the selected input note values.",
4604
5565
  );
4605
5566
 
5567
+ const { txSubmitter } = resolveTxSubmitterSigner({
5568
+ args,
5569
+ ownerSigner: signer,
5570
+ provider,
5571
+ });
5572
+ await requireActionImpactAcknowledgement("wallet-transfer-notes", args, {
5573
+ l1Address: txSubmitter.address,
5574
+ l2Address: wallet.wallet.l2Address,
5575
+ noteIds: noteIds.join(", "),
5576
+ amounts: amountInputs.join(", "),
5577
+ channelName: context.workspace.channelName,
5578
+ channelId: context.workspace.channelId,
5579
+ });
4606
5580
  const templatePayload = await buildTransferNotesTemplatePayload({
4607
5581
  context,
4608
5582
  signer,
@@ -4623,6 +5597,9 @@ async function handleTransferNotes({ args, provider }) {
4623
5597
  sourceFunction: templatePayload.method,
4624
5598
  sourceTxHash: execution.receipt.hash,
4625
5599
  bridgeCommitmentKeys: execution.noteLifecycle.outputCommitmentKeys,
5600
+ sourceBlockNumber: execution.receipt.blockNumber,
5601
+ counterpartyL2Addresses: templatePayload.recipientAddresses,
5602
+ counterpartyDirection: "sent",
4626
5603
  });
4627
5604
 
4628
5605
  printJson({
@@ -4744,16 +5721,40 @@ function ensureWallet({
4744
5721
  storageKey,
4745
5722
  leafIndex,
4746
5723
  noteReceiveKeyMaterial,
5724
+ lifecycleEpoch,
4747
5725
  rpcUrl,
4748
5726
  }) {
4749
5727
  const walletName = walletNameForChannelAndAddress(channelContext.workspace.channelName, signerAddress);
4750
- 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);
4751
5738
  expect(!walletConfigExists(walletDir), `Wallet ${walletName} already exists on ${channelContext.workspace.network}.`);
4752
5739
  ensureDir(walletDir);
4753
5740
  ensureDir(path.join(walletDir, "operations"));
4754
5741
 
4755
5742
  const wallet = normalizeWallet({
5743
+ walletFormatVersion: WALLET_WORKSPACE_FORMAT_VERSION,
4756
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,
4757
5758
  network: channelContext.workspace.network,
4758
5759
  rpcUrl,
4759
5760
  chainId: channelContext.workspace.chainId,
@@ -4769,10 +5770,8 @@ function ensureWallet({
4769
5770
  l2AccountingVault: channelContext.workspace.l2AccountingVault,
4770
5771
  liquidBalancesSlot: channelContext.workspace.liquidBalancesSlot,
4771
5772
  l1Address: signerAddress,
4772
- l1PrivateKey: normalizePrivateKey(signerPrivateKey),
4773
5773
  l2Address: l2Identity.l2Address,
4774
- l2PrivateKey: ethers.hexlify(l2Identity.l2PrivateKey),
4775
- l2PublicKey: ethers.hexlify(l2Identity.l2PublicKey),
5774
+ l2PublicKey: l2Identity.l2PublicKey ? ethers.hexlify(l2Identity.l2PublicKey) : null,
4776
5775
  l2DerivationMode: CHANNEL_BOUND_L2_DERIVATION_MODE,
4777
5776
  l2DerivationChannelName: channelContext.workspace.channelName,
4778
5777
  l2StorageKey: storageKey,
@@ -4788,31 +5787,75 @@ function ensureWallet({
4788
5787
  spent: {},
4789
5788
  },
4790
5789
  });
5790
+ if (l2Identity.l2PrivateKey) {
5791
+ wallet.l2PrivateKey = ethers.hexlify(l2Identity.l2PrivateKey);
5792
+ }
5793
+ wallet.noteReceivePrivateKey = normalizePrivateKey(noteReceiveKeyMaterial.privateKey);
4791
5794
 
4792
5795
  const context = {
4793
5796
  walletName,
4794
5797
  walletDir,
4795
5798
  wallet,
4796
- walletSecret,
5799
+ walletSecret: wallet.l2PrivateKey,
4797
5800
  };
5801
+ persistWalletKeys(context);
4798
5802
  persistWallet(context);
4799
- persistWalletMetadata(context);
5803
+ persistWalletIndexForContext(context);
4800
5804
  return context;
4801
5805
  }
4802
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
+
4803
5842
  function normalizeWallet(wallet) {
4804
5843
  assertWalletHasCurrentFormat(wallet, wallet.name ?? "unknown");
5844
+ expect(wallet.walletEpochId, "Current wallet metadata requires walletEpochId. Run wallet recover-workspace to rebuild this wallet.");
4805
5845
  const unusedNotes = Object.values(wallet.notes.unused).map(normalizeTrackedNote);
4806
5846
  unusedNotes.sort(compareNotesByValueDesc);
4807
5847
  const spentNotes = Object.values(wallet.notes.spent).map(normalizeTrackedNote);
4808
5848
 
4809
5849
  return {
4810
5850
  ...wallet,
5851
+ canonicalWalletName: wallet.canonicalWalletName ?? wallet.name,
5852
+ walletEpochId: wallet.walletEpochId,
5853
+ lifecycleStatus: wallet.lifecycleStatus ?? "active",
4811
5854
  canonicalAssetDecimals: Number(wallet.canonicalAssetDecimals),
4812
5855
  l2Nonce: Number(wallet.l2Nonce),
4813
- l1PrivateKey: normalizePrivateKey(wallet.l1PrivateKey),
4814
- l2PrivateKey: ethers.hexlify(wallet.l2PrivateKey),
4815
- l2PublicKey: ethers.hexlify(wallet.l2PublicKey),
5856
+ l2PrivateKey: wallet.l2PrivateKey ? ethers.hexlify(wallet.l2PrivateKey) : null,
5857
+ l2PublicKey: wallet.l2PublicKey ? ethers.hexlify(wallet.l2PublicKey) : null,
5858
+ noteReceivePrivateKey: wallet.noteReceivePrivateKey ? normalizePrivateKey(wallet.noteReceivePrivateKey) : null,
4816
5859
  noteReceiveDerivationVersion: Number(wallet.noteReceiveDerivationVersion),
4817
5860
  noteReceiveTypedDataMethod: wallet.noteReceiveTypedDataMethod,
4818
5861
  noteReceivePubKeyX: normalizeBytes32Hex(wallet.noteReceivePubKeyX),
@@ -4822,18 +5865,18 @@ function normalizeWallet(wallet) {
4822
5865
  unused: Object.fromEntries(unusedNotes.map((note) => [note.commitment, note])),
4823
5866
  spent: Object.fromEntries(spentNotes.map((note) => [note.nullifier, note])),
4824
5867
  unusedOrder: unusedNotes.map((note) => note.commitment),
4825
- unusedBalance: unusedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n).toString(),
5868
+ unusedBalance: unusedNotes.every((note) => note.value !== null)
5869
+ ? unusedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n).toString()
5870
+ : null,
4826
5871
  },
4827
5872
  };
4828
5873
  }
4829
5874
 
4830
5875
  function assertWalletHasCurrentFormat(wallet, walletName) {
4831
5876
  const requiredKeys = [
5877
+ "walletFormatVersion",
4832
5878
  "canonicalAssetDecimals",
4833
5879
  "l2Nonce",
4834
- "l1PrivateKey",
4835
- "l2PrivateKey",
4836
- "l2PublicKey",
4837
5880
  "noteReceiveDerivationVersion",
4838
5881
  "noteReceiveTypedDataMethod",
4839
5882
  "noteReceivePubKeyX",
@@ -4849,18 +5892,48 @@ function assertWalletHasCurrentFormat(wallet, walletName) {
4849
5892
  wallet.notes && typeof wallet.notes.unused === "object" && typeof wallet.notes.spent === "object",
4850
5893
  `Wallet ${walletName} was not created with the current CLI notes format.`,
4851
5894
  );
5895
+ expect(
5896
+ Number(wallet.walletFormatVersion) === WALLET_WORKSPACE_FORMAT_VERSION,
5897
+ [
5898
+ `Wallet ${walletName} uses unsupported wallet workspace format ${wallet.walletFormatVersion ?? "missing"}.`,
5899
+ "Rebuild the wallet metadata with wallet recover-workspace.",
5900
+ ].join(" "),
5901
+ );
4852
5902
  }
4853
5903
 
4854
5904
  function normalizeTrackedNote(note) {
4855
5905
  return {
4856
- owner: getAddress(note.owner),
4857
- value: ethers.toBigInt(note.value).toString(),
4858
- salt: normalizeBytes32Hex(note.salt),
5906
+ owner: note.owner ? getAddress(note.owner) : null,
5907
+ value: note.value !== undefined && note.value !== null ? ethers.toBigInt(note.value).toString() : null,
5908
+ salt: note.salt ? normalizeBytes32Hex(note.salt) : null,
4859
5909
  commitment: normalizeBytes32Hex(note.commitment),
4860
5910
  nullifier: normalizeBytes32Hex(note.nullifier),
5911
+ encryptedNoteValue: note.encryptedNoteValue ? normalizeEncryptedNoteValueWords(note.encryptedNoteValue) : null,
4861
5912
  status: note.status,
4862
5913
  sourceFunction: note.sourceFunction ?? null,
4863
5914
  sourceTxHash: note.sourceTxHash ?? null,
5915
+ createdAtTxHash: note.createdAtTxHash ?? (note.status === "unused" ? note.sourceTxHash ?? null : null),
5916
+ createdAtBlockNumber: note.createdAtBlockNumber !== undefined && note.createdAtBlockNumber !== null
5917
+ ? Number(note.createdAtBlockNumber)
5918
+ : null,
5919
+ createdAtLogIndex: note.createdAtLogIndex !== undefined && note.createdAtLogIndex !== null
5920
+ ? Number(note.createdAtLogIndex)
5921
+ : null,
5922
+ createdByFunction: note.createdByFunction ?? (note.status === "unused" ? note.sourceFunction ?? null : null),
5923
+ createdOutputIndex: note.createdOutputIndex !== undefined && note.createdOutputIndex !== null
5924
+ ? Number(note.createdOutputIndex)
5925
+ : null,
5926
+ spentAtTxHash: note.spentAtTxHash ?? (note.status === "spent" ? note.sourceTxHash ?? null : null),
5927
+ spentAtBlockNumber: note.spentAtBlockNumber !== undefined && note.spentAtBlockNumber !== null
5928
+ ? Number(note.spentAtBlockNumber)
5929
+ : null,
5930
+ spentByFunction: note.spentByFunction ?? (note.status === "spent" ? note.sourceFunction ?? null : null),
5931
+ spentInputIndex: note.spentInputIndex !== undefined && note.spentInputIndex !== null
5932
+ ? Number(note.spentInputIndex)
5933
+ : null,
5934
+ counterpartyL2Address: note.counterpartyL2Address ? getAddress(note.counterpartyL2Address) : null,
5935
+ counterpartyDirection: note.counterpartyDirection ?? null,
5936
+ counterpartyConfidence: note.counterpartyConfidence ?? null,
4864
5937
  bridgeCommitmentKey: note.bridgeCommitmentKey ? normalizeBytes32Hex(note.bridgeCommitmentKey) : null,
4865
5938
  bridgeNullifierKey: note.bridgeNullifierKey ? normalizeBytes32Hex(note.bridgeNullifierKey) : null,
4866
5939
  };
@@ -4886,15 +5959,28 @@ async function buildWalletNoteBridgeStatus({
4886
5959
  return {
4887
5960
  owner: note.owner,
4888
5961
  valueBaseUnits: note.value,
4889
- valueTokens: ethers.formatUnits(ethers.toBigInt(note.value), canonicalAssetDecimals),
5962
+ valueTokens: note.value === null ? null : ethers.formatUnits(ethers.toBigInt(note.value), canonicalAssetDecimals),
4890
5963
  commitment: note.commitment,
4891
5964
  nullifier: note.nullifier,
5965
+ encryptedNoteValue: note.encryptedNoteValue,
4892
5966
  walletStatus: note.status,
4893
5967
  bridgeCommitmentExists: commitmentExists,
4894
5968
  bridgeNullifierUsed: nullifierUsed,
4895
5969
  walletStatusMatchesBridge: commitmentExists && nullifierUsed === expectedNullifierUsed,
4896
5970
  sourceFunction: note.sourceFunction ?? null,
4897
5971
  sourceTxHash: note.sourceTxHash ?? null,
5972
+ createdAtTxHash: note.createdAtTxHash ?? null,
5973
+ createdAtBlockNumber: note.createdAtBlockNumber ?? null,
5974
+ createdAtLogIndex: note.createdAtLogIndex ?? null,
5975
+ createdByFunction: note.createdByFunction ?? null,
5976
+ createdOutputIndex: note.createdOutputIndex ?? null,
5977
+ spentAtTxHash: note.spentAtTxHash ?? null,
5978
+ spentAtBlockNumber: note.spentAtBlockNumber ?? null,
5979
+ spentByFunction: note.spentByFunction ?? null,
5980
+ spentInputIndex: note.spentInputIndex ?? null,
5981
+ counterpartyL2Address: note.counterpartyL2Address ?? null,
5982
+ counterpartyDirection: note.counterpartyDirection ?? null,
5983
+ counterpartyConfidence: note.counterpartyConfidence ?? null,
4898
5984
  };
4899
5985
  }
4900
5986
 
@@ -4912,8 +5998,8 @@ async function readBooleanStorageValueFromSnapshot({ snapshot, storageAddress, s
4912
5998
  }
4913
5999
 
4914
6000
  function compareNotesByValueDesc(left, right) {
4915
- const leftValue = ethers.toBigInt(left.value);
4916
- const rightValue = ethers.toBigInt(right.value);
6001
+ const leftValue = left.value === null || left.value === undefined ? 0n : ethers.toBigInt(left.value);
6002
+ const rightValue = right.value === null || right.value === undefined ? 0n : ethers.toBigInt(right.value);
4917
6003
  if (leftValue === rightValue) {
4918
6004
  return left.commitment.localeCompare(right.commitment);
4919
6005
  }
@@ -4922,13 +6008,28 @@ function compareNotesByValueDesc(left, right) {
4922
6008
 
4923
6009
  function buildTrackedNote(note, sourceFunction, sourceTxHash, bridgeKeys = {}) {
4924
6010
  const normalizedNote = normalizePlaintextNote(note);
6011
+ const createdTxHash = bridgeKeys.createdAtTxHash ?? sourceTxHash ?? null;
6012
+ const createdFunction = bridgeKeys.createdByFunction ?? sourceFunction ?? null;
4925
6013
  return {
4926
6014
  ...normalizedNote,
4927
6015
  commitment: normalizeBytes32Hex(computeNoteCommitment(normalizedNote)),
4928
6016
  nullifier: normalizeBytes32Hex(computeNullifier(normalizedNote)),
6017
+ encryptedNoteValue: note.encryptedNoteValue ? normalizeEncryptedNoteValueWords(note.encryptedNoteValue) : null,
4929
6018
  status: "unused",
4930
6019
  sourceFunction,
4931
6020
  sourceTxHash,
6021
+ createdAtTxHash: createdTxHash,
6022
+ createdAtBlockNumber: bridgeKeys.createdAtBlockNumber ?? null,
6023
+ createdAtLogIndex: bridgeKeys.createdAtLogIndex ?? null,
6024
+ createdByFunction: createdFunction,
6025
+ createdOutputIndex: bridgeKeys.createdOutputIndex ?? null,
6026
+ spentAtTxHash: bridgeKeys.spentAtTxHash ?? null,
6027
+ spentAtBlockNumber: bridgeKeys.spentAtBlockNumber ?? null,
6028
+ spentByFunction: bridgeKeys.spentByFunction ?? null,
6029
+ spentInputIndex: bridgeKeys.spentInputIndex ?? null,
6030
+ counterpartyL2Address: bridgeKeys.counterpartyL2Address ? getAddress(bridgeKeys.counterpartyL2Address) : null,
6031
+ counterpartyDirection: bridgeKeys.counterpartyDirection ?? null,
6032
+ counterpartyConfidence: bridgeKeys.counterpartyConfidence ?? null,
4932
6033
  bridgeCommitmentKey: bridgeKeys.bridgeCommitmentKey
4933
6034
  ? normalizeBytes32Hex(bridgeKeys.bridgeCommitmentKey)
4934
6035
  : null,
@@ -4943,9 +6044,19 @@ function buildLifecycleTrackedOutputs({
4943
6044
  sourceFunction,
4944
6045
  sourceTxHash,
4945
6046
  bridgeCommitmentKeys,
6047
+ sourceBlockNumber = null,
6048
+ counterpartyL2Addresses = [],
6049
+ counterpartyDirection = null,
4946
6050
  }) {
4947
6051
  return (outputNotes ?? []).map((note, index) => buildTrackedNote(note, sourceFunction, sourceTxHash, {
4948
6052
  bridgeCommitmentKey: bridgeCommitmentKeys?.[index] ?? null,
6053
+ createdAtTxHash: sourceTxHash,
6054
+ createdAtBlockNumber: sourceBlockNumber,
6055
+ createdByFunction: sourceFunction,
6056
+ createdOutputIndex: index,
6057
+ counterpartyL2Address: counterpartyL2Addresses?.[index] ?? null,
6058
+ counterpartyDirection,
6059
+ counterpartyConfidence: counterpartyL2Addresses?.[index] ? "direct-local-metadata" : null,
4949
6060
  }));
4950
6061
  }
4951
6062
 
@@ -4958,13 +6069,11 @@ async function recoverWalletReceivedNotes({
4958
6069
  progressAction = null,
4959
6070
  fromGenesis = false,
4960
6071
  }) {
4961
- const resolvedNoteReceiveKeyMaterial = noteReceiveKeyMaterial ?? await deriveNoteReceiveKeyMaterial({
4962
- signer,
4963
- chainId: context.workspace.chainId,
4964
- channelId: context.workspace.channelId,
4965
- channelName: context.workspace.channelName,
4966
- account: signer.address,
4967
- });
6072
+ const resolvedNoteReceiveKeyMaterial = noteReceiveKeyMaterial ?? {
6073
+ privateKey: walletContext.wallet.noteReceivePrivateKey,
6074
+ noteReceivePubKey: walletNoteReceivePubKey(walletContext),
6075
+ };
6076
+ requireWalletViewingCapability(walletContext);
4968
6077
  const recoveredDeliveryState = await recoverDeliveredNotesFromEventLogs({
4969
6078
  walletContext,
4970
6079
  context,
@@ -5064,12 +6173,22 @@ async function recoverDeliveredNotesFromEventLogs({
5064
6173
  owner: walletContext.wallet.l2Address,
5065
6174
  value: recoveredValue,
5066
6175
  salt: computeEncryptedNoteSalt(encryptedNoteValue),
6176
+ encryptedNoteValue,
5067
6177
  });
5068
6178
  const commitment = normalizeBytes32Hex(computeNoteCommitment(plaintextNote));
5069
6179
  const nullifier = normalizeBytes32Hex(computeNullifier(plaintextNote));
5070
- const trackedNote = buildTrackedNote(plaintextNote, sourceFunction, log.transactionHash, {
6180
+ const trackedNote = buildTrackedNote({
6181
+ ...plaintextNote,
6182
+ encryptedNoteValue,
6183
+ }, sourceFunction, log.transactionHash, {
5071
6184
  bridgeCommitmentKey: derivePrivateStateControllerMappingStorageKey(commitment, commitmentExistsSlot),
5072
6185
  bridgeNullifierKey: derivePrivateStateControllerMappingStorageKey(nullifier, nullifierUsedSlot),
6186
+ createdAtTxHash: log.transactionHash,
6187
+ createdAtBlockNumber: log.blockNumber !== undefined ? Number(log.blockNumber) : null,
6188
+ createdAtLogIndex: log.index ?? log.logIndex ?? null,
6189
+ createdByFunction: sourceFunction,
6190
+ counterpartyDirection: scheme === ENCRYPTED_NOTE_SCHEME_SELF_MINT ? "self-mint" : "received",
6191
+ counterpartyConfidence: scheme === ENCRYPTED_NOTE_SCHEME_SELF_MINT ? "direct-local-metadata" : "unavailable",
5073
6192
  });
5074
6193
  const commitmentExists = await readBooleanStorageValueFromSnapshot({
5075
6194
  snapshot: context.currentSnapshot,
@@ -5420,7 +6539,7 @@ function snapshotRootForAddress(snapshot, storageAddress) {
5420
6539
  return snapshot.stateRoots[addressIndex];
5421
6540
  }
5422
6541
 
5423
- function applyNoteLifecycleToWallet(walletContext, lifecycle, sourceFunction, sourceTxHash) {
6542
+ function applyNoteLifecycleToWallet(walletContext, lifecycle, sourceFunction, sourceTxHash, sourceBlockNumber = null) {
5424
6543
  for (const [index, inputNote] of lifecycle.inputs.entries()) {
5425
6544
  const trackedInput = buildTrackedNote(inputNote, sourceFunction, sourceTxHash);
5426
6545
  const existingUnusedNote = walletContext.wallet.notes.unused[trackedInput.commitment];
@@ -5434,12 +6553,26 @@ function applyNoteLifecycleToWallet(walletContext, lifecycle, sourceFunction, so
5434
6553
  sourceFunction,
5435
6554
  sourceTxHash,
5436
6555
  bridgeNullifierKey: lifecycle.inputNullifierKeys?.[index] ?? existingUnusedNote.bridgeNullifierKey ?? null,
6556
+ spentAtTxHash: sourceTxHash,
6557
+ spentAtBlockNumber: sourceBlockNumber,
6558
+ spentByFunction: sourceFunction,
6559
+ spentInputIndex: index,
6560
+ counterpartyL2Address: lifecycle.spendCounterpartyL2Address ?? existingUnusedNote.counterpartyL2Address ?? null,
6561
+ counterpartyDirection: lifecycle.spendCounterpartyDirection ?? existingUnusedNote.counterpartyDirection ?? null,
6562
+ counterpartyConfidence: lifecycle.spendCounterpartyConfidence ?? existingUnusedNote.counterpartyConfidence ?? null,
5437
6563
  };
5438
6564
  }
5439
6565
 
5440
6566
  for (const [index, outputNote] of lifecycle.outputs.entries()) {
5441
6567
  const trackedOutput = buildTrackedNote(outputNote, sourceFunction, sourceTxHash, {
5442
6568
  bridgeCommitmentKey: lifecycle.outputCommitmentKeys?.[index] ?? null,
6569
+ createdAtTxHash: sourceTxHash,
6570
+ createdAtBlockNumber: sourceBlockNumber,
6571
+ createdByFunction: sourceFunction,
6572
+ createdOutputIndex: index,
6573
+ counterpartyL2Address: lifecycle.outputCounterpartyL2Addresses?.[index] ?? null,
6574
+ counterpartyDirection: lifecycle.outputCounterpartyDirection ?? null,
6575
+ counterpartyConfidence: lifecycle.outputCounterpartyL2Addresses?.[index] ? "direct-local-metadata" : null,
5443
6576
  });
5444
6577
  if (trackedOutput.owner !== walletContext.wallet.l2Address) {
5445
6578
  continue;
@@ -5524,6 +6657,7 @@ function buildMintEncryptedOutputs({ wallet, values }) {
5524
6657
  owner: wallet.wallet.l2Address,
5525
6658
  value: ethers.toBigInt(value).toString(),
5526
6659
  salt: computeEncryptedNoteSalt(encryptedNoteValue),
6660
+ encryptedNoteValue,
5527
6661
  });
5528
6662
  }
5529
6663
  return {
@@ -5581,6 +6715,7 @@ async function buildTransferNotesTemplatePayload({
5581
6715
  owner: recipient,
5582
6716
  value: ethers.toBigInt(outputAmounts[index]).toString(),
5583
6717
  salt,
6718
+ encryptedNoteValue,
5584
6719
  });
5585
6720
  }
5586
6721
  return {
@@ -5589,6 +6724,7 @@ async function buildTransferNotesTemplatePayload({
5589
6724
  args: [transferOutputs, inputNotes],
5590
6725
  lifecycleInputs: inputNotes,
5591
6726
  lifecycleOutputs,
6727
+ recipientAddresses,
5592
6728
  };
5593
6729
  }
5594
6730
 
@@ -5609,6 +6745,10 @@ function loadWalletUnusedInputNotes(walletContext, noteIds) {
5609
6745
  return noteIds.map((noteId) => {
5610
6746
  const trackedNote = walletContext.wallet.notes.unused[noteId];
5611
6747
  expect(trackedNote, `Unknown unused note commitment: ${noteId}.`);
6748
+ expect(
6749
+ trackedNote.owner && trackedNote.value !== null && trackedNote.salt,
6750
+ `Note ${noteId} is encrypted-only. Import the wallet viewing key before spending it.`,
6751
+ );
5612
6752
  return normalizePlaintextNote(trackedNote);
5613
6753
  });
5614
6754
  }
@@ -5957,6 +7097,7 @@ async function executeWalletDirectTemplateCommand({
5957
7097
  }) {
5958
7098
  emitProgress(operationName, "loading");
5959
7099
  const { signer, l2Identity } = restoreWalletParticipant(wallet, provider);
7100
+ requireWalletSpendingCapability(wallet);
5960
7101
  const {
5961
7102
  txSubmitter,
5962
7103
  source: txSubmitterSource,
@@ -6098,7 +7239,16 @@ async function executeWalletTemplateSend({
6098
7239
 
6099
7240
  emitProgress(operationName, "persisting");
6100
7241
  wallet.wallet.l2Nonce = nonce + 1;
6101
- applyNoteLifecycleToWallet(wallet, noteLifecycle, functionName, receipt.hash);
7242
+ if (functionName.startsWith("transferNotes")) {
7243
+ noteLifecycle.spendCounterpartyL2Address = templatePayload.recipientAddresses?.[0] ?? null;
7244
+ noteLifecycle.spendCounterpartyDirection = "sent";
7245
+ noteLifecycle.spendCounterpartyConfidence = noteLifecycle.spendCounterpartyL2Address
7246
+ ? "direct-local-metadata"
7247
+ : null;
7248
+ noteLifecycle.outputCounterpartyL2Addresses = templatePayload.recipientAddresses ?? [];
7249
+ noteLifecycle.outputCounterpartyDirection = "sent";
7250
+ }
7251
+ applyNoteLifecycleToWallet(wallet, noteLifecycle, functionName, receipt.hash, receipt.blockNumber);
6102
7252
  context.currentSnapshot = nextSnapshot;
6103
7253
  persistWallet(wallet);
6104
7254
  await refreshPersistedWorkspaceAfterLocalTransaction({
@@ -6107,7 +7257,7 @@ async function executeWalletTemplateSend({
6107
7257
  receipt,
6108
7258
  progressAction: operationName,
6109
7259
  });
6110
- sealWalletOperationDir(operationDir, wallet.walletSecret);
7260
+ sealWalletOperationDir(operationDir, walletOperationSealSecret(wallet));
6111
7261
 
6112
7262
  return {
6113
7263
  wallet,
@@ -6195,34 +7345,76 @@ async function loadJoinChannelContext({ args, network, provider }) {
6195
7345
  };
6196
7346
  }
6197
7347
 
6198
- function loadWallet(walletName, walletSecret, networkName) {
7348
+ function loadWallet(walletName, networkName) {
7349
+ const normalizedWalletName = requireWalletName({ wallet: walletName });
7350
+ const normalizedNetworkName = requireNetworkName({ network: networkName });
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 }) {
6199
7360
  const normalizedWalletName = requireWalletName({ wallet: walletName });
6200
7361
  const normalizedNetworkName = requireNetworkName({ network: networkName });
6201
- const walletDir = walletPath(normalizedWalletName, normalizedNetworkName);
6202
7362
  if (!walletConfigExists(walletDir)) {
6203
7363
  throw cliError(CLI_ERROR_CODES.UNKNOWN_WALLET, `Unknown wallet: ${normalizedWalletName} on ${normalizedNetworkName}.`);
6204
7364
  }
6205
- const rawWallet = readEncryptedWalletJson(walletConfigPath(walletDir), walletSecret);
7365
+ const rawWallet = readJson(walletNotesMetadataPath(walletDir));
7366
+ const spendingKey = readWalletKeySecretIfExists({
7367
+ networkName: normalizedNetworkName,
7368
+ walletName: normalizedWalletName,
7369
+ keyKind: "spending",
7370
+ });
7371
+ const viewingKey = readWalletKeySecretIfExists({
7372
+ networkName: normalizedNetworkName,
7373
+ walletName: normalizedWalletName,
7374
+ keyKind: "viewing",
7375
+ });
7376
+ if (spendingKey && walletSpendingKeyMatchesWallet(spendingKey.metadata, rawWallet)) {
7377
+ rawWallet.l2PrivateKey = spendingKey.privateKey;
7378
+ rawWallet.l2PublicKey = spendingKey.metadata?.l2PublicKey ?? rawWallet.l2PublicKey;
7379
+ }
7380
+ if (viewingKey && walletViewingKeyMatchesWallet(viewingKey.metadata, rawWallet)) {
7381
+ rawWallet.noteReceivePrivateKey = viewingKey.privateKey;
7382
+ }
6206
7383
  assertWalletHasRequiredKeys(rawWallet, normalizedWalletName);
6207
7384
  const wallet = normalizeWallet(rawWallet);
6208
7385
  assertWalletUsesChannelBoundDerivation(wallet, normalizedWalletName);
6209
- const restoredIdentity = restoreParticipantIdentityFromWallet(wallet);
6210
- expect(
6211
- wallet.l2Address === restoredIdentity.l2Address,
6212
- `Wallet ${normalizedWalletName} is internally inconsistent: stored keys do not match the stored L2 address.`,
6213
- );
7386
+ if (wallet.l2PrivateKey) {
7387
+ const restoredIdentity = restoreParticipantIdentityFromWallet(wallet);
7388
+ expect(
7389
+ wallet.l2Address === restoredIdentity.l2Address,
7390
+ `Wallet ${normalizedWalletName} is internally inconsistent: stored keys do not match the stored L2 address.`,
7391
+ );
7392
+ }
7393
+ hydrateWalletNotesWithViewingKey(wallet);
6214
7394
  const context = {
6215
7395
  walletName: normalizedWalletName,
6216
7396
  walletDir,
6217
7397
  wallet,
6218
- walletSecret,
7398
+ walletSecret: wallet.l2PrivateKey ?? wallet.noteReceivePrivateKey ?? null,
6219
7399
  };
6220
7400
  return context;
6221
7401
  }
6222
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
+
6223
7415
  function loadUnlockedWalletWithMetadata(args) {
6224
7416
  const networkName = requireNetworkName(args);
6225
- const wallet = loadWallet(requireWalletName(args), requireWalletSecret(args), networkName);
7417
+ const wallet = loadWallet(requireWalletName(args), networkName);
6226
7418
  const walletMetadata = loadWalletMetadata(wallet.walletName, networkName);
6227
7419
  assertWalletMatchesMetadata(wallet, walletMetadata);
6228
7420
  expect(
@@ -6238,19 +7430,71 @@ function loadUnlockedWalletWithMetadata(args) {
6238
7430
  };
6239
7431
  }
6240
7432
 
7433
+ function readWalletKeySecretIfExists({ networkName, walletName, keyKind }) {
7434
+ const secretPath = keyKind === "spending"
7435
+ ? walletSpendingKeySecretPath(networkName, walletName)
7436
+ : walletViewingKeySecretPath(networkName, walletName);
7437
+ if (!fs.existsSync(secretPath)) {
7438
+ return null;
7439
+ }
7440
+ const payload = JSON.parse(readSecretFile(secretPath, `${keyKind} key`));
7441
+ validateWalletKeyPayload(payload, keyKind);
7442
+ return payload;
7443
+ }
7444
+
7445
+ function validateWalletKeyPayload(payload, keyKind) {
7446
+ expect(payload?.format === WALLET_KEY_EXPORT_FORMAT, `Invalid ${keyKind} key file format.`);
7447
+ expect(Number(payload.formatVersion) === WALLET_EXPORT_FORMAT_VERSION, `Unsupported ${keyKind} key file version.`);
7448
+ expect(payload.keyKind === keyKind, `Expected ${keyKind} key file, received ${payload.keyKind}.`);
7449
+ expect(typeof payload.privateKey === "string" && payload.privateKey.length > 0, `Missing ${keyKind} private key.`);
7450
+ expect(payload.metadata && typeof payload.metadata === "object", `Missing ${keyKind} key metadata.`);
7451
+ }
7452
+
7453
+ function hydrateWalletNotesWithViewingKey(wallet) {
7454
+ if (!wallet.noteReceivePrivateKey) {
7455
+ return;
7456
+ }
7457
+ const noteGroups = [wallet.notes?.unused ?? {}, wallet.notes?.spent ?? {}];
7458
+ for (const notes of noteGroups) {
7459
+ for (const note of Object.values(notes)) {
7460
+ if (!note.encryptedNoteValue || note.value !== null) {
7461
+ continue;
7462
+ }
7463
+ try {
7464
+ const { scheme } = unpackEncryptedNoteValue(note.encryptedNoteValue);
7465
+ let value;
7466
+ if (scheme === ENCRYPTED_NOTE_SCHEME_TRANSFER) {
7467
+ value = decryptEncryptedNoteValue({
7468
+ encryptedValue: note.encryptedNoteValue,
7469
+ noteReceivePrivateKey: wallet.noteReceivePrivateKey,
7470
+ chainId: wallet.chainId,
7471
+ channelId: wallet.channelId,
7472
+ owner: wallet.l2Address,
7473
+ });
7474
+ } else if (scheme === ENCRYPTED_NOTE_SCHEME_SELF_MINT) {
7475
+ value = decryptMintEncryptedNoteValue({
7476
+ encryptedValue: note.encryptedNoteValue,
7477
+ noteReceivePrivateKey: wallet.noteReceivePrivateKey,
7478
+ chainId: wallet.chainId,
7479
+ channelId: wallet.channelId,
7480
+ owner: wallet.l2Address,
7481
+ });
7482
+ } else {
7483
+ continue;
7484
+ }
7485
+ note.owner = wallet.l2Address;
7486
+ note.value = ethers.toBigInt(value).toString();
7487
+ note.salt = computeEncryptedNoteSalt(note.encryptedNoteValue);
7488
+ } catch {
7489
+ // Keep encrypted-only note records readable even when the local viewing key cannot decrypt them.
7490
+ }
7491
+ }
7492
+ }
7493
+ wallet.notes = normalizeWallet(wallet).notes;
7494
+ }
7495
+
6241
7496
  function assertWalletHasRequiredKeys(wallet, walletName) {
6242
- expect(
6243
- typeof wallet.l1PrivateKey === "string" && wallet.l1PrivateKey.length > 0,
6244
- `Wallet ${walletName} is missing the stored L1 private key.`,
6245
- );
6246
- expect(
6247
- typeof wallet.l2PrivateKey === "string" && wallet.l2PrivateKey.length > 0,
6248
- `Wallet ${walletName} is missing the stored L2 private key.`,
6249
- );
6250
- expect(
6251
- typeof wallet.l2PublicKey === "string" && wallet.l2PublicKey.length > 0,
6252
- `Wallet ${walletName} is missing the stored L2 public key.`,
6253
- );
7497
+ expect(wallet.walletFormatVersion !== undefined, `Wallet ${walletName} is missing walletFormatVersion.`);
6254
7498
  }
6255
7499
 
6256
7500
  function assertWalletUsesChannelBoundDerivation(wallet, walletName) {
@@ -6271,6 +7515,13 @@ function assertWalletUsesChannelBoundDerivation(wallet, walletName) {
6271
7515
  }
6272
7516
 
6273
7517
  function restoreParticipantIdentityFromWallet(wallet) {
7518
+ if (!wallet.l2PrivateKey) {
7519
+ return {
7520
+ l2PrivateKey: null,
7521
+ l2PublicKey: wallet.l2PublicKey ? Uint8Array.from(ethers.getBytes(wallet.l2PublicKey)) : null,
7522
+ l2Address: getAddress(wallet.l2Address),
7523
+ };
7524
+ }
6274
7525
  const l2PrivateKey = Uint8Array.from(ethers.getBytes(wallet.l2PrivateKey));
6275
7526
  const l2PublicKey = Uint8Array.from(ethers.getBytes(wallet.l2PublicKey));
6276
7527
  const l2Address = getAddress(fromEdwardsToAddress(l2PublicKey).toString());
@@ -6282,7 +7533,14 @@ function restoreParticipantIdentityFromWallet(wallet) {
6282
7533
  }
6283
7534
 
6284
7535
  function restoreWalletSigner(walletContext, provider) {
6285
- return new Wallet(normalizePrivateKey(walletContext.wallet.l1PrivateKey), provider);
7536
+ const privateKey = findAccountPrivateKeyForAddress(walletContext.wallet.network, walletContext.wallet.l1Address);
7537
+ if (privateKey) {
7538
+ return new Wallet(privateKey, provider);
7539
+ }
7540
+ return {
7541
+ address: getAddress(walletContext.wallet.l1Address),
7542
+ provider,
7543
+ };
6286
7544
  }
6287
7545
 
6288
7546
  function restoreWalletParticipant(walletContext, provider) {
@@ -6292,6 +7550,85 @@ function restoreWalletParticipant(walletContext, provider) {
6292
7550
  };
6293
7551
  }
6294
7552
 
7553
+ function requireWalletOwnerSigner(walletContext, provider) {
7554
+ const signer = restoreWalletSigner(walletContext, provider);
7555
+ expect(
7556
+ typeof signer.privateKey === "string",
7557
+ [
7558
+ `Missing local account secret for wallet owner ${walletContext.wallet.l1Address}.`,
7559
+ "Import the matching account secret or use a command-specific transaction submitter where supported.",
7560
+ ].join(" "),
7561
+ );
7562
+ return signer;
7563
+ }
7564
+
7565
+ function requireWalletSpendingCapability(walletContext) {
7566
+ expect(
7567
+ walletContext.wallet.l2PrivateKey,
7568
+ [
7569
+ `Wallet ${walletContext.walletName} is missing its spending key.`,
7570
+ "Import it with wallet import spending-key before commands that spend notes or change L2 channel accounting state.",
7571
+ ].join(" "),
7572
+ );
7573
+ }
7574
+
7575
+ function requireWalletViewingCapability(walletContext) {
7576
+ expect(
7577
+ walletContext.wallet.noteReceivePrivateKey,
7578
+ [
7579
+ `Wallet ${walletContext.walletName} is missing its viewing key.`,
7580
+ "Import it with wallet import viewing-key before commands that decrypt or refresh received notes.",
7581
+ ].join(" "),
7582
+ );
7583
+ }
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
+
7595
+ function walletOperationSealSecret(walletContext) {
7596
+ const secret = walletContext.wallet.l2PrivateKey
7597
+ ?? walletContext.wallet.noteReceivePrivateKey
7598
+ ?? findAccountPrivateKeyForAddress(walletContext.wallet.network, walletContext.wallet.l1Address);
7599
+ expect(
7600
+ secret,
7601
+ `Wallet ${walletContext.walletName} needs a local key to seal operation artifacts.`,
7602
+ );
7603
+ return secret;
7604
+ }
7605
+
7606
+ function findAccountPrivateKeyForAddress(networkName, l1Address) {
7607
+ const accountsRoot = path.join(secretRoot, requireNetworkName({ network: networkName }), "accounts");
7608
+ if (!fs.existsSync(accountsRoot)) {
7609
+ return null;
7610
+ }
7611
+ for (const entry of fs.readdirSync(accountsRoot, { withFileTypes: true })) {
7612
+ if (!entry.isDirectory()) {
7613
+ continue;
7614
+ }
7615
+ const privateKeyPath = path.join(accountsRoot, entry.name, "private-key");
7616
+ if (!fs.existsSync(privateKeyPath)) {
7617
+ continue;
7618
+ }
7619
+ try {
7620
+ const privateKey = normalizePrivateKey(readSecretFile(privateKeyPath, "--account"));
7621
+ const signer = new Wallet(privateKey);
7622
+ if (ethers.toBigInt(getAddress(signer.address)) === ethers.toBigInt(getAddress(l1Address))) {
7623
+ return privateKey;
7624
+ }
7625
+ } catch {
7626
+ continue;
7627
+ }
7628
+ }
7629
+ return null;
7630
+ }
7631
+
6295
7632
  function loadBridgeResources({ chainId }) {
6296
7633
  const bridgeDeploymentPath = defaultBridgeDeploymentPath(chainId);
6297
7634
  const bridgeDeployment = readJson(bridgeDeploymentPath);
@@ -6309,11 +7646,11 @@ function loadBridgeResources({ chainId }) {
6309
7646
  function loadWalletMetadata(walletName, networkName) {
6310
7647
  const normalizedWalletName = requireWalletName({ wallet: walletName });
6311
7648
  const normalizedNetworkName = requireNetworkName({ network: networkName });
6312
- const walletDir = walletPath(normalizedWalletName, normalizedNetworkName);
7649
+ const walletDir = selectedWalletEpochDir(normalizedWalletName, normalizedNetworkName);
6313
7650
  if (!walletConfigExists(walletDir)) {
6314
7651
  throw cliError(CLI_ERROR_CODES.UNKNOWN_WALLET, `Unknown wallet: ${normalizedWalletName} on ${normalizedNetworkName}.`);
6315
7652
  }
6316
- const metadataPath = walletMetadataPath(walletDir);
7653
+ const metadataPath = walletNotesMetadataPath(walletDir);
6317
7654
  if (!fs.existsSync(metadataPath)) {
6318
7655
  throw new Error(`Wallet ${normalizedWalletName} is missing unencrypted metadata at ${metadataPath}.`);
6319
7656
  }
@@ -6342,14 +7679,14 @@ function assertWalletMatchesMetadata(walletContext, walletMetadata) {
6342
7679
  walletContext.wallet.network === walletMetadata.network,
6343
7680
  [
6344
7681
  `Wallet ${walletContext.walletName} metadata network (${walletMetadata.network}) does not match`,
6345
- `the encrypted wallet network (${walletContext.wallet.network}).`,
7682
+ `the wallet note metadata network (${walletContext.wallet.network}).`,
6346
7683
  ].join(" "),
6347
7684
  );
6348
7685
  expect(
6349
7686
  walletContext.wallet.channelName === walletMetadata.channelName,
6350
7687
  [
6351
7688
  `Wallet ${walletContext.walletName} metadata channelName (${walletMetadata.channelName}) does not match`,
6352
- `the encrypted wallet channel (${walletContext.wallet.channelName}).`,
7689
+ `the wallet note metadata channel (${walletContext.wallet.channelName}).`,
6353
7690
  ].join(" "),
6354
7691
  );
6355
7692
  }
@@ -7535,6 +8872,7 @@ const OUTPUT_BYTES32_SCALAR_KEYS = new Set([
7535
8872
  "commitment",
7536
8873
  "currentRootVectorHash",
7537
8874
  "currentUserKey",
8875
+ "createdAtTxHash",
7538
8876
  "emittedRootVectorHash",
7539
8877
  "ephemeralPubKeyX",
7540
8878
  "hash",
@@ -7548,6 +8886,7 @@ const OUTPUT_BYTES32_SCALAR_KEYS = new Set([
7548
8886
  "rootVectorHash",
7549
8887
  "salt",
7550
8888
  "sourceTxHash",
8889
+ "spentAtTxHash",
7551
8890
  "topic0",
7552
8891
  "transactionHash",
7553
8892
  "txHash",
@@ -7744,6 +9083,13 @@ function parseArgs(argv) {
7744
9083
  && parsed.positional[1]
7745
9084
  ) {
7746
9085
  parsed.command = `${parsed.command}-${parsed.positional[1]}`;
9086
+ if (
9087
+ parsed.positional[0] === "wallet"
9088
+ && (parsed.positional[1] === "export" || parsed.positional[1] === "import")
9089
+ && parsed.positional[2]
9090
+ ) {
9091
+ parsed.command = `${parsed.command}-${parsed.positional[2]}`;
9092
+ }
7747
9093
  parsed.positional = [parsed.command];
7748
9094
  }
7749
9095
  return parsed;
@@ -7773,18 +9119,6 @@ function parseTokenAmount(value, decimals) {
7773
9119
  }
7774
9120
  }
7775
9121
 
7776
- function requireWalletSecret(args) {
7777
- if (args.wallet !== undefined && args.network !== undefined) {
7778
- return resolveWalletSecretForName({
7779
- networkName: requireNetworkName(args),
7780
- walletName: requireWalletName(args),
7781
- });
7782
- }
7783
- throw new Error(
7784
- "Missing --wallet and --network. Wallet commands use the wallet-local default secret file.",
7785
- );
7786
- }
7787
-
7788
9122
  function requireArg(value, label) {
7789
9123
  if (value === undefined || value === null || value === "") {
7790
9124
  throw new Error(`Missing ${label}.`);
@@ -7849,6 +9183,13 @@ function requireL1Signer(args, provider) {
7849
9183
 
7850
9184
  function resolveTxSubmitterSigner({ args, ownerSigner, provider }) {
7851
9185
  if (args.txSubmitter === undefined) {
9186
+ expect(
9187
+ typeof ownerSigner.privateKey === "string",
9188
+ [
9189
+ `Missing local account secret for wallet owner ${ownerSigner.address}.`,
9190
+ "Pass --tx-submitter <ACCOUNT> or import the matching local account secret.",
9191
+ ].join(" "),
9192
+ );
7852
9193
  return {
7853
9194
  txSubmitter: ownerSigner,
7854
9195
  source: "wallet-owner",
@@ -7883,68 +9224,35 @@ function resolveStandalonePrivateKeySource(args) {
7883
9224
  ));
7884
9225
  }
7885
9226
 
7886
- function resolveWalletSecretForName({ networkName, walletName }) {
7887
- return resolveWalletDefaultSecret(networkName, walletName);
7888
- }
7889
-
7890
- function resolvedWalletSecretSource(args) {
7891
- if (args.walletSecretPath !== undefined) return "wallet-secret-path";
7892
- return "wallet-default";
7893
- }
7894
-
7895
- function resolvedWalletSecretFile(networkName, walletName) {
7896
- return walletSecretPath(networkName, walletName);
7897
- }
7898
-
7899
- function resolveWalletDefaultSecret(networkName, walletName) {
7900
- const secretPath = walletSecretPath(networkName, walletName);
7901
- if (!fs.existsSync(secretPath)) {
7902
- throw cliError(
7903
- CLI_ERROR_CODES.MISSING_WALLET_SECRET,
7904
- [
7905
- `Missing wallet default secret file: ${secretPath}.`,
7906
- "Run channel join with --wallet-secret-path before wallet commands.",
7907
- ].join(" "),
7908
- );
7909
- }
7910
- return readSecretFile(secretPath, "wallet default secret file");
7911
- }
7912
-
7913
9227
  function prepareJoinWalletSecretForName({
7914
9228
  args,
7915
9229
  networkName,
7916
9230
  walletName,
7917
9231
  }) {
7918
- const secretPath = walletSecretPath(networkName, walletName);
7919
9232
  const { channelName } = parseWalletName(walletName);
7920
- 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;
7921
9238
  expect(
7922
- !walletConfigExists(walletDir),
9239
+ !activeEpoch,
7923
9240
  [
7924
9241
  `Wallet ${walletName} already exists on ${networkName}.`,
7925
- "channel join always creates a new local wallet.",
7926
- "If this wallet was previously exited on-chain, run",
7927
- `wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account ${args.account ?? "<ACCOUNT>"}`,
7928
- "once to remove the stale local wallet, then retry channel join.",
9242
+ "channel join creates a new active wallet epoch.",
7929
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>"}.`,
7930
9245
  ].join(" "),
7931
9246
  );
7932
9247
  const sourcePath = path.resolve(String(requireArg(args.walletSecretPath, "--wallet-secret-path")));
7933
- const canonicalPath = path.resolve(secretPath);
7934
- const walletSecret = sourcePath === canonicalPath
7935
- ? readSecretFile(sourcePath, "--wallet-secret-path")
7936
- : readImportSecretSourceFile(sourcePath, "--wallet-secret-path");
7937
- if (sourcePath !== canonicalPath) {
7938
- writeSecretFile(canonicalPath, walletSecret);
7939
- }
7940
- return walletSecret;
9248
+ return readImportSecretSourceFile(sourcePath, "--wallet-secret-path");
7941
9249
  }
7942
9250
 
7943
9251
  function channelWorkspacePath(networkName, name) {
7944
9252
  return workspaceDirForName(workspaceRoot, networkName, name);
7945
9253
  }
7946
9254
 
7947
- function walletPath(name, networkName) {
9255
+ function walletRootPath(name, networkName) {
7948
9256
  const walletName = String(name);
7949
9257
  const { channelName } = parseWalletName(walletName);
7950
9258
  const normalizedNetworkName = requireNetworkName({ network: networkName });
@@ -7952,6 +9260,91 @@ function walletPath(name, networkName) {
7952
9260
  return walletDirForName(workspaceWalletsDir(workspaceDir), walletName);
7953
9261
  }
7954
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
+
7955
9348
  function accountPrivateKeyPath(networkName, accountName) {
7956
9349
  return path.join(
7957
9350
  secretRoot,
@@ -7986,6 +9379,24 @@ function walletSecretPath(networkName, walletName) {
7986
9379
  );
7987
9380
  }
7988
9381
 
9382
+ function walletViewingKeySecretPath(networkName, walletName) {
9383
+ return walletKeySecretPath(networkName, walletName, "viewing");
9384
+ }
9385
+
9386
+ function walletSpendingKeySecretPath(networkName, walletName) {
9387
+ return walletKeySecretPath(networkName, walletName, "spending");
9388
+ }
9389
+
9390
+ function walletKeySecretPath(networkName, walletName, keyKind) {
9391
+ return path.join(
9392
+ secretRoot,
9393
+ requireNetworkName({ network: networkName }),
9394
+ "wallets",
9395
+ slugifyPathComponent(walletName),
9396
+ `${keyKind}.key`,
9397
+ );
9398
+ }
9399
+
7989
9400
  function resolveWalletPathCandidates(walletName) {
7990
9401
  if (!fs.existsSync(workspaceRoot)) {
7991
9402
  return [];
@@ -8007,7 +9418,8 @@ function resolveWalletPathCandidates(walletName) {
8007
9418
  "wallets",
8008
9419
  walletSlug,
8009
9420
  );
8010
- if (walletConfigExists(candidate)) {
9421
+ if (fs.existsSync(candidate)) {
9422
+ requireWalletIndex({ walletRoot: candidate, walletName, networkName: entry.name });
8011
9423
  candidates.push(candidate);
8012
9424
  }
8013
9425
  }
@@ -8038,15 +9450,30 @@ function listLocalWallets({ networkFilter = null, channelFilter = null } = {}) {
8038
9450
  if (!walletEntry.isDirectory()) {
8039
9451
  continue;
8040
9452
  }
8041
- 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);
8042
9461
  wallets.push({
8043
9462
  wallet: walletEntry.name,
8044
9463
  network: networkEntry.name,
8045
9464
  channelName: channelEntry.name,
8046
9465
  walletDir,
8047
- metadataPath: walletMetadataPath(walletDir),
8048
- hasMetadata: fs.existsSync(walletMetadataPath(walletDir)),
8049
- hasEncryptedWallet: walletConfigExists(walletDir),
9466
+ walletRoot,
9467
+ activeEpochId: walletIndex?.activeEpochId ?? null,
9468
+ selectedEpochId: selectedEpoch?.epochId ?? null,
9469
+ lifecycleStatus: selectedEpoch.lifecycleStatus,
9470
+ epochs: walletIndex.epochs,
9471
+ metadataPath: walletNotesMetadataPath(walletDir),
9472
+ hasMetadata: fs.existsSync(walletNotesMetadataPath(walletDir)),
9473
+ hasEncryptedWallet: false,
9474
+ hasBackupMetadata: walletConfigExists(walletDir),
9475
+ hasViewingKey: fs.existsSync(walletViewingKeySecretPath(networkEntry.name, walletEntry.name)),
9476
+ hasSpendingKey: fs.existsSync(walletSpendingKeySecretPath(networkEntry.name, walletEntry.name)),
8050
9477
  });
8051
9478
  }
8052
9479
  }
@@ -8068,14 +9495,14 @@ function privateStateCliDataRoot() {
8068
9495
 
8069
9496
  function resolveExportWalletInfo({ networkName, walletName }) {
8070
9497
  resolveCliNetwork(networkName);
8071
- const walletDir = walletPath(walletName, networkName);
9498
+ const walletDir = selectedWalletEpochDir(walletName, networkName);
8072
9499
  return {
8073
9500
  wallet: walletName,
8074
9501
  network: networkName,
8075
9502
  channelName: parseWalletName(walletName).channelName,
8076
9503
  walletDir,
8077
- metadataPath: walletMetadataPath(walletDir),
8078
- hasMetadata: fs.existsSync(walletMetadataPath(walletDir)),
9504
+ metadataPath: walletNotesMetadataPath(walletDir),
9505
+ hasMetadata: fs.existsSync(walletNotesMetadataPath(walletDir)),
8079
9506
  hasEncryptedWallet: walletConfigExists(walletDir),
8080
9507
  };
8081
9508
  }
@@ -8083,16 +9510,12 @@ function resolveExportWalletInfo({ networkName, walletName }) {
8083
9510
  function normalizeExportWalletInfo(walletInfo) {
8084
9511
  const wallet = requireWalletName({ wallet: walletInfo.wallet });
8085
9512
  const network = requireNetworkName({ network: walletInfo.network });
8086
- const walletDir = walletInfo.walletDir ?? walletPath(wallet, network);
8087
- const metadataPath = walletMetadataPath(walletDir);
8088
- const encryptedWalletPath = walletConfigPath(walletDir);
9513
+ const walletDir = walletInfo.walletDir ?? selectedWalletEpochDir(wallet, network);
9514
+ const metadataPath = walletNotesMetadataPath(walletDir);
8089
9515
  const metadata = readJsonIfExists(metadataPath);
8090
9516
  const channelName = metadata?.channelName ?? walletInfo.channelName ?? parseWalletName(wallet).channelName;
8091
- const walletSecret = walletSecretPath(network, wallet);
8092
9517
 
8093
- expect(fs.existsSync(encryptedWalletPath), `Wallet export cannot find encrypted wallet file: ${encryptedWalletPath}.`);
8094
9518
  expect(fs.existsSync(metadataPath), `Wallet export cannot find wallet metadata file: ${metadataPath}.`);
8095
- expect(fs.existsSync(walletSecret), `Wallet export cannot find wallet-local secret file: ${walletSecret}.`);
8096
9519
  expect(
8097
9520
  metadata.network === network,
8098
9521
  `Wallet export metadata network ${metadata.network} does not match ${network}.`,
@@ -8107,18 +9530,27 @@ function normalizeExportWalletInfo(walletInfo) {
8107
9530
  channelName,
8108
9531
  wallet,
8109
9532
  walletDir,
8110
- walletSecretPath: walletSecret,
8111
9533
  };
8112
9534
  }
8113
9535
 
8114
- function walletExportFilePaths(walletInfo, { includeNotes }) {
9536
+ function walletBackupExportFilePaths(walletInfo) {
8115
9537
  const walletFiles = [
8116
- walletInfo.walletSecretPath,
8117
- walletConfigPath(walletInfo.walletDir),
8118
- walletMetadataPath(walletInfo.walletDir),
9538
+ walletNotesMetadataPath(walletInfo.walletDir),
8119
9539
  ];
8120
- if (!includeNotes) {
8121
- return walletFiles;
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));
9547
+ for (const metadataPath of [
9548
+ walletViewingKeyMetadataPath(walletInfo.walletDir),
9549
+ walletSpendingKeyMetadataPath(walletInfo.walletDir),
9550
+ ]) {
9551
+ if (fs.existsSync(metadataPath)) {
9552
+ walletFiles.push(metadataPath);
9553
+ }
8122
9554
  }
8123
9555
 
8124
9556
  const workspaceDir = channelWorkspacePath(walletInfo.network, walletInfo.channelName);
@@ -8134,8 +9566,8 @@ function walletExportFilePaths(walletInfo, { includeNotes }) {
8134
9566
  expect(
8135
9567
  fs.existsSync(filePath),
8136
9568
  [
8137
- `wallet export --include-notes requires channel workspace cache file: ${filePath}.`,
8138
- "Run channel recover-workspace first, or export without --include-notes.",
9569
+ `wallet export backup requires channel workspace cache file: ${filePath}.`,
9570
+ "Run channel recover-workspace first.",
8139
9571
  ].join(" "),
8140
9572
  );
8141
9573
  }
@@ -8150,14 +9582,13 @@ function archivePathForLocalCliFile(filePath) {
8150
9582
  }
8151
9583
 
8152
9584
  function validateWalletExportManifest(manifest) {
8153
- expect(manifest?.format === WALLET_EXPORT_FORMAT, "Wallet import ZIP has an unsupported format.");
9585
+ expect(manifest?.format === WALLET_BACKUP_EXPORT_FORMAT, "Wallet import ZIP has an unsupported format.");
8154
9586
  expect(
8155
9587
  Number(manifest.formatVersion) === WALLET_EXPORT_FORMAT_VERSION,
8156
9588
  `Wallet import ZIP format version ${manifest?.formatVersion} is not supported.`,
8157
9589
  );
8158
9590
  expect(Array.isArray(manifest.files), "Wallet import ZIP manifest is missing files[].");
8159
9591
  expect(Array.isArray(manifest.wallets), "Wallet import ZIP manifest is missing wallets[].");
8160
- expect(typeof manifest.includeNotes === "boolean", "Wallet import ZIP manifest is missing includeNotes.");
8161
9592
  expect(manifest.wallets.length > 0, "Wallet import ZIP manifest does not list any wallets.");
8162
9593
  const uniqueFiles = new Set(manifest.files);
8163
9594
  expect(uniqueFiles.size === manifest.files.length, "Wallet import ZIP manifest contains duplicate file paths.");
@@ -8166,9 +9597,18 @@ function validateWalletExportManifest(manifest) {
8166
9597
  validateWalletArchivePath(filePath);
8167
9598
  }
8168
9599
  for (const wallet of manifest.wallets) {
8169
- requireNetworkName({ network: wallet.network });
8170
- requireWalletName({ wallet: wallet.wallet });
9600
+ const networkName = requireNetworkName({ network: wallet.network });
9601
+ const walletName = requireWalletName({ wallet: wallet.wallet });
8171
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
+ );
8172
9612
  }
8173
9613
  }
8174
9614
 
@@ -8179,8 +9619,8 @@ function validateWalletArchivePath(archivePath) {
8179
9619
  expect(!path.posix.isAbsolute(archivePath), `Wallet import ZIP path must be relative: ${archivePath}.`);
8180
9620
  expect(path.posix.normalize(archivePath) === archivePath, `Wallet import ZIP path is not normalized: ${archivePath}.`);
8181
9621
  expect(
8182
- archivePath.startsWith("secrets/") || archivePath.startsWith("workspace/"),
8183
- `Wallet import ZIP path must start with secrets/ or workspace/: ${archivePath}.`,
9622
+ archivePath.startsWith("workspace/"),
9623
+ `Wallet backup import ZIP path must start with workspace/: ${archivePath}.`,
8184
9624
  );
8185
9625
  }
8186
9626
 
@@ -8191,9 +9631,9 @@ function expectPathWithinRoot(targetPath, rootPath, message) {
8191
9631
 
8192
9632
  function applyImportedWalletFileMode(archivePath, targetPath) {
8193
9633
  if (
8194
- archivePath.startsWith("secrets/")
8195
- || archivePath.endsWith("/wallet.json")
8196
- || archivePath.endsWith("/wallet.metadata.json")
9634
+ archivePath.endsWith("/wallet-notes.metadata.json")
9635
+ || archivePath.endsWith("/wallet-viewing-key.metadata.json")
9636
+ || archivePath.endsWith("/wallet-spending-key.metadata.json")
8197
9637
  ) {
8198
9638
  protectSecretFile(targetPath, `imported wallet file ${archivePath}`);
8199
9639
  }
@@ -8215,16 +9655,20 @@ function channelWorkspaceOperationsPath(workspaceDir) {
8215
9655
  return path.join(channelDataPath(workspaceDir), "operations");
8216
9656
  }
8217
9657
 
8218
- function walletConfigPath(walletDir) {
8219
- return path.join(walletDir, "wallet.json");
9658
+ function walletNotesMetadataPath(walletDir) {
9659
+ return path.join(walletDir, "wallet-notes.metadata.json");
9660
+ }
9661
+
9662
+ function walletViewingKeyMetadataPath(walletDir) {
9663
+ return path.join(walletDir, "wallet-viewing-key.metadata.json");
8220
9664
  }
8221
9665
 
8222
- function walletMetadataPath(walletDir) {
8223
- return walletMetadataPathForDir(walletDir);
9666
+ function walletSpendingKeyMetadataPath(walletDir) {
9667
+ return path.join(walletDir, "wallet-spending-key.metadata.json");
8224
9668
  }
8225
9669
 
8226
9670
  function walletConfigExists(walletDir) {
8227
- return fs.existsSync(walletConfigPath(walletDir));
9671
+ return fs.existsSync(walletNotesMetadataPath(walletDir));
8228
9672
  }
8229
9673
 
8230
9674
  const COMMAND_ARG_SCHEMAS = Object.freeze(
@@ -8266,21 +9710,9 @@ function assertAllowedCommandKeys(args, commandName, allowedKeys, acceptedUsage)
8266
9710
  );
8267
9711
  }
8268
9712
 
8269
- function assertWalletSecretArgs(args, commandName, extraOptionKeys = [], acceptedUsage = "--wallet and --network") {
8270
- if (COMMAND_ARG_SCHEMAS[commandName]) {
8271
- assertAllowedCommandSchema(args, commandName);
8272
- return;
8273
- }
8274
- assertAllowedCommandKeys(
8275
- args,
8276
- commandName,
8277
- new Set(["command", "positional", "wallet", "network", ...extraOptionKeys]),
8278
- acceptedUsage,
8279
- );
8280
- }
8281
-
8282
9713
  function assertWalletChannelMoveArgs(args, commandName) {
8283
- assertWalletSecretArgs(args, commandName, ["amount"], "--wallet, --network, and --amount");
9714
+ assertAllowedCommandSchema(args, commandName);
9715
+ assertActionImpactArg(args, COMMAND_ARG_SCHEMAS[commandName]?.label ?? commandName);
8284
9716
  }
8285
9717
 
8286
9718
  function assertInstallZkEvmArgs(args) {
@@ -8332,12 +9764,17 @@ function assertTransactionFeesArgs(args) {
8332
9764
  assertAllowedCommandSchema(args, "help-transaction-fees");
8333
9765
  }
8334
9766
 
9767
+ function assertInvestigatorArgs(args) {
9768
+ assertAllowedCommandSchema(args, "investigator");
9769
+ }
9770
+
8335
9771
  function assertAccountImportArgs(args) {
8336
9772
  assertAllowedCommandSchema(args, "account-import");
8337
9773
  }
8338
9774
 
8339
9775
  function assertMintNotesArgs(args) {
8340
9776
  assertAllowedCommandSchema(args, "wallet-mint-notes");
9777
+ assertActionImpactArg(args, "wallet mint-notes");
8341
9778
  assertTxSubmitterArg(args);
8342
9779
  parseAmountVector(args.amounts, {
8343
9780
  allowZeroEntries: true,
@@ -8347,12 +9784,14 @@ function assertMintNotesArgs(args) {
8347
9784
 
8348
9785
  function assertRedeemNotesArgs(args) {
8349
9786
  assertAllowedCommandSchema(args, "wallet-redeem-notes");
9787
+ assertActionImpactArg(args, "wallet redeem-notes");
8350
9788
  assertTxSubmitterArg(args);
8351
9789
  selectRedeemNotesMethod(parseNoteIdVector(args.noteIds).length);
8352
9790
  }
8353
9791
 
8354
9792
  function assertTransferNotesArgs(args) {
8355
9793
  assertAllowedCommandSchema(args, "wallet-transfer-notes");
9794
+ assertActionImpactArg(args, "wallet transfer-notes");
8356
9795
  assertTxSubmitterArg(args);
8357
9796
  const noteIds = parseNoteIdVector(args.noteIds);
8358
9797
  const recipients = parseRecipientVector(args.recipients);
@@ -8373,8 +9812,34 @@ function assertTxSubmitterArg(args) {
8373
9812
  }
8374
9813
  }
8375
9814
 
9815
+ function assertActionImpactArg(args, commandName) {
9816
+ if (
9817
+ args.acknowledgeActionImpact !== undefined
9818
+ && args.acknowledgeActionImpact !== true
9819
+ ) {
9820
+ throw new Error(`${commandName} option --acknowledge-action-impact does not accept a value.`);
9821
+ }
9822
+ if (args.acknowledgeActionImpact !== true && !process.stdin.isTTY) {
9823
+ throw new Error(`${commandName} requires --acknowledge-action-impact after reviewing the action-impact warning.`);
9824
+ }
9825
+ }
9826
+
8376
9827
  function assertWalletGetNotesArgs(args) {
8377
- assertWalletSecretArgs(args, "wallet-get-notes");
9828
+ assertAllowedCommandSchema(args, "wallet-get-notes");
9829
+ if (args.exportEvidence !== undefined) {
9830
+ requireArg(args.exportEvidence, "--export-evidence");
9831
+ if (args.acknowledgeFullNotePlaintextExport !== true) {
9832
+ throw new Error(
9833
+ "wallet get-notes --export-evidence requires --acknowledge-full-note-plaintext-export.",
9834
+ );
9835
+ }
9836
+ }
9837
+ if (
9838
+ args.acknowledgeFullNotePlaintextExport !== undefined
9839
+ && args.acknowledgeFullNotePlaintextExport !== true
9840
+ ) {
9841
+ throw new Error("wallet get-notes option --acknowledge-full-note-plaintext-export does not accept a value.");
9842
+ }
8378
9843
  }
8379
9844
 
8380
9845
  function assertCreateChannelArgs(args) {
@@ -8408,18 +9873,15 @@ function assertPublishWorkspaceMirrorArgs(args) {
8408
9873
 
8409
9874
  function assertDepositBridgeArgs(args) {
8410
9875
  assertAllowedCommandSchema(args, "account-deposit-bridge");
9876
+ assertActionImpactArg(args, "account deposit-bridge");
8411
9877
  }
8412
9878
 
8413
9879
  function assertAccountGetBridgeFundArgs(args) {
8414
9880
  assertAllowedCommandSchema(args, "account-get-bridge-fund");
8415
9881
  }
8416
9882
 
8417
- function assertExplicitSignerCommandArgs(args, commandName) {
8418
- assertAllowedCommandSchema(args, commandName);
8419
- }
8420
-
8421
9883
  function assertRecoverWalletArgs(args) {
8422
- assertExplicitSignerCommandArgs(args, "wallet-recover-workspace");
9884
+ assertAllowedCommandSchema(args, "wallet-recover-workspace");
8423
9885
  if (args.fromGenesis !== undefined && args.fromGenesis !== true) {
8424
9886
  throw new Error("wallet recover-workspace option --from-genesis does not accept a value.");
8425
9887
  }
@@ -8427,10 +9889,11 @@ function assertRecoverWalletArgs(args) {
8427
9889
 
8428
9890
  function assertJoinChannelArgs(args) {
8429
9891
  assertAllowedCommandSchema(args, "channel-join");
9892
+ assertActionImpactArg(args, "channel join");
8430
9893
  }
8431
9894
 
8432
9895
  function assertWalletGetMetaArgs(args) {
8433
- assertWalletSecretArgs(args, "wallet-get-meta");
9896
+ assertAllowedCommandSchema(args, "wallet-get-meta");
8434
9897
  }
8435
9898
 
8436
9899
  function assertAccountGetL1AddressArgs(args) {
@@ -8447,48 +9910,45 @@ function assertListLocalWalletsArgs(args) {
8447
9910
  assertAllowedCommandSchema(args, "wallet-list");
8448
9911
  }
8449
9912
 
8450
- function assertWalletExportArgs(args) {
8451
- assertAllowedCommandSchema(args, "wallet-export");
8452
- assertFlagOption(args, "all", "wallet export");
8453
- assertFlagOption(args, "includeNotes", "wallet export");
9913
+ function assertWalletExportBackupArgs(args) {
9914
+ assertAllowedCommandSchema(args, "wallet-export-backup");
8454
9915
  requireArg(args.output, "--output");
8455
- if (args.all === true) {
8456
- expect(
8457
- args.network === undefined && args.wallet === undefined,
8458
- "wallet export --all exports every local mainnet wallet and does not accept --network or --wallet.",
8459
- );
8460
- return;
8461
- }
8462
9916
  requireNetworkName(args);
8463
9917
  requireWalletName(args);
8464
9918
  }
8465
9919
 
8466
- function assertWalletImportArgs(args) {
8467
- assertAllowedCommandSchema(args, "wallet-import");
9920
+ function assertWalletExportKeyArgs(args, commandName) {
9921
+ assertAllowedCommandSchema(args, commandName);
9922
+ requireArg(args.output, "--output");
9923
+ requireNetworkName(args);
9924
+ requireWalletName(args);
8468
9925
  }
8469
9926
 
8470
- function assertFlagOption(args, key, commandName) {
8471
- if (args[key] !== undefined && args[key] !== true) {
8472
- throw new Error(`${commandName} option --${toKebabCase(key)} does not accept a value.`);
8473
- }
9927
+ function assertWalletImportBackupArgs(args) {
9928
+ assertAllowedCommandSchema(args, "wallet-import-backup");
9929
+ }
9930
+
9931
+ function assertWalletImportKeyArgs(args, commandName) {
9932
+ assertAllowedCommandSchema(args, commandName);
8474
9933
  }
8475
9934
 
8476
9935
  function assertWithdrawBridgeArgs(args) {
8477
9936
  assertAllowedCommandSchema(args, "account-withdraw-bridge");
9937
+ assertActionImpactArg(args, "account withdraw-bridge");
8478
9938
  }
8479
9939
 
8480
9940
  function assertWalletGetChannelFundArgs(args) {
8481
- assertWalletSecretArgs(args, "wallet-get-channel-fund");
9941
+ assertAllowedCommandSchema(args, "wallet-get-channel-fund");
8482
9942
  }
8483
9943
 
8484
9944
  function assertExitChannelArgs(args) {
8485
- assertWalletSecretArgs(args, "channel-exit");
9945
+ assertAllowedCommandSchema(args, "channel-exit");
8486
9946
  }
8487
9947
 
8488
9948
  function createWalletOperationDir(walletName, networkName, suffix) {
8489
9949
  const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
8490
9950
  const operationDir = path.join(
8491
- walletPath(walletName, networkName),
9951
+ selectedWalletEpochDir(walletName, networkName),
8492
9952
  "operations",
8493
9953
  `${timestamp}-${slugifyPathComponent(suffix)}`,
8494
9954
  );
@@ -8497,14 +9957,193 @@ function createWalletOperationDir(walletName, networkName, suffix) {
8497
9957
  }
8498
9958
 
8499
9959
  function persistWallet(context) {
8500
- writeEncryptedWalletJson(path.join(context.walletDir, "wallet.json"), context.wallet, context.walletSecret);
9960
+ writeJson(walletNotesMetadataPath(context.walletDir), sanitizeWalletForNotesMetadata(context.wallet));
9961
+ if (context.wallet?.l2PrivateKey || context.wallet?.l2PublicKey || context.wallet?.l2Address) {
9962
+ writeJson(walletSpendingKeyMetadataPath(context.walletDir), buildWalletSpendingKeyMetadata(context.wallet));
9963
+ }
9964
+ if (context.wallet?.noteReceivePubKeyX || context.wallet?.noteReceivePubKeyYParity !== undefined) {
9965
+ writeJson(walletViewingKeyMetadataPath(context.walletDir), buildWalletViewingKeyMetadata(context.wallet));
9966
+ }
9967
+ }
9968
+
9969
+ function persistWalletKeys(context) {
9970
+ if (context.wallet?.l2PrivateKey) {
9971
+ writeSecretFile(
9972
+ walletSpendingKeySecretPath(context.wallet.network, context.walletName),
9973
+ JSON.stringify({
9974
+ format: WALLET_KEY_EXPORT_FORMAT,
9975
+ formatVersion: WALLET_EXPORT_FORMAT_VERSION,
9976
+ keyKind: "spending",
9977
+ metadata: buildWalletSpendingKeyMetadata(context.wallet),
9978
+ privateKey: normalizePrivateKey(context.wallet.l2PrivateKey),
9979
+ }, null, 2),
9980
+ );
9981
+ }
9982
+ if (context.wallet?.noteReceivePrivateKey) {
9983
+ writeSecretFile(
9984
+ walletViewingKeySecretPath(context.wallet.network, context.walletName),
9985
+ JSON.stringify({
9986
+ format: WALLET_KEY_EXPORT_FORMAT,
9987
+ formatVersion: WALLET_EXPORT_FORMAT_VERSION,
9988
+ keyKind: "viewing",
9989
+ metadata: buildWalletViewingKeyMetadata(context.wallet),
9990
+ privateKey: normalizePrivateKey(context.wallet.noteReceivePrivateKey),
9991
+ }, null, 2),
9992
+ );
9993
+ }
8501
9994
  }
8502
9995
 
8503
- function persistWalletMetadata(context) {
8504
- writeJson(walletMetadataPath(context.walletDir), {
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,
8505
10021
  network: context.wallet.network,
8506
- rpcUrl: context.wallet.rpcUrl,
8507
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
+ };
10063
+ }
10064
+
10065
+ function sanitizeWalletForNotesMetadata(wallet) {
10066
+ const normalized = normalizeWallet({
10067
+ ...wallet,
10068
+ l2PrivateKey: null,
10069
+ noteReceivePrivateKey: null,
10070
+ });
10071
+ const { l2PrivateKey: _l2PrivateKey, noteReceivePrivateKey: _noteReceivePrivateKey, ...publicWallet } = normalized;
10072
+ return {
10073
+ ...publicWallet,
10074
+ notes: {
10075
+ unused: sanitizeTrackedNoteMap(normalized.notes.unused),
10076
+ spent: sanitizeTrackedNoteMap(normalized.notes.spent),
10077
+ unusedOrder: normalized.notes.unusedOrder,
10078
+ unusedBalance: null,
10079
+ },
10080
+ };
10081
+ }
10082
+
10083
+ function sanitizeTrackedNoteMap(notes) {
10084
+ return Object.fromEntries(Object.entries(notes ?? {}).map(([key, note]) => [key, sanitizeTrackedNoteForPersistence(note)]));
10085
+ }
10086
+
10087
+ function sanitizeTrackedNoteForPersistence(note) {
10088
+ const normalized = normalizeTrackedNote(note);
10089
+ return {
10090
+ commitment: normalized.commitment,
10091
+ nullifier: normalized.nullifier,
10092
+ encryptedNoteValue: normalized.encryptedNoteValue,
10093
+ status: normalized.status,
10094
+ sourceFunction: normalized.sourceFunction,
10095
+ sourceTxHash: normalized.sourceTxHash,
10096
+ createdAtTxHash: normalized.createdAtTxHash,
10097
+ createdAtBlockNumber: normalized.createdAtBlockNumber,
10098
+ createdAtLogIndex: normalized.createdAtLogIndex,
10099
+ createdByFunction: normalized.createdByFunction,
10100
+ createdOutputIndex: normalized.createdOutputIndex,
10101
+ spentAtTxHash: normalized.spentAtTxHash,
10102
+ spentAtBlockNumber: normalized.spentAtBlockNumber,
10103
+ spentByFunction: normalized.spentByFunction,
10104
+ spentInputIndex: normalized.spentInputIndex,
10105
+ counterpartyL2Address: normalized.counterpartyL2Address,
10106
+ counterpartyDirection: normalized.counterpartyDirection,
10107
+ counterpartyConfidence: normalized.counterpartyConfidence,
10108
+ bridgeCommitmentKey: normalized.bridgeCommitmentKey,
10109
+ bridgeNullifierKey: normalized.bridgeNullifierKey,
10110
+ };
10111
+ }
10112
+
10113
+ function buildWalletSpendingKeyMetadata(wallet) {
10114
+ return normalizeCliOutput({
10115
+ walletFormatVersion: WALLET_WORKSPACE_FORMAT_VERSION,
10116
+ network: wallet.network,
10117
+ wallet: wallet.name,
10118
+ ...walletLifecycleMetadata(wallet),
10119
+ channelName: wallet.channelName,
10120
+ channelId: wallet.channelId,
10121
+ l1Address: wallet.l1Address,
10122
+ l2Address: wallet.l2Address,
10123
+ l2PublicKey: wallet.l2PublicKey,
10124
+ l2DerivationMode: wallet.l2DerivationMode,
10125
+ l2DerivationChannelName: wallet.l2DerivationChannelName,
10126
+ l2StorageKey: wallet.l2StorageKey,
10127
+ leafIndex: wallet.leafIndex,
10128
+ });
10129
+ }
10130
+
10131
+ function buildWalletViewingKeyMetadata(wallet) {
10132
+ return normalizeCliOutput({
10133
+ walletFormatVersion: WALLET_WORKSPACE_FORMAT_VERSION,
10134
+ network: wallet.network,
10135
+ wallet: wallet.name,
10136
+ ...walletLifecycleMetadata(wallet),
10137
+ channelName: wallet.channelName,
10138
+ channelId: wallet.channelId,
10139
+ l1Address: wallet.l1Address,
10140
+ l2Address: wallet.l2Address,
10141
+ noteReceiveDerivationVersion: wallet.noteReceiveDerivationVersion,
10142
+ noteReceiveTypedDataMethod: wallet.noteReceiveTypedDataMethod,
10143
+ noteReceivePubKey: {
10144
+ x: wallet.noteReceivePubKeyX,
10145
+ yParity: wallet.noteReceivePubKeyYParity,
10146
+ },
8508
10147
  });
8509
10148
  }
8510
10149
 
@@ -8524,10 +10163,10 @@ Secret source options:
8524
10163
  A wallet secret source file is arbitrary high-entropy secret text read once by channel join.
8525
10164
  Create one before joining a channel, for example:
8526
10165
  openssl rand -hex 32 > ./wallet-secret.txt
8527
- private-state-cli channel join --channel-name <NAME> --network <NAME> --account <NAME> --wallet-secret-path ./wallet-secret.txt
10166
+ private-state-cli channel join --channel-name <NAME> --network <NAME> --account <NAME> --wallet-secret-path ./wallet-secret.txt --acknowledge-action-impact
8528
10167
  Bridge-facing commands accept optional --rpc-url. When provided, it is saved to
8529
10168
  ~/tokamak-private-channels/secrets/<network>/.env as RPC_URL. When omitted, the CLI reads RPC_URL from that file.
8530
- Wallet commands use wallet-local default secret files only.
10169
+ Wallet commands use separate protected viewing-key and spending-key files when those capabilities are needed.
8531
10170
  Source files passed to --private-key-file and --wallet-secret-path are not required to use 0600 permissions, but
8532
10171
  canonical CLI secret files remain protected. On macOS/Linux this means 0600; on Windows the CLI repairs ACLs when possible.
8533
10172
 
@@ -8861,23 +10500,6 @@ function getUsableWorkspaceRecoveryIndex({
8861
10500
  };
8862
10501
  }
8863
10502
 
8864
- function writeEncryptedWalletJson(filePath, value, walletSecret) {
8865
- const normalizedValue = normalizeCliOutput(value);
8866
- writeEncryptedWalletFile(filePath, Buffer.from(`${JSON.stringify(normalizedValue, null, 2)}\n`, "utf8"), walletSecret);
8867
- }
8868
-
8869
- function readEncryptedWalletJson(filePath, walletSecret) {
8870
- try {
8871
- return JSON.parse(readEncryptedWalletFile(filePath, walletSecret).toString("utf8"));
8872
- } catch (error) {
8873
- throw cliError(
8874
- CLI_ERROR_CODES.WALLET_DECRYPT_FAILED,
8875
- `Unable to decrypt wallet data at ${filePath}. Check the wallet-local default secret file.`,
8876
- { cause: error },
8877
- );
8878
- }
8879
- }
8880
-
8881
10503
  function writeEncryptedWalletFile(filePath, plaintextBytes, walletSecret) {
8882
10504
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
8883
10505
  const salt = randomBytes(16);
@@ -8898,23 +10520,6 @@ function writeEncryptedWalletFile(filePath, plaintextBytes, walletSecret) {
8898
10520
  fs.writeFileSync(filePath, `${JSON.stringify(envelope, null, 2)}\n`);
8899
10521
  }
8900
10522
 
8901
- function readEncryptedWalletFile(filePath, walletSecret) {
8902
- const envelope = readJson(filePath);
8903
- expect(
8904
- envelope.version === WALLET_ENCRYPTION_VERSION
8905
- && envelope.algorithm === WALLET_ENCRYPTION_ALGORITHM
8906
- && envelope.kdf === "scrypt",
8907
- `Unsupported wallet encryption envelope at ${filePath}.`,
8908
- );
8909
- const encryptionKey = deriveWalletEncryptionKey(walletSecret, Buffer.from(ethers.getBytes(envelope.salt)));
8910
- const decipher = createDecipheriv("aes-256-gcm", encryptionKey, Buffer.from(ethers.getBytes(envelope.iv)));
8911
- decipher.setAuthTag(Buffer.from(ethers.getBytes(envelope.tag)));
8912
- return Buffer.concat([
8913
- decipher.update(Buffer.from(ethers.getBytes(envelope.ciphertext))),
8914
- decipher.final(),
8915
- ]);
8916
- }
8917
-
8918
10523
  function deriveWalletEncryptionKey(walletSecret, salt) {
8919
10524
  return scryptSync(String(walletSecret), salt, 32);
8920
10525
  }
@@ -8963,6 +10568,7 @@ function loadWalletCommandRuntime(args) {
8963
10568
 
8964
10569
  const HUMAN_RESULT_RENDERERS = Object.freeze({
8965
10570
  guide: printGuideHumanResult,
10571
+ investigator: printInvestigatorHumanResult,
8966
10572
  "transaction-fees": printTransactionFeesHumanResult,
8967
10573
  update: printUpdateHumanResult,
8968
10574
  });
@@ -9022,6 +10628,29 @@ function printGuideHumanResult(guide) {
9022
10628
  console.log(lines.join("\n"));
9023
10629
  }
9024
10630
 
10631
+ function printInvestigatorHumanResult(result) {
10632
+ const lines = [
10633
+ "Private-State Evidence Investigator",
10634
+ `HTML path: ${formatHumanValue(result.htmlPath)}`,
10635
+ `File URL: ${formatHumanValue(result.fileUrl)}`,
10636
+ `Browser opened: ${result.browserOpened ? "yes" : "no"}`,
10637
+ ];
10638
+ if (!result.browserOpened) {
10639
+ lines.push(
10640
+ `Open command: ${formatHumanValue(result.browserOpenCommand)}`,
10641
+ `Open error: ${formatHumanValue(result.browserOpenError ?? "none")}`,
10642
+ );
10643
+ }
10644
+ if (Array.isArray(result.nextSteps) && result.nextSteps.length > 0) {
10645
+ lines.push(
10646
+ "",
10647
+ "Next Steps",
10648
+ ...result.nextSteps.map((step) => `- ${step}`),
10649
+ );
10650
+ }
10651
+ console.log(lines.join("\n"));
10652
+ }
10653
+
9025
10654
  function printTransactionFeesHumanResult(report) {
9026
10655
  const lines = [
9027
10656
  "Transaction Fees",
@@ -9356,17 +10985,6 @@ function buildRecoveryHints(error, args = {}) {
9356
10985
  hints.push(`private-state-cli help guide --network ${networkName} --wallet ${walletName}`);
9357
10986
  }
9358
10987
 
9359
- if (error?.code === CLI_ERROR_CODES.MISSING_WALLET_SECRET) {
9360
- hints.push("restore the wallet-local default secret file from backup before running wallet commands.");
9361
- hints.push(`private-state-cli help guide --network ${networkName} --wallet ${walletName}`);
9362
- }
9363
-
9364
- if (error?.code === CLI_ERROR_CODES.WALLET_DECRYPT_FAILED) {
9365
- hints.push("verify that the wallet-local default secret file is the same secret used when the wallet was created.");
9366
- hints.push("if the encrypted wallet file is corrupted but the wallet secret and L1 account secret still exist, rerun wallet recover-workspace.");
9367
- hints.push("if the wallet secret was lost, the local L2 key cannot be recovered from the encrypted wallet file.");
9368
- }
9369
-
9370
10988
  if (
9371
10989
  message.startsWith("Missing --account:")
9372
10990
  || message.includes("Missing --account.")
@@ -9384,7 +11002,7 @@ function buildRecoveryHints(error, args = {}) {
9384
11002
  }
9385
11003
 
9386
11004
  if (error?.code === CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION) {
9387
- hints.push(`private-state-cli channel join --channel-name ${channelName} --network ${networkName} --account ${accountName} --wallet-secret-path <PATH>`);
11005
+ hints.push(`private-state-cli channel join --channel-name ${channelName} --network ${networkName} --account ${accountName} --wallet-secret-path <PATH> --acknowledge-action-impact`);
9388
11006
  hints.push(`private-state-cli help guide --network ${networkName} --channel-name ${channelName} --account ${accountName}`);
9389
11007
  }
9390
11008