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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,17 @@ 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_EVIDENCE_BUNDLE_FORMAT = "tokamak-private-state-raw-evidence-bundle";
141
+ const WALLET_EXPORT_FORMAT_VERSION = 2;
142
+ const WALLET_EVIDENCE_BUNDLE_FORMAT_VERSION = 1;
143
+ const WALLET_WORKSPACE_FORMAT_VERSION = 2;
139
144
  const CHANNEL_WORKSPACE_MIRROR_PROTOCOL_VERSION = 2;
140
145
  const CHANNEL_WORKSPACE_MIRROR_MANIFEST_PATH_PREFIX =
141
146
  ".well-known/tokamak-private-state/channel-workspace";
@@ -151,8 +156,6 @@ let activeCliArgs = {};
151
156
  const CLI_ERROR_CODES = Object.freeze({
152
157
  MISSING_RPC_URL: "MISSING_RPC_URL",
153
158
  UNKNOWN_WALLET: "UNKNOWN_WALLET",
154
- MISSING_WALLET_SECRET: "MISSING_WALLET_SECRET",
155
- WALLET_DECRYPT_FAILED: "WALLET_DECRYPT_FAILED",
156
159
  MISSING_DEPLOYMENT_ARTIFACTS: "MISSING_DEPLOYMENT_ARTIFACTS",
157
160
  MISSING_CHANNEL_REGISTRATION: "MISSING_CHANNEL_REGISTRATION",
158
161
  STALE_WORKSPACE: "STALE_WORKSPACE",
@@ -207,8 +210,7 @@ const ZERO_TOPIC = normalizeBytes32Hex(ethers.ZeroHash);
207
210
  const DEFAULT_LOG_CHUNK_SIZE = 2000;
208
211
  const DEFAULT_LOG_REQUESTS_PER_SECOND = 5;
209
212
  const LOG_REQUEST_INTERVAL_MS = Math.ceil(1000 / DEFAULT_LOG_REQUESTS_PER_SECOND);
210
- const AUTO_RECOVERY_TIME_BUDGET_SECONDS = 10;
211
- const AUTO_RECOVERY_LOG_REQUEST_BUDGET = DEFAULT_LOG_REQUESTS_PER_SECOND * AUTO_RECOVERY_TIME_BUDGET_SECONDS;
213
+ const AUTO_RECOVERY_BLOCK_BUDGET = 7200;
212
214
  let lastLogRequestStartedAtMs = 0;
213
215
 
214
216
  function printImmutableChannelPolicyWarning({
@@ -248,6 +250,206 @@ function printImmutableChannelPolicyWarning({
248
250
  console.error(details.join("\n"));
249
251
  }
250
252
 
253
+ const ACTION_IMPACT_SUMMARIES = Object.freeze({
254
+ "account-deposit-bridge": {
255
+ display: "account deposit-bridge",
256
+ l1PublicEvent: "Yes. ERC-20 approval and bridge vault funding transactions are public L1 events.",
257
+ privateNoteState: "No. This action only moves canonical tokens into the shared bridge vault.",
258
+ publicFields: ({ l1Address, amountInput, bridgeTokenVault }) => [
259
+ `L1 account: ${l1Address}`,
260
+ `Bridge token vault: ${bridgeTokenVault}`,
261
+ `Amount: ${amountInput}`,
262
+ "Approval and funding transaction hashes, block numbers, and event logs.",
263
+ ],
264
+ notPublic: [
265
+ "No private note owner, value, salt, counterparty, or note provenance is created by this action.",
266
+ ],
267
+ noteProvenance: "Not applicable for this bridge-edge action.",
268
+ cexWarning: "Do not use a centralized-exchange controlled address as a self-custody bridge source.",
269
+ policy: "No channel policy is accepted by this action.",
270
+ },
271
+ "account-withdraw-bridge": {
272
+ display: "account withdraw-bridge",
273
+ l1PublicEvent: "Yes. The bridge withdrawal transaction and claim event are public L1 data.",
274
+ privateNoteState: "No. This action claims shared bridge-vault balance to the local L1 account.",
275
+ publicFields: ({ l1Address, amountInput, bridgeTokenVault }) => [
276
+ `L1 recipient/account: ${l1Address}`,
277
+ `Bridge token vault: ${bridgeTokenVault}`,
278
+ `Amount: ${amountInput}`,
279
+ "Withdrawal transaction hash, block number, and event log.",
280
+ ],
281
+ notPublic: [
282
+ "The private note path that produced any prior channel balance is not reconstructed from this event alone.",
283
+ ],
284
+ noteProvenance: "Public observers cannot reconstruct prior internal note provenance from this withdrawal alone.",
285
+ 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.",
286
+ policy: "No channel policy is accepted by this action.",
287
+ },
288
+ "channel-join": {
289
+ display: "channel join",
290
+ l1PublicEvent: "Yes. Channel join and token-vault registration transactions are public L1 data.",
291
+ privateNoteState: "No. This action registers identity and note-receive metadata; it does not create or spend notes.",
292
+ publicFields: ({ l1Address, l2Address, noteReceivePubKey, joinToll, channelName, channelId }) => [
293
+ `Channel: ${channelName} (${channelId})`,
294
+ `L1 account: ${l1Address}`,
295
+ `L2 address: ${l2Address}`,
296
+ `Note-receive public key: ${noteReceivePubKey}`,
297
+ `Join toll: ${joinToll}`,
298
+ ],
299
+ notPublic: [
300
+ "Wallet secret, L2 spending private key, note-receive private key, and future note plaintext.",
301
+ ],
302
+ noteProvenance: "Future note provenance is not made public by joining.",
303
+ policy: "Joining accepts the displayed immutable channel policy snapshot.",
304
+ },
305
+ "wallet-deposit-channel": {
306
+ display: "wallet deposit-channel",
307
+ l1PublicEvent: "Yes. The proof-backed channel accounting transaction is public L1 data.",
308
+ privateNoteState: "No. This action increases liquid channel accounting balance; it does not create notes.",
309
+ publicFields: ({ l1Address, l2Address, amountInput, channelName, channelId }) => [
310
+ `Channel: ${channelName} (${channelId})`,
311
+ `L1 submitter/account: ${l1Address}`,
312
+ `Registered L2 address: ${l2Address}`,
313
+ `Amount: ${amountInput}`,
314
+ "Transaction hash, accepted proof surface, and accounting root update.",
315
+ ],
316
+ notPublic: [
317
+ "No note owner, value, salt, counterparty, or note provenance is created by this action.",
318
+ ],
319
+ noteProvenance: "Not applicable; this action does not transfer note ownership.",
320
+ policy: "This action uses the channel policy snapshot accepted by the registered wallet.",
321
+ },
322
+ "wallet-withdraw-channel": {
323
+ display: "wallet withdraw-channel",
324
+ l1PublicEvent: "Yes. The proof-backed channel accounting transaction is public L1 data.",
325
+ privateNoteState: "No. This action decreases liquid channel accounting balance; it does not spend notes directly.",
326
+ publicFields: ({ l1Address, l2Address, amountInput, channelName, channelId }) => [
327
+ `Channel: ${channelName} (${channelId})`,
328
+ `L1 submitter/account: ${l1Address}`,
329
+ `Registered L2 address: ${l2Address}`,
330
+ `Amount: ${amountInput}`,
331
+ "Transaction hash, accepted proof surface, and accounting root update.",
332
+ ],
333
+ notPublic: [
334
+ "Any prior private note path that produced the liquid balance is not reconstructed from this action alone.",
335
+ ],
336
+ noteProvenance: "Public observers cannot reconstruct prior internal note provenance from this withdrawal-channel action alone.",
337
+ policy: "This action uses the channel policy snapshot accepted by the registered wallet.",
338
+ },
339
+ "wallet-mint-notes": {
340
+ display: "wallet mint-notes",
341
+ l1PublicEvent: "Yes. executeChannelTransaction, accepted transition, commitments, encrypted note events, and root updates are public L1 data.",
342
+ privateNoteState: "Yes. This action creates private-state notes tracked by the local wallet.",
343
+ publicFields: ({ l1Address, l2Address, amounts, channelName, channelId }) => [
344
+ `Channel: ${channelName} (${channelId})`,
345
+ `L1 submitter/account: ${l1Address}`,
346
+ `Registered L2 address: ${l2Address}`,
347
+ `Requested note amounts: ${amounts}`,
348
+ "New commitments, encrypted note-delivery events, transaction hash, and root updates.",
349
+ ],
350
+ notPublic: [
351
+ "Note owner, value, salt, plaintext note contents, and later note provenance are not public by default.",
352
+ ],
353
+ noteProvenance: "Public observers cannot reconstruct later note provenance without user-controlled disclosure.",
354
+ policy: "This action uses the channel policy snapshot accepted by the registered wallet.",
355
+ },
356
+ "wallet-transfer-notes": {
357
+ display: "wallet transfer-notes",
358
+ l1PublicEvent: "Yes. executeChannelTransaction, nullifiers, output commitments, encrypted note events, and root updates are public L1 data.",
359
+ privateNoteState: "Yes. This action spends selected input notes and creates output notes.",
360
+ publicFields: ({ l1Address, l2Address, noteIds, amounts, channelName, channelId }) => [
361
+ `Channel: ${channelName} (${channelId})`,
362
+ `L1 submitter/account: ${l1Address}`,
363
+ `Registered L2 address: ${l2Address}`,
364
+ `Input note commitments: ${noteIds}`,
365
+ `Output amounts supplied to the CLI: ${amounts}`,
366
+ "Input nullifiers, output commitments, encrypted note-delivery events, transaction hash, and root updates.",
367
+ ],
368
+ notPublic: [
369
+ "Sender-recipient relationship, recipient note plaintext, and note provenance are not public by default.",
370
+ ],
371
+ noteProvenance: "Public observers cannot reconstruct private note counterparty relationships or provenance from public contract state alone.",
372
+ policy: "This action uses the channel policy snapshot accepted by the registered wallet.",
373
+ },
374
+ "wallet-redeem-notes": {
375
+ display: "wallet redeem-notes",
376
+ l1PublicEvent: "Yes. executeChannelTransaction, nullifier usage, accounting update, and root updates are public L1 data.",
377
+ privateNoteState: "Yes. This action consumes selected notes and credits liquid channel accounting balance.",
378
+ publicFields: ({ l1Address, l2Address, noteIds, channelName, channelId }) => [
379
+ `Channel: ${channelName} (${channelId})`,
380
+ `L1 submitter/account: ${l1Address}`,
381
+ `Registered L2 address: ${l2Address}`,
382
+ `Input note commitments: ${noteIds}`,
383
+ "Input nullifiers, accounting update, transaction hash, and root updates.",
384
+ ],
385
+ notPublic: [
386
+ "The prior path by which the redeemed note was received is not public by default.",
387
+ ],
388
+ noteProvenance: "Public observers cannot reconstruct prior internal note provenance from this redeem action alone.",
389
+ policy: "This action uses the channel policy snapshot accepted by the registered wallet.",
390
+ },
391
+ });
392
+
393
+ async function requireActionImpactAcknowledgement(commandId, args, details = {}) {
394
+ const summary = ACTION_IMPACT_SUMMARIES[commandId];
395
+ if (!summary) {
396
+ throw new Error(`Missing action-impact summary for ${commandId}.`);
397
+ }
398
+ printActionImpactSummary(summary, details);
399
+ if (args.acknowledgeActionImpact === true) {
400
+ return;
401
+ }
402
+ if (args.acknowledgeActionImpact !== undefined) {
403
+ throw new Error(`${summary.display} option --acknowledge-action-impact does not accept a value.`);
404
+ }
405
+ if (!process.stdin.isTTY) {
406
+ throw new Error(`${summary.display} requires --acknowledge-action-impact after reviewing the action-impact warning.`);
407
+ }
408
+ const prompt = [
409
+ `Type exactly: ${ACTION_IMPACT_CONFIRMATION}`,
410
+ "> ",
411
+ ].join("\n");
412
+ const rl = readline.createInterface({
413
+ input: process.stdin,
414
+ output: process.stderr,
415
+ terminal: process.stdin.isTTY && process.stderr.isTTY,
416
+ });
417
+ try {
418
+ const answer = await rl.question(prompt);
419
+ if (answer !== ACTION_IMPACT_CONFIRMATION) {
420
+ throw new Error(`${summary.display} action-impact confirmation did not match. No transaction was submitted.`);
421
+ }
422
+ } finally {
423
+ rl.close();
424
+ }
425
+ }
426
+
427
+ function printActionImpactSummary(summary, details) {
428
+ const lines = [
429
+ `ACTION IMPACT SUMMARY: ${summary.display}`,
430
+ `- L1 public event: ${summary.l1PublicEvent}`,
431
+ `- Private note state change: ${summary.privateNoteState}`,
432
+ "- Public addresses and amounts:",
433
+ ...normalizeImpactLines(summary.publicFields, details).map((line) => ` - ${line}`),
434
+ "- Not public by default:",
435
+ ...normalizeImpactLines(summary.notPublic, details).map((line) => ` - ${line}`),
436
+ `- Note provenance: ${summary.noteProvenance}`,
437
+ `- Illegal-use prohibition: Do not use this command for money laundering, sanctions evasion, terrorist financing, illegal gambling, criminal-proceeds concealment, or regulatory evasion.`,
438
+ `- Secret recovery: Losing wallet secrets, viewing keys, or spending keys can prevent note discovery or note use. The CLI cannot recover lost secrets.`,
439
+ `- Channel policy: ${summary.policy}`,
440
+ ];
441
+ if (summary.cexWarning) {
442
+ lines.push(`- CEX address warning: ${summary.cexWarning}`);
443
+ }
444
+ lines.push(`- Confirmation: pass --acknowledge-action-impact or type the exact confirmation phrase when prompted.`);
445
+ console.error(lines.join("\n"));
446
+ }
447
+
448
+ function normalizeImpactLines(value, details) {
449
+ const resolved = typeof value === "function" ? value(details) : value;
450
+ return Array.isArray(resolved) ? resolved : [resolved];
451
+ }
452
+
251
453
  function normalizeDAppPolicySnapshot({
252
454
  dappId,
253
455
  metadataDigest,
@@ -387,6 +589,12 @@ async function main() {
387
589
  return;
388
590
  }
389
591
 
592
+ if (args.command === "investigator") {
593
+ assertInvestigatorArgs(args);
594
+ handleInvestigator();
595
+ return;
596
+ }
597
+
390
598
  if (args.command === "account-get-l1-address") {
391
599
  assertAccountGetL1AddressArgs(args);
392
600
  handleAccountGetL1Address({ args });
@@ -405,15 +613,39 @@ async function main() {
405
613
  return;
406
614
  }
407
615
 
408
- if (args.command === "wallet-export") {
409
- assertWalletExportArgs(args);
410
- handleWalletExport({ args });
616
+ if (args.command === "wallet-export-backup") {
617
+ assertWalletExportBackupArgs(args);
618
+ handleWalletExportBackup({ args });
619
+ return;
620
+ }
621
+
622
+ if (args.command === "wallet-export-viewing-key") {
623
+ assertWalletExportKeyArgs(args, "wallet-export-viewing-key");
624
+ handleWalletExportKey({ args, keyKind: "viewing" });
625
+ return;
626
+ }
627
+
628
+ if (args.command === "wallet-export-spending-key") {
629
+ assertWalletExportKeyArgs(args, "wallet-export-spending-key");
630
+ handleWalletExportKey({ args, keyKind: "spending" });
411
631
  return;
412
632
  }
413
633
 
414
- if (args.command === "wallet-import") {
415
- assertWalletImportArgs(args);
416
- handleWalletImport({ args });
634
+ if (args.command === "wallet-import-backup") {
635
+ assertWalletImportBackupArgs(args);
636
+ handleWalletImportBackup({ args });
637
+ return;
638
+ }
639
+
640
+ if (args.command === "wallet-import-viewing-key") {
641
+ assertWalletImportKeyArgs(args, "wallet-import-viewing-key");
642
+ handleWalletImportKey({ args, keyKind: "viewing" });
643
+ return;
644
+ }
645
+
646
+ if (args.command === "wallet-import-spending-key") {
647
+ assertWalletImportKeyArgs(args, "wallet-import-spending-key");
648
+ handleWalletImportKey({ args, keyKind: "spending" });
417
649
  return;
418
650
  }
419
651
 
@@ -2162,6 +2394,11 @@ async function handleDepositBridge({ args, network, provider }) {
2162
2394
  const bridgeVaultContext = await loadBridgeVaultContext({ provider, chainId: network.chainId });
2163
2395
  const amountInput = requireArg(args.amount, "--amount");
2164
2396
  const amount = parseTokenAmount(amountInput, Number(bridgeVaultContext.canonicalAssetDecimals));
2397
+ await requireActionImpactAcknowledgement("account-deposit-bridge", args, {
2398
+ l1Address: signer.address,
2399
+ amountInput,
2400
+ bridgeTokenVault: bridgeVaultContext.bridgeTokenVaultAddress,
2401
+ });
2165
2402
  const bridgeTokenVault = new Contract(
2166
2403
  bridgeVaultContext.bridgeTokenVaultAddress,
2167
2404
  bridgeVaultContext.bridgeAbiManifest.contracts.bridgeTokenVault.abi,
@@ -2224,10 +2461,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2224
2461
  const channelName = requireArg(args.channelName, "--channel-name");
2225
2462
  const signer = requireL1Signer(args, provider);
2226
2463
  const walletName = walletNameForChannelAndAddress(channelName, signer.address);
2227
- const walletSecret = resolveWalletSecretForName({
2228
- networkName: network.name,
2229
- walletName,
2230
- });
2231
2464
  const bridgeResources = loadBridgeResources({ chainId: network.chainId });
2232
2465
  const initialized = await syncChannelWorkspace({
2233
2466
  workspaceName: channelName,
@@ -2261,11 +2494,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2261
2494
  provider,
2262
2495
  ),
2263
2496
  };
2264
- const l2Identity = await deriveParticipantIdentityFromSigner({
2265
- channelName,
2266
- walletSecret,
2267
- signer,
2268
- });
2269
2497
  const noteReceiveKeyMaterial = await deriveNoteReceiveKeyMaterial({
2270
2498
  signer,
2271
2499
  chainId: network.chainId,
@@ -2273,8 +2501,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2273
2501
  channelName,
2274
2502
  account: signer.address,
2275
2503
  });
2276
- const storageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
2277
- const leafIndex = deriveChannelTokenVaultLeafIndex(storageKey);
2278
2504
  const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
2279
2505
 
2280
2506
  if (!registration.exists) {
@@ -2286,15 +2512,13 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2286
2512
  wallet: walletName,
2287
2513
  removedWalletDir: cleanup.removedWalletDir ? cleanup.walletDir : null,
2288
2514
  removedWalletSecretFile: cleanup.removedWalletSecret ? cleanup.walletSecretFile : null,
2289
- walletSecretSource: resolvedWalletSecretSource(args),
2290
- walletSecretFile: resolvedWalletSecretFile(network.name, walletName),
2291
2515
  workspace: context.workspaceName,
2292
2516
  channelName: context.workspace.channelName,
2293
2517
  channelId: context.workspace.channelId,
2294
2518
  l1Address: signer.address,
2295
- l2Address: l2Identity.l2Address,
2296
- l2StorageKey: storageKey,
2297
- leafIndex: leafIndex.toString(),
2519
+ l2Address: null,
2520
+ l2StorageKey: null,
2521
+ leafIndex: null,
2298
2522
  reason: "The local wallet existed, but the L1 address is no longer registered in the channel.",
2299
2523
  nextAction: buildRecoverWalletRemovedNextAction({
2300
2524
  channelName,
@@ -2312,19 +2536,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2312
2536
  ),
2313
2537
  );
2314
2538
  }
2315
- expect(
2316
- ethers.toBigInt(getAddress(registration.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address)),
2317
- "The existing channel registration L2 address does not match the derived L2 address.",
2318
- );
2319
- expect(
2320
- ethers.toBigInt(normalizeBytes32Hex(registration.channelTokenVaultKey))
2321
- === ethers.toBigInt(normalizeBytes32Hex(storageKey)),
2322
- "The existing channel registration key does not match the derived channelTokenVault key.",
2323
- );
2324
- expect(
2325
- ethers.toBigInt(registration.leafIndex) === ethers.toBigInt(leafIndex),
2326
- "The existing channel registration leaf index does not match the derived leaf index.",
2327
- );
2328
2539
  expect(
2329
2540
  ethers.toBigInt(normalizeBytes32Hex(registration.noteReceivePubKey.x))
2330
2541
  === ethers.toBigInt(normalizeBytes32Hex(noteReceiveKeyMaterial.noteReceivePubKey.x)),
@@ -2334,21 +2545,21 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2334
2545
  Number(registration.noteReceivePubKey.yParity) === Number(noteReceiveKeyMaterial.noteReceivePubKey.yParity),
2335
2546
  "The existing note-receive public key parity does not match the derived note-receive public key.",
2336
2547
  );
2548
+ const l2Identity = {
2549
+ l2PrivateKey: null,
2550
+ l2PublicKey: null,
2551
+ l2Address: getAddress(registration.l2Address),
2552
+ };
2553
+ const storageKey = normalizeBytes32Hex(registration.channelTokenVaultKey);
2337
2554
 
2338
- const existingWallet = tryLoadRecoverableWallet({
2339
- walletName,
2340
- walletSecret,
2341
- signerAddress: signer.address,
2342
- signerPrivateKey: signer.privateKey,
2343
- l2Identity,
2344
- storageKey,
2345
- leafIndex,
2346
- rpcUrl,
2347
- channelContext: context,
2348
- noteReceiveKeyMaterial,
2349
- });
2555
+ const walletDir = walletPath(walletName, context.workspace.network);
2556
+ const existingWallet = walletConfigExists(walletDir)
2557
+ ? loadWallet(walletName, context.workspace.network)
2558
+ : null;
2350
2559
 
2351
2560
  if (existingWallet) {
2561
+ existingWallet.wallet.noteReceivePrivateKey = noteReceiveKeyMaterial.privateKey;
2562
+ persistWalletKeys(existingWallet);
2352
2563
  const { recoveredDeliveryState } = await recoverWalletReceivedNotes({
2353
2564
  walletContext: existingWallet,
2354
2565
  context,
@@ -2363,8 +2574,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2363
2574
  status: "already-recovered",
2364
2575
  wallet: walletName,
2365
2576
  walletDir: existingWallet.walletDir,
2366
- walletSecretSource: resolvedWalletSecretSource(args),
2367
- walletSecretFile: resolvedWalletSecretFile(network.name, walletName),
2368
2577
  workspace: context.workspaceName,
2369
2578
  channelName: context.workspace.channelName,
2370
2579
  channelId: context.workspace.channelId,
@@ -2388,7 +2597,7 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2388
2597
  signerAddress: signer.address,
2389
2598
  signerPrivateKey: signer.privateKey,
2390
2599
  l2Identity,
2391
- walletSecret,
2600
+ walletSecret: noteReceiveKeyMaterial.privateKey,
2392
2601
  storageKey,
2393
2602
  leafIndex: registration.leafIndex,
2394
2603
  noteReceiveKeyMaterial,
@@ -2412,8 +2621,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2412
2621
  status: "recovered",
2413
2622
  wallet: walletName,
2414
2623
  walletDir: walletContext.walletDir,
2415
- walletSecretSource: resolvedWalletSecretSource(args),
2416
- walletSecretFile: resolvedWalletSecretFile(network.name, walletName),
2417
2624
  workspace: context.workspaceName,
2418
2625
  channelName: context.workspace.channelName,
2419
2626
  channelId: context.workspace.channelId,
@@ -2429,135 +2636,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2429
2636
  });
2430
2637
  }
2431
2638
 
2432
- function tryLoadRecoverableWallet({
2433
- walletName,
2434
- walletSecret,
2435
- signerAddress,
2436
- signerPrivateKey,
2437
- l2Identity,
2438
- storageKey,
2439
- leafIndex,
2440
- rpcUrl,
2441
- channelContext,
2442
- noteReceiveKeyMaterial,
2443
- }) {
2444
- const walletDir = walletPath(walletName, channelContext.workspace.network);
2445
- if (!walletConfigExists(walletDir)) {
2446
- return null;
2447
- }
2448
-
2449
- try {
2450
- const walletMetadata = loadWalletMetadata(walletName, channelContext.workspace.network);
2451
- const walletContext = loadWallet(walletName, walletSecret, channelContext.workspace.network);
2452
- assertWalletMatchesMetadata(walletContext, walletMetadata);
2453
- assertExistingRecoverableWallet({
2454
- walletContext,
2455
- walletMetadata,
2456
- signerAddress,
2457
- signerPrivateKey,
2458
- l2Identity,
2459
- storageKey,
2460
- leafIndex,
2461
- rpcUrl,
2462
- channelContext,
2463
- noteReceiveKeyMaterial,
2464
- });
2465
- return walletContext;
2466
- } catch {
2467
- return null;
2468
- }
2469
- }
2470
-
2471
- function assertExistingRecoverableWallet({
2472
- walletContext,
2473
- walletMetadata,
2474
- signerAddress,
2475
- signerPrivateKey,
2476
- l2Identity,
2477
- storageKey,
2478
- leafIndex,
2479
- rpcUrl,
2480
- channelContext,
2481
- noteReceiveKeyMaterial,
2482
- }) {
2483
- const wallet = walletContext.wallet;
2484
- expect(
2485
- walletMetadata.network === channelContext.workspace.network,
2486
- `Wallet ${walletContext.walletName} metadata network does not match the requested network.`,
2487
- );
2488
- expect(
2489
- walletMetadata.channelName === channelContext.workspace.channelName,
2490
- `Wallet ${walletContext.walletName} metadata channel does not match the requested channel.`,
2491
- );
2492
- expect(
2493
- walletMetadata.rpcUrl === rpcUrl,
2494
- `Wallet ${walletContext.walletName} metadata rpcUrl does not match the requested runtime RPC URL.`,
2495
- );
2496
- expect(
2497
- normalizePrivateKey(wallet.l1PrivateKey) === normalizePrivateKey(signerPrivateKey),
2498
- `Wallet ${walletContext.walletName} does not decrypt to the requested L1 private key.`,
2499
- );
2500
- expect(
2501
- ethers.toBigInt(getAddress(wallet.l1Address)) === ethers.toBigInt(getAddress(signerAddress)),
2502
- `Wallet ${walletContext.walletName} L1 address does not match the requested signer.`,
2503
- );
2504
- expect(
2505
- ethers.toBigInt(getAddress(wallet.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address)),
2506
- `Wallet ${walletContext.walletName} L2 address does not match the derived channel identity.`,
2507
- );
2508
- expect(
2509
- ethers.toBigInt(normalizeBytes32Hex(wallet.l2StorageKey))
2510
- === ethers.toBigInt(normalizeBytes32Hex(storageKey)),
2511
- `Wallet ${walletContext.walletName} storage key does not match the derived registration key.`,
2512
- );
2513
- expect(
2514
- ethers.toBigInt(wallet.leafIndex) === ethers.toBigInt(leafIndex),
2515
- `Wallet ${walletContext.walletName} leaf index does not match the derived registration leaf index.`,
2516
- );
2517
- expect(
2518
- ethers.toBigInt(wallet.channelId) === ethers.toBigInt(channelContext.workspace.channelId),
2519
- `Wallet ${walletContext.walletName} channel ID does not match the requested channel.`,
2520
- );
2521
- expect(
2522
- wallet.channelName === channelContext.workspace.channelName,
2523
- `Wallet ${walletContext.walletName} channel name does not match the requested channel.`,
2524
- );
2525
- expect(
2526
- wallet.network === channelContext.workspace.network,
2527
- `Wallet ${walletContext.walletName} network does not match the requested network.`,
2528
- );
2529
- expect(
2530
- wallet.rpcUrl === rpcUrl,
2531
- `Wallet ${walletContext.walletName} rpcUrl does not match the requested runtime RPC URL.`,
2532
- );
2533
- expect(
2534
- ethers.toBigInt(getAddress(wallet.channelManager)) === ethers.toBigInt(getAddress(channelContext.workspace.channelManager)),
2535
- `Wallet ${walletContext.walletName} channel manager does not match the recovered workspace.`,
2536
- );
2537
- expect(
2538
- ethers.toBigInt(getAddress(wallet.bridgeTokenVault)) === ethers.toBigInt(getAddress(channelContext.workspace.bridgeTokenVault)),
2539
- `Wallet ${walletContext.walletName} bridge token vault does not match the recovered workspace.`,
2540
- );
2541
- expect(
2542
- ethers.toBigInt(getAddress(wallet.controller)) === ethers.toBigInt(getAddress(channelContext.workspace.controller)),
2543
- `Wallet ${walletContext.walletName} controller does not match the recovered workspace.`,
2544
- );
2545
- expect(
2546
- ethers.toBigInt(getAddress(wallet.l2AccountingVault))
2547
- === ethers.toBigInt(getAddress(channelContext.workspace.l2AccountingVault)),
2548
- `Wallet ${walletContext.walletName} L2 accounting vault does not match the recovered workspace.`,
2549
- );
2550
- expect(
2551
- ethers.toBigInt(normalizeBytes32Hex(wallet.noteReceivePubKeyX))
2552
- === ethers.toBigInt(normalizeBytes32Hex(noteReceiveKeyMaterial.noteReceivePubKey.x)),
2553
- `Wallet ${walletContext.walletName} note-receive public key X does not match the derived key.`,
2554
- );
2555
- expect(
2556
- Number(wallet.noteReceivePubKeyYParity) === Number(noteReceiveKeyMaterial.noteReceivePubKey.yParity),
2557
- `Wallet ${walletContext.walletName} note-receive public key parity does not match the derived key.`,
2558
- );
2559
- }
2560
-
2561
2639
  function removeLocalWalletArtifacts(walletName, networkName) {
2562
2640
  const walletDir = walletPath(walletName, networkName);
2563
2641
  const walletSecretFile = walletSecretPath(networkName, walletName);
@@ -2581,7 +2659,7 @@ function removeLocalWalletArtifacts(walletName, networkName) {
2581
2659
 
2582
2660
  function buildRecoverWalletRemovedNextAction({ channelName, networkName, accountName }) {
2583
2661
  const account = accountName ? String(accountName) : "<ACCOUNT>";
2584
- return `channel join --channel-name ${channelName} --network ${networkName} --account ${account} --wallet-secret-path <PATH>`;
2662
+ return `channel join --channel-name ${channelName} --network ${networkName} --account ${account} --wallet-secret-path <PATH> --acknowledge-action-impact`;
2585
2663
  }
2586
2664
 
2587
2665
  async function handleInstallZkEvm({ args }) {
@@ -2881,6 +2959,68 @@ async function handleDoctor({ args }) {
2881
2959
  }
2882
2960
  }
2883
2961
 
2962
+ function handleInvestigator() {
2963
+ const htmlPath = resolveInvestigatorIndexPath();
2964
+ const fileUrl = pathToFileURL(htmlPath).href;
2965
+ const browser = openFileInDefaultBrowser(fileUrl);
2966
+ printJson({
2967
+ action: "investigator",
2968
+ htmlPath,
2969
+ fileUrl,
2970
+ browserOpened: browser.opened,
2971
+ browserOpenCommand: browser.command,
2972
+ browserOpenError: browser.error,
2973
+ nextSteps: [
2974
+ "Create a raw evidence ZIP with wallet get-notes --export-evidence and --acknowledge-full-note-plaintext-export.",
2975
+ "Load the raw evidence ZIP in the browser investigator.",
2976
+ "Filter the raw bundle and export a user-consent disclosure ZIP.",
2977
+ "Do not submit the raw evidence ZIP unless full wallet-history disclosure is intended.",
2978
+ ],
2979
+ });
2980
+ }
2981
+
2982
+ function resolveInvestigatorIndexPath() {
2983
+ const candidates = [
2984
+ path.join(privateStateCliPackageRoot, "investigator", "index.html"),
2985
+ path.resolve(privateStateCliPackageRoot, "..", "investigator", "index.html"),
2986
+ ];
2987
+ const htmlPath = candidates.find((candidate) => fs.existsSync(candidate));
2988
+ if (!htmlPath) {
2989
+ throw new Error(
2990
+ [
2991
+ "Missing investigator HTML asset.",
2992
+ `Checked: ${candidates.join(", ")}`,
2993
+ "Reinstall the private-state CLI package or run from a complete repository checkout.",
2994
+ ].join(" "),
2995
+ );
2996
+ }
2997
+ return htmlPath;
2998
+ }
2999
+
3000
+ function openFileInDefaultBrowser(fileUrl) {
3001
+ const opener = defaultBrowserOpenCommand(fileUrl);
3002
+ const result = spawnSync(opener.command, opener.args, {
3003
+ stdio: "ignore",
3004
+ windowsHide: true,
3005
+ });
3006
+ return {
3007
+ command: [opener.command, ...opener.args].join(" "),
3008
+ opened: result.status === 0,
3009
+ status: result.status,
3010
+ error: result.error?.message ?? null,
3011
+ };
3012
+ }
3013
+
3014
+ function defaultBrowserOpenCommand(fileUrl) {
3015
+ if (process.platform === "darwin") {
3016
+ return { command: "open", args: [fileUrl] };
3017
+ }
3018
+ if (process.platform === "win32") {
3019
+ return { command: "cmd", args: ["/c", "start", "", fileUrl] };
3020
+ }
3021
+ return { command: "xdg-open", args: [fileUrl] };
3022
+ }
3023
+
2884
3024
  async function handleTransactionFees({ network, provider, rpcUrl }) {
2885
3025
  const feeAsset = loadTransactionFeeAsset();
2886
3026
  const feeData = await provider.getFeeData();
@@ -3080,24 +3220,19 @@ function handleListLocalWallets({ args }) {
3080
3220
  });
3081
3221
  }
3082
3222
 
3083
- function handleWalletExport({ args }) {
3223
+ function handleWalletExportBackup({ args }) {
3084
3224
  const outputPath = path.resolve(String(requireArg(args.output, "--output")));
3085
3225
  expect(!fs.existsSync(outputPath), `Export output already exists: ${outputPath}.`);
3086
3226
  ensureDir(path.dirname(outputPath));
3087
3227
 
3088
- const includeNotes = args.includeNotes === true;
3089
- const wallets = args.all === true
3090
- ? listLocalWallets({ networkFilter: "mainnet" }).filter((wallet) => wallet.hasEncryptedWallet)
3091
- : [resolveExportWalletInfo({
3092
- networkName: requireNetworkName(args),
3093
- walletName: requireWalletName(args),
3094
- })];
3228
+ const wallets = [resolveExportWalletInfo({
3229
+ networkName: requireNetworkName(args),
3230
+ walletName: requireWalletName(args),
3231
+ })];
3095
3232
 
3096
3233
  expect(
3097
3234
  wallets.length > 0,
3098
- args.all === true
3099
- ? "No local mainnet wallets are available to export."
3100
- : "No local wallet is available to export.",
3235
+ "No local wallet is available to export.",
3101
3236
  );
3102
3237
 
3103
3238
  const archive = new AdmZip();
@@ -3110,7 +3245,7 @@ function handleWalletExport({ args }) {
3110
3245
  channelName: normalized.channelName,
3111
3246
  wallet: normalized.wallet,
3112
3247
  });
3113
- for (const filePath of walletExportFilePaths(normalized, { includeNotes })) {
3248
+ for (const filePath of walletBackupExportFilePaths(normalized)) {
3114
3249
  const archivePath = archivePathForLocalCliFile(filePath);
3115
3250
  if (!files.has(archivePath)) {
3116
3251
  files.set(archivePath, filePath);
@@ -3119,44 +3254,65 @@ function handleWalletExport({ args }) {
3119
3254
  }
3120
3255
 
3121
3256
  const manifest = {
3122
- format: WALLET_EXPORT_FORMAT,
3257
+ format: WALLET_BACKUP_EXPORT_FORMAT,
3123
3258
  formatVersion: WALLET_EXPORT_FORMAT_VERSION,
3124
3259
  createdAt: new Date().toISOString(),
3125
3260
  cliPackage: PRIVATE_STATE_CLI_PACKAGE_NAME,
3126
3261
  cliVersion: privateStateCliPackageJson.version,
3127
- exportMode: args.all === true ? "all-mainnet" : "single-wallet",
3128
- includeNotes,
3129
- notes: includeNotes
3130
- ? [
3131
- "Includes the channel workspace cache required for immediate wallet command use when the cache is still chain-aligned.",
3132
- ]
3133
- : [
3134
- "Includes wallet identity, encrypted wallet state, metadata, and wallet-local secret only.",
3135
- "Run channel recover-workspace after import before wallet commands need channel state.",
3136
- ],
3262
+ exportMode: "backup",
3263
+ notes: [
3264
+ "Includes wallet note-tracking metadata, public key metadata, and channel workspace cache.",
3265
+ "Excludes spending keys, viewing keys, key derivation material, owner, value, and salt.",
3266
+ ],
3137
3267
  wallets: exportedWallets,
3138
3268
  files: [...files.keys()].sort(),
3139
3269
  };
3140
3270
 
3141
3271
  archive.addFile("manifest.json", Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"));
3142
3272
  for (const archivePath of manifest.files) {
3143
- archive.addFile(archivePath, fs.readFileSync(files.get(archivePath)));
3273
+ const filePath = files.get(archivePath);
3274
+ validateBackupExportFile(filePath);
3275
+ archive.addFile(archivePath, fs.readFileSync(filePath));
3144
3276
  }
3145
3277
  archive.writeZip(outputPath);
3146
3278
  protectSecretFile(outputPath, "wallet export ZIP");
3147
3279
 
3148
3280
  printJson({
3149
- action: "wallet export",
3281
+ action: "wallet export backup",
3150
3282
  output: outputPath,
3151
3283
  exportMode: manifest.exportMode,
3152
- includeNotes,
3153
3284
  walletCount: exportedWallets.length,
3154
3285
  fileCount: manifest.files.length,
3155
3286
  wallets: exportedWallets.map(({ network, channelName, wallet }) => ({ network, channelName, wallet })),
3156
3287
  });
3157
3288
  }
3158
3289
 
3159
- function handleWalletImport({ args }) {
3290
+ function handleWalletExportKey({ args, keyKind }) {
3291
+ const outputPath = path.resolve(String(requireArg(args.output, "--output")));
3292
+ expect(!fs.existsSync(outputPath), `Export output already exists: ${outputPath}.`);
3293
+ ensureDir(path.dirname(outputPath));
3294
+ const networkName = requireNetworkName(args);
3295
+ const walletName = requireWalletName(args);
3296
+ const wallet = loadWallet(walletName, networkName);
3297
+ const secretPath = keyKind === "spending"
3298
+ ? walletSpendingKeySecretPath(networkName, walletName)
3299
+ : walletViewingKeySecretPath(networkName, walletName);
3300
+ expect(fs.existsSync(secretPath), `Wallet ${walletName} is missing its ${keyKind} key.`);
3301
+ const payload = JSON.parse(readSecretFile(secretPath, `${keyKind} key`));
3302
+ validateWalletKeyPayload(payload, keyKind);
3303
+ fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
3304
+ protectSecretFile(outputPath, `${keyKind} key export`);
3305
+ printJson({
3306
+ action: `wallet export ${keyKind}-key`,
3307
+ wallet: wallet.walletName,
3308
+ network: networkName,
3309
+ output: outputPath,
3310
+ keyKind,
3311
+ metadata: payload.metadata,
3312
+ });
3313
+ }
3314
+
3315
+ function handleWalletImportBackup({ args }) {
3160
3316
  const inputPath = path.resolve(String(requireArg(args.input, "--input")));
3161
3317
  expect(fs.existsSync(inputPath), `Import ZIP does not exist: ${inputPath}.`);
3162
3318
 
@@ -3192,16 +3348,51 @@ function handleWalletImport({ args }) {
3192
3348
  commitWalletImportFiles({ targetRoot, plannedWrites });
3193
3349
 
3194
3350
  printJson({
3195
- action: "wallet import",
3351
+ action: "wallet import backup",
3196
3352
  input: inputPath,
3197
3353
  exportMode: manifest.exportMode,
3198
- includeNotes: Boolean(manifest.includeNotes),
3199
3354
  walletCount: manifest.wallets.length,
3200
3355
  fileCount: plannedWrites.length,
3201
3356
  wallets: manifest.wallets.map(({ network, channelName, wallet }) => ({ network, channelName, wallet })),
3202
- nextStep: manifest.includeNotes
3203
- ? "Wallet commands can run immediately if the imported channel workspace cache is still chain-aligned."
3204
- : "Run channel recover-workspace before wallet commands need channel state.",
3357
+ nextStep: "Import viewing-key and spending-key files separately when the wallet needs those capabilities.",
3358
+ });
3359
+ }
3360
+
3361
+ function handleWalletImportKey({ args, keyKind }) {
3362
+ const inputPath = path.resolve(String(requireArg(args.input, "--input")));
3363
+ expect(fs.existsSync(inputPath), `Key import file does not exist: ${inputPath}.`);
3364
+ const payload = JSON.parse(readImportSecretSourceFile(inputPath, "--input"));
3365
+ validateWalletKeyPayload(payload, keyKind);
3366
+ const metadata = payload.metadata;
3367
+ const networkName = requireNetworkName({ network: metadata.network });
3368
+ const walletName = requireWalletName({ wallet: metadata.wallet });
3369
+ const targetPath = keyKind === "spending"
3370
+ ? walletSpendingKeySecretPath(networkName, walletName)
3371
+ : walletViewingKeySecretPath(networkName, walletName);
3372
+ expect(!fs.existsSync(targetPath), `Refusing to overwrite existing ${keyKind} key: ${targetPath}.`);
3373
+ writeSecretFile(targetPath, JSON.stringify(payload, null, 2));
3374
+ const walletDir = walletPath(walletName, networkName);
3375
+ ensureDir(walletDir);
3376
+ if (walletConfigExists(walletDir)) {
3377
+ const metadataPath = keyKind === "spending"
3378
+ ? walletSpendingKeyMetadataPath(walletDir)
3379
+ : walletViewingKeyMetadataPath(walletDir);
3380
+ if (fs.existsSync(metadataPath)) {
3381
+ expect(
3382
+ JSON.stringify(readJson(metadataPath)) === JSON.stringify(normalizeCliOutput(metadata)),
3383
+ `Refusing to overwrite mismatched ${keyKind} key metadata: ${metadataPath}.`,
3384
+ );
3385
+ } else {
3386
+ writeJson(metadataPath, metadata);
3387
+ }
3388
+ }
3389
+ printJson({
3390
+ action: `wallet import ${keyKind}-key`,
3391
+ input: inputPath,
3392
+ network: networkName,
3393
+ wallet: walletName,
3394
+ keyKind,
3395
+ metadata,
3205
3396
  });
3206
3397
  }
3207
3398
 
@@ -3218,6 +3409,47 @@ function readWalletImportArchive(inputPath) {
3218
3409
  }
3219
3410
  }
3220
3411
 
3412
+ function validateBackupExportFile(filePath) {
3413
+ if (path.basename(filePath) !== "wallet-notes.metadata.json") {
3414
+ return;
3415
+ }
3416
+ const metadata = readJson(filePath);
3417
+ const forbidden = findForbiddenBackupMetadataPaths(metadata);
3418
+ expect(
3419
+ forbidden.length === 0,
3420
+ `wallet export backup refuses to export plaintext note secrets or key material: ${forbidden.join(", ")}.`,
3421
+ );
3422
+ }
3423
+
3424
+ function findForbiddenBackupMetadataPaths(value, pathParts = []) {
3425
+ const forbiddenNames = new Set([
3426
+ "owner",
3427
+ "value",
3428
+ "salt",
3429
+ "l1PrivateKey",
3430
+ "l2PrivateKey",
3431
+ "noteReceivePrivateKey",
3432
+ "walletSecret",
3433
+ "seedSignature",
3434
+ ]);
3435
+ if (Array.isArray(value)) {
3436
+ return value.flatMap((entry, index) => findForbiddenBackupMetadataPaths(entry, [...pathParts, String(index)]));
3437
+ }
3438
+ if (!value || typeof value !== "object") {
3439
+ return [];
3440
+ }
3441
+ const found = [];
3442
+ for (const [key, entry] of Object.entries(value)) {
3443
+ const nextPath = [...pathParts, key];
3444
+ if (forbiddenNames.has(key) && entry !== undefined && entry !== null) {
3445
+ found.push(nextPath.join("."));
3446
+ continue;
3447
+ }
3448
+ found.push(...findForbiddenBackupMetadataPaths(entry, nextPath));
3449
+ }
3450
+ return found;
3451
+ }
3452
+
3221
3453
  function commitWalletImportFiles({ targetRoot, plannedWrites }) {
3222
3454
  const stagingRoot = fs.mkdtempSync(path.join(targetRoot, ".wallet-import-"));
3223
3455
  const committedPaths = [];
@@ -3611,14 +3843,18 @@ async function inspectGuideAccount({ account, networkName, network, provider, ar
3611
3843
 
3612
3844
  async function inspectGuideWallet({ walletName, networkName, provider, artifactsInstalled }) {
3613
3845
  const walletDir = walletPath(walletName, networkName);
3846
+ const viewingKeyFile = walletViewingKeySecretPath(networkName, walletName);
3847
+ const spendingKeyFile = walletSpendingKeySecretPath(networkName, walletName);
3614
3848
  const result = {
3615
3849
  wallet: walletName,
3616
3850
  network: networkName,
3617
3851
  walletDir,
3618
3852
  exists: walletConfigExists(walletDir),
3619
- metadataExists: fs.existsSync(walletMetadataPath(walletDir)),
3620
- secretFile: walletSecretPath(networkName, walletName),
3621
- secretFileExists: fs.existsSync(walletSecretPath(networkName, walletName)),
3853
+ metadataExists: fs.existsSync(walletNotesMetadataPath(walletDir)),
3854
+ viewingKeyFile,
3855
+ viewingKeyFileExists: fs.existsSync(viewingKeyFile),
3856
+ spendingKeyFile,
3857
+ spendingKeyFileExists: fs.existsSync(spendingKeyFile),
3622
3858
  channelName: null,
3623
3859
  l1Address: null,
3624
3860
  l2Address: null,
@@ -3636,7 +3872,7 @@ async function inspectGuideWallet({ walletName, networkName, provider, artifacts
3636
3872
  }
3637
3873
 
3638
3874
  try {
3639
- const walletContext = loadWallet(walletName, resolveWalletDefaultSecret(networkName, walletName), networkName);
3875
+ const walletContext = loadWallet(walletName, networkName);
3640
3876
  const walletMetadata = loadWalletMetadata(walletName, networkName);
3641
3877
  assertWalletMatchesMetadata(walletContext, walletMetadata);
3642
3878
  result.channelName = walletContext.wallet.channelName;
@@ -3644,10 +3880,13 @@ async function inspectGuideWallet({ walletName, networkName, provider, artifacts
3644
3880
  result.l2Address = getAddress(walletContext.wallet.l2Address);
3645
3881
  result.unusedNoteCount = Object.keys(walletContext.wallet.notes.unused).length;
3646
3882
  result.spentNoteCount = Object.keys(walletContext.wallet.notes.spent).length;
3647
- const unusedNoteBalance = Object.values(walletContext.wallet.notes.unused)
3648
- .reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n);
3649
- result.unusedNoteBalanceBaseUnits = unusedNoteBalance.toString();
3650
- result.unusedNoteBalanceTokens = ethers.formatUnits(unusedNoteBalance, Number(walletContext.wallet.canonicalAssetDecimals));
3883
+ const unusedValues = Object.values(walletContext.wallet.notes.unused).map((note) => note.value);
3884
+ if (unusedValues.every((value) => value !== null)) {
3885
+ const unusedNoteBalance = Object.values(walletContext.wallet.notes.unused)
3886
+ .reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n);
3887
+ result.unusedNoteBalanceBaseUnits = unusedNoteBalance.toString();
3888
+ result.unusedNoteBalanceTokens = ethers.formatUnits(unusedNoteBalance, Number(walletContext.wallet.canonicalAssetDecimals));
3889
+ }
3651
3890
 
3652
3891
  if (provider && artifactsInstalled && walletChannelWorkspaceIsReady(walletContext)) {
3653
3892
  const context = await loadWorkspaceContext(walletContext.wallet.channelName, networkName, provider);
@@ -3718,7 +3957,7 @@ function applyGuideNextAction(guide) {
3718
3957
  const channelName = guide.selectors.channelName ?? guide.state.channel?.channelName ?? "<CHANNEL>";
3719
3958
  const account = guide.selectors.account ?? "<ACCOUNT>";
3720
3959
  setGuideNextAction(guide, {
3721
- command: `channel join --channel-name ${channelName} --network ${guide.selectors.network} --account ${account} --wallet-secret-path <PATH>`,
3960
+ command: `channel join --channel-name ${channelName} --network ${guide.selectors.network} --account ${account} --wallet-secret-path <PATH> --acknowledge-action-impact`,
3722
3961
  why: "The selected local wallet does not exist. Join the channel to create the wallet and register the channel L2 identity.",
3723
3962
  });
3724
3963
  return;
@@ -3727,7 +3966,7 @@ function applyGuideNextAction(guide) {
3727
3966
  const channelName = guide.state.wallet.channelName ?? guide.selectors.channelName ?? "<CHANNEL>";
3728
3967
  const account = guide.selectors.account ?? "<ACCOUNT>";
3729
3968
  setGuideNextAction(guide, {
3730
- command: `channel join --channel-name ${channelName} --network ${guide.selectors.network} --account ${account} --wallet-secret-path <PATH>`,
3969
+ command: `channel join --channel-name ${channelName} --network ${guide.selectors.network} --account ${account} --wallet-secret-path <PATH> --acknowledge-action-impact`,
3731
3970
  why: "The local wallet exists, but the corresponding L1 address is not registered in the channel.",
3732
3971
  });
3733
3972
  return;
@@ -3744,32 +3983,32 @@ function applyGuideNextAction(guide) {
3744
3983
  if (guide.state.wallet?.exists && bridgeBalance === 0n && (channelBalance === null || channelBalance === 0n) && unusedNotes === 0) {
3745
3984
  const account = guide.selectors.account ?? "<ACCOUNT>";
3746
3985
  setGuideNextAction(guide, {
3747
- command: `account deposit-bridge --amount <TOKENS> --network ${guide.selectors.network} --account ${account}`,
3986
+ command: `account deposit-bridge --amount <TOKENS> --network ${guide.selectors.network} --account ${account} --acknowledge-action-impact`,
3748
3987
  why: "The wallet is joined, but there is no bridge balance, channel balance, or local unused note to spend.",
3749
3988
  });
3750
3989
  return;
3751
3990
  }
3752
3991
  if (guide.state.wallet?.exists && bridgeBalance !== null && bridgeBalance > 0n && channelBalance === 0n) {
3753
3992
  setGuideNextAction(guide, {
3754
- command: `wallet deposit-channel --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amount <TOKENS>`,
3993
+ command: `wallet deposit-channel --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amount <TOKENS> --acknowledge-action-impact`,
3755
3994
  why: "The account has funds in the shared bridge vault, but the wallet has no channel L2 accounting balance.",
3756
3995
  });
3757
3996
  return;
3758
3997
  }
3759
3998
  if (guide.state.wallet?.exists && channelBalance !== null && channelBalance > 0n && unusedNotes === 0) {
3760
3999
  setGuideNextAction(guide, {
3761
- command: `wallet mint-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amounts <A,B> [--tx-submitter <ACCOUNT>]`,
4000
+ command: `wallet mint-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amounts <A,B> --acknowledge-action-impact [--tx-submitter <ACCOUNT>]`,
3762
4001
  why: "The wallet has channel L2 balance and no unused private notes yet. Use --tx-submitter for stronger transaction-submission privacy.",
3763
4002
  });
3764
4003
  return;
3765
4004
  }
3766
4005
  if (guide.state.wallet?.exists && unusedNotes !== null && unusedNotes > 0) {
3767
4006
  setGuideNextAction(guide, {
3768
- 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>]`,
4007
+ 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>]`,
3769
4008
  why: "The wallet has unused private notes. It can transfer or redeem those notes. Use --tx-submitter for stronger transaction-submission privacy.",
3770
4009
  candidates: [
3771
4010
  `wallet get-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network}`,
3772
- `wallet redeem-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID> [--tx-submitter <ACCOUNT>]`,
4011
+ `wallet redeem-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID> --acknowledge-action-impact [--tx-submitter <ACCOUNT>]`,
3773
4012
  ],
3774
4013
  });
3775
4014
  return;
@@ -3880,6 +4119,12 @@ async function handleWalletGetMeta({ args, provider }) {
3880
4119
  registeredL2Address: registration.exists ? getAddress(registration.l2Address) : null,
3881
4120
  registeredL2StorageKey: registration.exists ? normalizeBytes32Hex(registration.channelTokenVaultKey) : null,
3882
4121
  registeredLeafIndex: registration.exists ? registration.leafIndex.toString() : null,
4122
+ registeredNoteReceivePubKey: registration.exists
4123
+ ? {
4124
+ x: normalizeBytes32Hex(registration.noteReceivePubKey.x),
4125
+ yParity: Number(registration.noteReceivePubKey.yParity),
4126
+ }
4127
+ : null,
3883
4128
  });
3884
4129
  }
3885
4130
 
@@ -3925,7 +4170,8 @@ async function loadWalletChannelRegistrationState({
3925
4170
  provider,
3926
4171
  requireRegistration = false,
3927
4172
  }) {
3928
- const { signer, l2Identity } = restoreWalletParticipant(walletContext, provider);
4173
+ const signer = requireWalletOwnerSigner(walletContext, provider);
4174
+ const l2Identity = restoreParticipantIdentityFromWallet(walletContext.wallet);
3929
4175
  const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
3930
4176
  const expectedStorageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
3931
4177
  const matchesWallet = registration.exists
@@ -4026,13 +4272,9 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4026
4272
  const storageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
4027
4273
  const leafIndex = deriveChannelTokenVaultLeafIndex(storageKey);
4028
4274
 
4029
- const resolvedLeafIndex = leafIndex;
4030
4275
  let approveReceipt = null;
4031
4276
  let receipt = null;
4032
- let joinToll = 0n;
4033
- let status = null;
4034
-
4035
- joinToll = ethers.toBigInt(await context.channelManager.joinToll());
4277
+ const joinToll = ethers.toBigInt(await context.channelManager.joinToll());
4036
4278
  const asset = new Contract(
4037
4279
  context.workspace.canonicalAsset,
4038
4280
  context.bridgeAbiManifest.contracts.erc20.abi,
@@ -4046,6 +4288,14 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4046
4288
  channelManager: context.workspace.channelManager,
4047
4289
  policySnapshot: context.workspace.policySnapshot,
4048
4290
  });
4291
+ await requireActionImpactAcknowledgement("channel-join", args, {
4292
+ l1Address: signer.address,
4293
+ l2Address: l2Identity.l2Address,
4294
+ noteReceivePubKey: JSON.stringify(noteReceiveKeyMaterial.noteReceivePubKey),
4295
+ joinToll: ethers.formatUnits(joinToll, Number(context.workspace.canonicalAssetDecimals)),
4296
+ channelName: context.workspace.channelName,
4297
+ channelId: context.workspace.channelId,
4298
+ });
4049
4299
  if (joinToll !== 0n) {
4050
4300
  approveReceipt = await waitForReceipt(
4051
4301
  await asset.approve(context.workspace.bridgeTokenVault, joinToll, { nonce: nextNonce++ }),
@@ -4061,8 +4311,6 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4061
4311
  { nonce: nextNonce++ },
4062
4312
  ),
4063
4313
  );
4064
- status = "joined";
4065
-
4066
4314
  await refreshPersistedWorkspaceAfterLocalTransaction({
4067
4315
  context,
4068
4316
  provider,
@@ -4077,7 +4325,7 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4077
4325
  l2Identity,
4078
4326
  walletSecret,
4079
4327
  storageKey,
4080
- leafIndex: resolvedLeafIndex,
4328
+ leafIndex,
4081
4329
  noteReceiveKeyMaterial,
4082
4330
  rpcUrl,
4083
4331
  });
@@ -4086,14 +4334,15 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4086
4334
  action: "channel join",
4087
4335
  workspace: context.workspaceName,
4088
4336
  wallet: walletContext.walletName,
4089
- walletSecretSource: resolvedWalletSecretSource(args),
4090
- walletSecretFile: resolvedWalletSecretFile(network.name, walletContext.walletName),
4337
+ walletSecretSource: "wallet-secret-path-one-time-derivation",
4338
+ walletSecretStored: false,
4339
+ 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.",
4091
4340
  channelName: context.workspace.channelName,
4092
4341
  channelId: context.workspace.channelId,
4093
4342
  l1Address: signer.address,
4094
4343
  l2Address: l2Identity.l2Address,
4095
4344
  l2StorageKey: storageKey,
4096
- leafIndex: resolvedLeafIndex.toString(),
4345
+ leafIndex: leafIndex.toString(),
4097
4346
  joinTollBaseUnits: joinToll.toString(),
4098
4347
  joinTollTokens: ethers.formatUnits(joinToll, Number(context.workspace.canonicalAssetDecimals)),
4099
4348
  noteReceivePubKey: noteReceiveKeyMaterial.noteReceivePubKey,
@@ -4104,7 +4353,7 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4104
4353
  txUrl: receipt ? explorerTxUrl(network, receipt.hash) : null,
4105
4354
  approveReceipt: approveReceipt ? sanitizeReceipt(approveReceipt) : null,
4106
4355
  receipt: receipt ? sanitizeReceipt(receipt) : null,
4107
- status,
4356
+ status: "joined",
4108
4357
  });
4109
4358
  }
4110
4359
 
@@ -4115,18 +4364,19 @@ async function handleExitChannel({ args, provider }) {
4115
4364
  provider,
4116
4365
  progressAction: "channel exit",
4117
4366
  });
4367
+ const ownerSigner = requireWalletOwnerSigner(walletContext, provider);
4118
4368
  const network = contextResult.network;
4119
4369
  expect(
4120
4370
  channelFund === 0n,
4121
4371
  [
4122
- `The current channel fund for ${signer.address} is ${channelFund.toString()}.`,
4372
+ `The current channel fund for ${ownerSigner.address} is ${channelFund.toString()}.`,
4123
4373
  "channel exit requires a zero channel balance.",
4124
4374
  "Run wallet withdraw-channel first, then retry channel exit.",
4125
4375
  ].join(" "),
4126
4376
  );
4127
- const [refundAmount, refundBps] = await context.channelManager.getExitTollRefundQuote(signer.address);
4377
+ const [refundAmount, refundBps] = await context.channelManager.getExitTollRefundQuote(ownerSigner.address);
4128
4378
  const receipt = await waitForReceipt(
4129
- await context.bridgeTokenVault.connect(signer).exitChannel(ethers.toBigInt(context.workspace.channelId)),
4379
+ await context.bridgeTokenVault.connect(ownerSigner).exitChannel(ethers.toBigInt(context.workspace.channelId)),
4130
4380
  );
4131
4381
  const cleanup = removeLocalWalletArtifacts(walletContext.walletName, walletMetadata.network);
4132
4382
 
@@ -4136,7 +4386,7 @@ async function handleExitChannel({ args, provider }) {
4136
4386
  network: walletMetadata.network,
4137
4387
  channelName: walletMetadata.channelName,
4138
4388
  channelId: context.workspace.channelId,
4139
- l1Address: signer.address,
4389
+ l1Address: ownerSigner.address,
4140
4390
  currentUserValue: channelFund.toString(),
4141
4391
  refundAmountBaseUnits: refundAmount.toString(),
4142
4392
  refundAmountTokens: ethers.formatUnits(refundAmount, Number(context.workspace.canonicalAssetDecimals)),
@@ -4175,6 +4425,13 @@ async function handleGrothVaultMove({ args, provider, direction }) {
4175
4425
  const { signer, l2Identity } = restoreWalletParticipant(walletContext, provider);
4176
4426
  const amountInput = requireArg(args.amount, "--amount");
4177
4427
  const amount = parseTokenAmount(amountInput, Number(context.workspace.canonicalAssetDecimals));
4428
+ await requireActionImpactAcknowledgement(args.command, args, {
4429
+ l1Address: signer.address,
4430
+ l2Address: l2Identity.l2Address,
4431
+ amountInput,
4432
+ channelName: context.workspace.channelName,
4433
+ channelId: context.workspace.channelId,
4434
+ });
4178
4435
  const storageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
4179
4436
  const bridgeTokenVault = new Contract(
4180
4437
  context.workspace.bridgeTokenVault,
@@ -4257,7 +4514,7 @@ async function handleGrothVaultMove({ args, provider, direction }) {
4257
4514
  writeJson(path.join(operationDir, `${operationName}-receipt.json`), sanitizeReceipt(receipt));
4258
4515
  writeJson(path.join(operationDir, "state_snapshot.json"), transition.nextSnapshot);
4259
4516
  writeJson(path.join(operationDir, "state_snapshot.normalized.json"), transition.nextSnapshot);
4260
- sealWalletOperationDir(operationDir, walletContext.walletSecret);
4517
+ sealWalletOperationDir(operationDir, walletOperationSealSecret(walletContext));
4261
4518
 
4262
4519
  context.currentSnapshot = transition.nextSnapshot;
4263
4520
  await refreshPersistedWorkspaceAfterLocalTransaction({
@@ -4292,6 +4549,11 @@ async function handleWithdrawBridge({ args, network, provider }) {
4292
4549
  const bridgeVaultContext = await loadBridgeVaultContext({ provider, chainId });
4293
4550
  const amountInput = requireArg(args.amount, "--amount");
4294
4551
  const amount = parseTokenAmount(amountInput, Number(bridgeVaultContext.canonicalAssetDecimals));
4552
+ await requireActionImpactAcknowledgement("account-withdraw-bridge", args, {
4553
+ l1Address: signer.address,
4554
+ amountInput,
4555
+ bridgeTokenVault: bridgeVaultContext.bridgeTokenVaultAddress,
4556
+ });
4295
4557
  const bridgeTokenVault = new Contract(
4296
4558
  bridgeVaultContext.bridgeTokenVaultAddress,
4297
4559
  bridgeVaultContext.bridgeAbiManifest.contracts.bridgeTokenVault.abi,
@@ -4372,6 +4634,7 @@ function resolveFunctionMetadataProofForExecution({
4372
4634
 
4373
4635
  async function handleMintNotes({ args, provider }) {
4374
4636
  const { wallet } = loadUnlockedWalletWithMetadata(args);
4637
+ requireWalletSpendingCapability(wallet);
4375
4638
  const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
4376
4639
  const amountInputs = parseAmountVector(requireArg(args.amounts, "--amounts"), {
4377
4640
  allowZeroEntries: true,
@@ -4401,6 +4664,19 @@ async function handleMintNotes({ args, provider }) {
4401
4664
  `${channelFund.toString()}. Run wallet get-channel-fund to inspect the available balance.`,
4402
4665
  ].join(" "),
4403
4666
  );
4667
+ const { signer, l2Identity } = restoreWalletParticipant(wallet, provider);
4668
+ const { txSubmitter } = resolveTxSubmitterSigner({
4669
+ args,
4670
+ ownerSigner: signer,
4671
+ provider,
4672
+ });
4673
+ await requireActionImpactAcknowledgement("wallet-mint-notes", args, {
4674
+ l1Address: txSubmitter.address,
4675
+ l2Address: l2Identity.l2Address,
4676
+ amounts: baseUnitAmounts.map(({ amountInput }) => amountInput).join(", "),
4677
+ channelName: wallet.wallet.channelName,
4678
+ channelId: wallet.wallet.channelId,
4679
+ });
4404
4680
  const templatePayload = buildMintNotesTemplatePayload({
4405
4681
  wallet,
4406
4682
  baseUnitAmounts: baseUnitAmounts.map(({ amountBaseUnits }) => amountBaseUnits),
@@ -4433,6 +4709,7 @@ async function handleMintNotes({ args, provider }) {
4433
4709
  sourceFunction: templatePayload.method,
4434
4710
  sourceTxHash: execution.receipt.hash,
4435
4711
  bridgeCommitmentKeys: execution.noteLifecycle.outputCommitmentKeys,
4712
+ sourceBlockNumber: execution.receipt.blockNumber,
4436
4713
  }),
4437
4714
  gasUsed: receiptGasUsed(execution.receipt),
4438
4715
  txUrl: explorerTxUrl(contextResult.network, execution.receipt.hash),
@@ -4445,6 +4722,8 @@ async function handleMintNotes({ args, provider }) {
4445
4722
 
4446
4723
  async function handleRedeemNotes({ args, provider }) {
4447
4724
  const { wallet } = loadUnlockedWalletWithMetadata(args);
4725
+ requireWalletViewingCapability(wallet);
4726
+ requireWalletSpendingCapability(wallet);
4448
4727
  const noteIds = parseNoteIdVector(requireArg(args.noteIds, "--note-ids"));
4449
4728
  const preparedContextResult = await loadFreshWalletChannelContext({
4450
4729
  walletContext: wallet,
@@ -4456,9 +4735,22 @@ async function handleRedeemNotes({ args, provider }) {
4456
4735
  context: preparedContextResult.context,
4457
4736
  provider,
4458
4737
  progressAction: "wallet redeem-notes",
4459
- preConsumedLogRequests: preparedContextResult.autoRecoveryLogRequests,
4738
+ preConsumedBlockDelta: preparedContextResult.autoRecoveryBlockDelta,
4460
4739
  });
4461
4740
  const inputNotes = loadWalletUnusedInputNotes(wallet, noteIds);
4741
+ const { signer, l2Identity } = restoreWalletParticipant(wallet, provider);
4742
+ const { txSubmitter } = resolveTxSubmitterSigner({
4743
+ args,
4744
+ ownerSigner: signer,
4745
+ provider,
4746
+ });
4747
+ await requireActionImpactAcknowledgement("wallet-redeem-notes", args, {
4748
+ l1Address: txSubmitter.address,
4749
+ l2Address: l2Identity.l2Address,
4750
+ noteIds: noteIds.join(", "),
4751
+ channelName: wallet.wallet.channelName,
4752
+ channelId: wallet.wallet.channelId,
4753
+ });
4462
4754
  const templatePayload = buildRedeemNotesTemplatePayload({
4463
4755
  wallet,
4464
4756
  inputNotes,
@@ -4514,13 +4806,20 @@ async function handleWalletGetNotes({ args, provider }) {
4514
4806
  progressAction: "wallet get-notes",
4515
4807
  });
4516
4808
  const context = contextResult.context;
4517
- const noteReceiveFreshness = await ensureWalletNoteReceiveStateCurrent({
4518
- walletContext: wallet,
4519
- context,
4520
- provider,
4521
- progressAction: "wallet get-notes",
4522
- preConsumedLogRequests: contextResult.autoRecoveryLogRequests,
4523
- });
4809
+ const noteReceiveFreshness = wallet.wallet.noteReceivePrivateKey
4810
+ ? await ensureWalletNoteReceiveStateCurrent({
4811
+ walletContext: wallet,
4812
+ context,
4813
+ provider,
4814
+ progressAction: "wallet get-notes",
4815
+ preConsumedBlockDelta: contextResult.autoRecoveryBlockDelta,
4816
+ })
4817
+ : {
4818
+ nextBlock: wallet.wallet.noteReceiveLastScannedBlock,
4819
+ latestBlock: await provider.getBlockNumber(),
4820
+ recoveredWalletWorkspace: false,
4821
+ recoveredDeliveryState: null,
4822
+ };
4524
4823
 
4525
4824
  const unusedTrackedNotes = wallet.wallet.notes.unusedOrder
4526
4825
  .map((commitment) => wallet.wallet.notes.unused[commitment])
@@ -4540,8 +4839,20 @@ async function handleWalletGetNotes({ args, provider }) {
4540
4839
  canonicalAssetDecimals,
4541
4840
  })));
4542
4841
 
4543
- const unusedTotal = unusedTrackedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n);
4544
- const spentTotal = spentTrackedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n);
4842
+ const canComputeTotals = [...unusedTrackedNotes, ...spentTrackedNotes].every((note) => note.value !== null);
4843
+ const unusedTotal = canComputeTotals ? unusedTrackedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n) : null;
4844
+ const spentTotal = canComputeTotals ? spentTrackedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n) : null;
4845
+ const evidenceExport = args.exportEvidence
4846
+ ? await exportWalletGetNotesEvidenceBundle({
4847
+ args,
4848
+ provider,
4849
+ walletContext: wallet,
4850
+ walletMetadata,
4851
+ context,
4852
+ unusedTrackedNotes,
4853
+ spentTrackedNotes,
4854
+ })
4855
+ : null;
4545
4856
 
4546
4857
  printJson({
4547
4858
  action: "wallet get-notes",
@@ -4551,22 +4862,447 @@ async function handleWalletGetNotes({ args, provider }) {
4551
4862
  controller: wallet.wallet.controller,
4552
4863
  unusedNotes,
4553
4864
  spentNotes,
4554
- unusedTotalBaseUnits: unusedTotal.toString(),
4555
- unusedTotalTokens: ethers.formatUnits(unusedTotal, canonicalAssetDecimals),
4556
- spentTotalBaseUnits: spentTotal.toString(),
4557
- spentTotalTokens: ethers.formatUnits(spentTotal, canonicalAssetDecimals),
4865
+ unusedTotalBaseUnits: unusedTotal?.toString() ?? null,
4866
+ unusedTotalTokens: unusedTotal === null ? null : ethers.formatUnits(unusedTotal, canonicalAssetDecimals),
4867
+ spentTotalBaseUnits: spentTotal?.toString() ?? null,
4868
+ spentTotalTokens: spentTotal === null ? null : ethers.formatUnits(spentTotal, canonicalAssetDecimals),
4558
4869
  bridgeStatusMismatches: [...unusedNotes, ...spentNotes].filter((note) => !note.walletStatusMatchesBridge).length,
4559
4870
  noteReceiveLastScannedBlock: noteReceiveFreshness.nextBlock,
4560
4871
  latestBlock: noteReceiveFreshness.latestBlock,
4872
+ viewingKeyAvailable: Boolean(wallet.wallet.noteReceivePrivateKey),
4561
4873
  recoveredWalletWorkspace: noteReceiveFreshness.recoveredWalletWorkspace,
4562
4874
  recoveredFromLogs: noteReceiveFreshness.recoveredDeliveryState?.importedNotes ?? 0,
4563
4875
  scannedDeliveryLogs: noteReceiveFreshness.recoveredDeliveryState?.scannedLogs ?? 0,
4564
4876
  noteReceiveScanRange: noteReceiveFreshness.recoveredDeliveryState?.scanRange ?? null,
4877
+ evidenceExport,
4878
+ });
4879
+ }
4880
+
4881
+ async function exportWalletGetNotesEvidenceBundle({
4882
+ args,
4883
+ provider,
4884
+ walletContext,
4885
+ walletMetadata,
4886
+ context,
4887
+ unusedTrackedNotes,
4888
+ spentTrackedNotes,
4889
+ }) {
4890
+ const outputPath = path.resolve(String(requireArg(args.exportEvidence, "--export-evidence")));
4891
+ ensureDir(path.dirname(outputPath));
4892
+
4893
+ const notes = [
4894
+ ...unusedTrackedNotes.map(normalizeTrackedNote),
4895
+ ...spentTrackedNotes.map(normalizeTrackedNote),
4896
+ ].sort((left, right) => left.commitment.localeCompare(right.commitment));
4897
+
4898
+ for (const note of notes) {
4899
+ validateEvidenceNotePlaintext(note, walletContext.wallet);
4900
+ }
4901
+
4902
+ const txHashes = uniqueNonNull([
4903
+ ...notes.map((note) => note.createdAtTxHash),
4904
+ ...notes.map((note) => note.spentAtTxHash),
4905
+ ]);
4906
+ const transactionEvidence = await buildTransactionEvidenceMap({ provider, txHashes });
4907
+ const blockTimestampCache = buildBlockTimestampCache(transactionEvidence);
4908
+ const noteRecords = notes.map((note) => buildEvidenceNoteRecord({
4909
+ note,
4910
+ walletContext,
4911
+ walletMetadata,
4912
+ context,
4913
+ transactionEvidence,
4914
+ blockTimestampCache,
4915
+ }));
4916
+ const indexes = buildEvidenceIndexes(noteRecords);
4917
+ const manifest = buildEvidenceManifest({
4918
+ outputPath,
4919
+ walletContext,
4920
+ walletMetadata,
4921
+ context,
4922
+ noteRecords,
4923
+ txHashes,
4924
+ });
4925
+
4926
+ const archive = new AdmZip();
4927
+ addEvidenceJson(archive, "manifest.json", manifest);
4928
+ addEvidenceJson(archive, "indexes/by-commitment.json", indexes.byCommitment);
4929
+ addEvidenceJson(archive, "indexes/by-nullifier.json", indexes.byNullifier);
4930
+ addEvidenceJson(archive, "indexes/by-creation-tx.json", indexes.byCreationTx);
4931
+ addEvidenceJson(archive, "indexes/by-spend-tx.json", indexes.bySpendTx);
4932
+ addEvidenceJson(archive, "indexes/by-block-range.json", indexes.byBlockRange);
4933
+ addEvidenceJson(archive, "indexes/by-counterparty.json", indexes.byCounterparty);
4934
+ for (const record of noteRecords) {
4935
+ addEvidenceJson(archive, `notes/${record.derived.commitment}.json`, record);
4936
+ }
4937
+ for (const [txHash, txRecord] of Object.entries(transactionEvidence)) {
4938
+ addEvidenceJson(archive, `transactions/${txHash}.json`, txRecord.transaction);
4939
+ addEvidenceJson(archive, `receipts/${txHash}.json`, txRecord.receipt);
4940
+ addEvidenceJson(archive, `events/${txHash}.json`, txRecord.events);
4941
+ }
4942
+
4943
+ assertEvidenceBundleDoesNotContainSecrets({
4944
+ wallet: walletContext.wallet,
4945
+ payload: {
4946
+ manifest,
4947
+ indexes,
4948
+ noteRecords,
4949
+ transactionEvidence,
4950
+ },
4565
4951
  });
4952
+ fs.rmSync(outputPath, { force: true });
4953
+ archive.writeZip(outputPath);
4954
+ protectSecretFile(outputPath, "wallet evidence export ZIP");
4955
+
4956
+ return {
4957
+ output: outputPath,
4958
+ format: WALLET_EVIDENCE_BUNDLE_FORMAT,
4959
+ formatVersion: WALLET_EVIDENCE_BUNDLE_FORMAT_VERSION,
4960
+ noteCount: noteRecords.length,
4961
+ transactionCount: txHashes.length,
4962
+ containsNotePlaintext: true,
4963
+ containsSpendingKey: false,
4964
+ containsViewingKey: false,
4965
+ containsWalletSecret: false,
4966
+ warning: "Local full-note evidence bundle. Do not submit as-is unless full wallet-history disclosure is intended.",
4967
+ };
4968
+ }
4969
+
4970
+ function validateEvidenceNotePlaintext(note, wallet) {
4971
+ expect(
4972
+ note.owner && note.value !== null && note.salt && note.encryptedNoteValue,
4973
+ [
4974
+ `Cannot export evidence for note ${note.commitment} because plaintext note data is incomplete.`,
4975
+ "Import the wallet viewing key and run wallet recover-workspace before exporting evidence.",
4976
+ ].join(" "),
4977
+ );
4978
+ expect(
4979
+ getAddress(note.owner) === getAddress(wallet.l2Address),
4980
+ `Cannot export evidence for note ${note.commitment}: owner does not match wallet L2 address.`,
4981
+ );
4982
+ const recomputedSalt = computeEncryptedNoteSalt(note.encryptedNoteValue);
4983
+ expect(
4984
+ ethers.toBigInt(recomputedSalt) === ethers.toBigInt(note.salt),
4985
+ `Cannot export evidence for note ${note.commitment}: encrypted note salt mismatch.`,
4986
+ );
4987
+ const plaintext = normalizePlaintextNote(note);
4988
+ expect(
4989
+ ethers.toBigInt(computeNoteCommitment(plaintext)) === ethers.toBigInt(note.commitment),
4990
+ `Cannot export evidence for note ${note.commitment}: commitment mismatch.`,
4991
+ );
4992
+ expect(
4993
+ ethers.toBigInt(computeNullifier(plaintext)) === ethers.toBigInt(note.nullifier),
4994
+ `Cannot export evidence for note ${note.commitment}: nullifier mismatch.`,
4995
+ );
4996
+ }
4997
+
4998
+ async function buildTransactionEvidenceMap({ provider, txHashes }) {
4999
+ const entries = {};
5000
+ for (const txHash of txHashes) {
5001
+ const [transaction, receipt] = await Promise.all([
5002
+ provider.getTransaction(txHash).catch(() => null),
5003
+ provider.getTransactionReceipt(txHash).catch(() => null),
5004
+ ]);
5005
+ const blockNumber = receipt?.blockNumber ?? transaction?.blockNumber ?? null;
5006
+ const block = blockNumber === null ? null : await provider.getBlock(blockNumber).catch(() => null);
5007
+ entries[txHash] = {
5008
+ transaction: sanitizeTransactionEvidence(transaction, block),
5009
+ receipt: receipt ? sanitizeReceipt(receipt) : null,
5010
+ events: receipt ? sanitizeReceiptEvents(receipt) : [],
5011
+ };
5012
+ }
5013
+ return entries;
5014
+ }
5015
+
5016
+ function sanitizeTransactionEvidence(transaction, block) {
5017
+ if (!transaction) {
5018
+ return null;
5019
+ }
5020
+ return normalizeCliOutput(serializeBigInts({
5021
+ hash: transaction.hash,
5022
+ from: transaction.from,
5023
+ to: transaction.to,
5024
+ nonce: transaction.nonce,
5025
+ data: transaction.data,
5026
+ value: transaction.value,
5027
+ chainId: transaction.chainId,
5028
+ blockHash: transaction.blockHash,
5029
+ blockNumber: transaction.blockNumber,
5030
+ blockTimestamp: block?.timestamp ?? null,
5031
+ blockTimestampIso: block?.timestamp ? new Date(Number(block.timestamp) * 1000).toISOString() : null,
5032
+ }));
5033
+ }
5034
+
5035
+ function sanitizeReceiptEvents(receipt) {
5036
+ return (receipt.logs ?? []).map((log) => normalizeCliOutput(serializeBigInts({
5037
+ address: log.address,
5038
+ blockHash: log.blockHash,
5039
+ blockNumber: log.blockNumber,
5040
+ transactionHash: log.transactionHash,
5041
+ transactionIndex: log.transactionIndex,
5042
+ logIndex: log.index ?? log.logIndex ?? null,
5043
+ topics: log.topics,
5044
+ data: log.data,
5045
+ })));
5046
+ }
5047
+
5048
+ function buildBlockTimestampCache(transactionEvidence) {
5049
+ const cache = {};
5050
+ for (const txRecord of Object.values(transactionEvidence)) {
5051
+ const tx = txRecord.transaction;
5052
+ if (tx?.blockNumber !== null && tx?.blockNumber !== undefined) {
5053
+ cache[Number(tx.blockNumber)] = {
5054
+ timestamp: tx.blockTimestamp ?? null,
5055
+ iso: tx.blockTimestampIso ?? null,
5056
+ };
5057
+ }
5058
+ }
5059
+ return cache;
5060
+ }
5061
+
5062
+ function buildEvidenceNoteRecord({
5063
+ note,
5064
+ walletContext,
5065
+ walletMetadata,
5066
+ context,
5067
+ transactionEvidence,
5068
+ blockTimestampCache,
5069
+ }) {
5070
+ const creationBlockNumber = note.createdAtBlockNumber
5071
+ ?? transactionEvidence[note.createdAtTxHash]?.transaction?.blockNumber
5072
+ ?? null;
5073
+ const spentBlockNumber = note.spentAtBlockNumber
5074
+ ?? transactionEvidence[note.spentAtTxHash]?.transaction?.blockNumber
5075
+ ?? null;
5076
+ const scheme = note.encryptedNoteValue ? unpackEncryptedNoteValue(note.encryptedNoteValue).scheme : null;
5077
+ return normalizeCliOutput({
5078
+ recordType: "note-evidence",
5079
+ recordVersion: 1,
5080
+ noteId: note.commitment,
5081
+ walletScope: {
5082
+ network: walletMetadata.network,
5083
+ chainId: walletContext.wallet.chainId,
5084
+ channelName: walletMetadata.channelName,
5085
+ channelId: walletContext.wallet.channelId,
5086
+ wallet: walletContext.walletName,
5087
+ walletL1Address: walletContext.wallet.l1Address,
5088
+ walletL2Address: walletContext.wallet.l2Address,
5089
+ controller: context.workspace.controller,
5090
+ },
5091
+ plaintext: {
5092
+ owner: note.owner,
5093
+ value: note.value,
5094
+ salt: note.salt,
5095
+ },
5096
+ derived: {
5097
+ commitment: note.commitment,
5098
+ nullifier: note.nullifier,
5099
+ commitmentStorageKey: note.bridgeCommitmentKey,
5100
+ nullifierStorageKey: note.bridgeNullifierKey,
5101
+ },
5102
+ encryptedDelivery: {
5103
+ encryptedNoteValue: note.encryptedNoteValue,
5104
+ saltDerivation: "poseidon(encryptedNoteValue)",
5105
+ scheme: encryptedNoteSchemeLabel(scheme),
5106
+ event: {
5107
+ txHash: note.createdAtTxHash,
5108
+ blockNumber: creationBlockNumber,
5109
+ blockTimestamp: blockTimestampCache[Number(creationBlockNumber)]?.timestamp ?? null,
5110
+ blockTimestampIso: blockTimestampCache[Number(creationBlockNumber)]?.iso ?? null,
5111
+ logIndex: note.createdAtLogIndex,
5112
+ contract: context.workspace.channelManager,
5113
+ },
5114
+ },
5115
+ creation: {
5116
+ txHash: note.createdAtTxHash,
5117
+ blockNumber: creationBlockNumber,
5118
+ blockTimestamp: blockTimestampCache[Number(creationBlockNumber)]?.timestamp ?? null,
5119
+ blockTimestampIso: blockTimestampCache[Number(creationBlockNumber)]?.iso ?? null,
5120
+ function: note.createdByFunction,
5121
+ outputIndex: note.createdOutputIndex,
5122
+ acceptedTransition: acceptedTransitionReference(note.createdAtTxHash),
5123
+ },
5124
+ spend: {
5125
+ status: note.status,
5126
+ txHash: note.spentAtTxHash,
5127
+ blockNumber: spentBlockNumber,
5128
+ blockTimestamp: blockTimestampCache[Number(spentBlockNumber)]?.timestamp ?? null,
5129
+ blockTimestampIso: blockTimestampCache[Number(spentBlockNumber)]?.iso ?? null,
5130
+ function: note.spentByFunction,
5131
+ inputIndex: note.spentInputIndex,
5132
+ acceptedTransition: note.spentAtTxHash ? acceptedTransitionReference(note.spentAtTxHash) : null,
5133
+ },
5134
+ relationshipHints: {
5135
+ direction: note.counterpartyDirection ?? inferEvidenceDirection(note, scheme),
5136
+ counterpartyL2Address: note.counterpartyL2Address,
5137
+ counterpartyL1Address: null,
5138
+ confidence: note.counterpartyConfidence ?? (note.counterpartyL2Address ? "direct-local-metadata" : "unavailable"),
5139
+ },
5140
+ verificationClaims: {
5141
+ commitmentRecomputesFromPlaintext: true,
5142
+ nullifierRecomputesFromPlaintext: true,
5143
+ ownerMatchesWalletL2Address: true,
5144
+ spendingKeyIncluded: false,
5145
+ viewingKeyIncluded: false,
5146
+ walletSecretIncluded: false,
5147
+ fullWalletHistoryRequiredForFinalDisclosure: false,
5148
+ },
5149
+ });
5150
+ }
5151
+
5152
+ function acceptedTransitionReference(txHash) {
5153
+ if (!txHash) {
5154
+ return null;
5155
+ }
5156
+ return {
5157
+ txHash,
5158
+ transactionPath: `transactions/${txHash}.json`,
5159
+ receiptPath: `receipts/${txHash}.json`,
5160
+ eventsPath: `events/${txHash}.json`,
5161
+ proofCalldataLocation: "transactions[].data",
5162
+ localProofArtifactIncluded: false,
5163
+ };
5164
+ }
5165
+
5166
+ function encryptedNoteSchemeLabel(scheme) {
5167
+ if (scheme === ENCRYPTED_NOTE_SCHEME_SELF_MINT) {
5168
+ return "self-mint";
5169
+ }
5170
+ if (scheme === ENCRYPTED_NOTE_SCHEME_TRANSFER) {
5171
+ return "transfer";
5172
+ }
5173
+ return "unknown";
5174
+ }
5175
+
5176
+ function inferEvidenceDirection(note, scheme) {
5177
+ if (scheme === ENCRYPTED_NOTE_SCHEME_SELF_MINT) {
5178
+ return "self-mint";
5179
+ }
5180
+ if (note.spentByFunction?.startsWith("transferNotes")) {
5181
+ return "sent";
5182
+ }
5183
+ if (note.createdByFunction?.startsWith("transferNotes")) {
5184
+ return "received";
5185
+ }
5186
+ return "unknown";
5187
+ }
5188
+
5189
+ function buildEvidenceIndexes(noteRecords) {
5190
+ const indexes = {
5191
+ byCommitment: {},
5192
+ byNullifier: {},
5193
+ byCreationTx: {},
5194
+ bySpendTx: {},
5195
+ byBlockRange: [],
5196
+ byCounterparty: {
5197
+ unavailable: [],
5198
+ },
5199
+ };
5200
+ for (const record of noteRecords) {
5201
+ const pathName = `notes/${record.derived.commitment}.json`;
5202
+ indexes.byCommitment[record.derived.commitment] = pathName;
5203
+ indexes.byNullifier[record.derived.nullifier] = pathName;
5204
+ pushIndexEntry(indexes.byCreationTx, record.creation.txHash, pathName);
5205
+ pushIndexEntry(indexes.bySpendTx, record.spend.txHash, pathName);
5206
+ indexes.byBlockRange.push({
5207
+ commitment: record.derived.commitment,
5208
+ createdAtBlockNumber: record.creation.blockNumber,
5209
+ spentAtBlockNumber: record.spend.blockNumber,
5210
+ path: pathName,
5211
+ });
5212
+ const counterparty = record.relationshipHints.counterpartyL2Address;
5213
+ if (counterparty) {
5214
+ if (!indexes.byCounterparty[counterparty]) {
5215
+ indexes.byCounterparty[counterparty] = { sent: [], received: [], both: [] };
5216
+ }
5217
+ const direction = record.relationshipHints.direction === "received" ? "received" : "sent";
5218
+ indexes.byCounterparty[counterparty][direction].push(pathName);
5219
+ indexes.byCounterparty[counterparty].both.push(pathName);
5220
+ } else {
5221
+ indexes.byCounterparty.unavailable.push(pathName);
5222
+ }
5223
+ }
5224
+ indexes.byBlockRange.sort((left, right) =>
5225
+ Number(left.createdAtBlockNumber ?? Number.MAX_SAFE_INTEGER)
5226
+ - Number(right.createdAtBlockNumber ?? Number.MAX_SAFE_INTEGER));
5227
+ return indexes;
5228
+ }
5229
+
5230
+ function pushIndexEntry(index, key, value) {
5231
+ if (!key) {
5232
+ return;
5233
+ }
5234
+ if (!index[key]) {
5235
+ index[key] = [];
5236
+ }
5237
+ index[key].push(value);
5238
+ }
5239
+
5240
+ function buildEvidenceManifest({
5241
+ outputPath,
5242
+ walletContext,
5243
+ walletMetadata,
5244
+ context,
5245
+ noteRecords,
5246
+ txHashes,
5247
+ }) {
5248
+ return normalizeCliOutput({
5249
+ format: WALLET_EVIDENCE_BUNDLE_FORMAT,
5250
+ formatVersion: WALLET_EVIDENCE_BUNDLE_FORMAT_VERSION,
5251
+ bundleType: "local-full-note-evidence",
5252
+ generatedAt: new Date().toISOString(),
5253
+ outputFileName: path.basename(outputPath),
5254
+ network: walletMetadata.network,
5255
+ chainId: walletContext.wallet.chainId,
5256
+ channelName: walletMetadata.channelName,
5257
+ channelId: walletContext.wallet.channelId,
5258
+ wallet: walletContext.walletName,
5259
+ walletL1Address: walletContext.wallet.l1Address,
5260
+ walletL2Address: walletContext.wallet.l2Address,
5261
+ controller: context.workspace.controller,
5262
+ channelManager: context.workspace.channelManager,
5263
+ bridgeTokenVault: context.workspace.bridgeTokenVault,
5264
+ containsAllLocallyKnownNotes: true,
5265
+ containsNotePlaintext: true,
5266
+ noteCount: noteRecords.length,
5267
+ transactionCount: txHashes.length,
5268
+ intendedUse: "Input for private-state-cli investigator; not a default exchange submission package.",
5269
+ warning: "DO_NOT_SUBMIT_AS_IS unless full wallet-history disclosure is intended.",
5270
+ excludedSecrets: {
5271
+ spendingKey: true,
5272
+ viewingKey: true,
5273
+ walletSecret: true,
5274
+ accountPrivateKey: true,
5275
+ keyFiles: true,
5276
+ },
5277
+ });
5278
+ }
5279
+
5280
+ function addEvidenceJson(archive, archivePath, value) {
5281
+ archive.addFile(archivePath, Buffer.from(`${JSON.stringify(normalizeCliOutput(value), null, 2)}\n`, "utf8"));
5282
+ }
5283
+
5284
+ function assertEvidenceBundleDoesNotContainSecrets({ wallet, payload }) {
5285
+ const serialized = JSON.stringify(payload);
5286
+ const forbiddenValues = [
5287
+ wallet.l2PrivateKey,
5288
+ wallet.noteReceivePrivateKey,
5289
+ ].filter((value) => typeof value === "string" && value.length > 0);
5290
+ for (const value of forbiddenValues) {
5291
+ expect(
5292
+ !serialized.includes(value),
5293
+ "Evidence export refused to write authority-bearing wallet secret material.",
5294
+ );
5295
+ }
5296
+ }
5297
+
5298
+ function uniqueNonNull(values) {
5299
+ return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))];
4566
5300
  }
4567
5301
 
4568
5302
  async function handleTransferNotes({ args, provider }) {
4569
5303
  const { wallet } = loadUnlockedWalletWithMetadata(args);
5304
+ requireWalletViewingCapability(wallet);
5305
+ requireWalletSpendingCapability(wallet);
4570
5306
  const { signer } = restoreWalletParticipant(wallet, provider);
4571
5307
  const preparedContextResult = await loadFreshWalletChannelContext({
4572
5308
  walletContext: wallet,
@@ -4580,7 +5316,7 @@ async function handleTransferNotes({ args, provider }) {
4580
5316
  provider,
4581
5317
  signer,
4582
5318
  progressAction: "wallet transfer-notes",
4583
- preConsumedLogRequests: preparedContextResult.autoRecoveryLogRequests,
5319
+ preConsumedBlockDelta: preparedContextResult.autoRecoveryBlockDelta,
4584
5320
  });
4585
5321
  const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
4586
5322
  const noteIds = parseNoteIdVector(requireArg(args.noteIds, "--note-ids"));
@@ -4604,6 +5340,19 @@ async function handleTransferNotes({ args, provider }) {
4604
5340
  "The sum of --amounts must equal the sum of the selected input note values.",
4605
5341
  );
4606
5342
 
5343
+ const { txSubmitter } = resolveTxSubmitterSigner({
5344
+ args,
5345
+ ownerSigner: signer,
5346
+ provider,
5347
+ });
5348
+ await requireActionImpactAcknowledgement("wallet-transfer-notes", args, {
5349
+ l1Address: txSubmitter.address,
5350
+ l2Address: wallet.wallet.l2Address,
5351
+ noteIds: noteIds.join(", "),
5352
+ amounts: amountInputs.join(", "),
5353
+ channelName: context.workspace.channelName,
5354
+ channelId: context.workspace.channelId,
5355
+ });
4607
5356
  const templatePayload = await buildTransferNotesTemplatePayload({
4608
5357
  context,
4609
5358
  signer,
@@ -4624,6 +5373,9 @@ async function handleTransferNotes({ args, provider }) {
4624
5373
  sourceFunction: templatePayload.method,
4625
5374
  sourceTxHash: execution.receipt.hash,
4626
5375
  bridgeCommitmentKeys: execution.noteLifecycle.outputCommitmentKeys,
5376
+ sourceBlockNumber: execution.receipt.blockNumber,
5377
+ counterpartyL2Addresses: templatePayload.recipientAddresses,
5378
+ counterpartyDirection: "sent",
4627
5379
  });
4628
5380
 
4629
5381
  printJson({
@@ -4754,6 +5506,7 @@ function ensureWallet({
4754
5506
  ensureDir(path.join(walletDir, "operations"));
4755
5507
 
4756
5508
  const wallet = normalizeWallet({
5509
+ walletFormatVersion: WALLET_WORKSPACE_FORMAT_VERSION,
4757
5510
  name: walletName,
4758
5511
  network: channelContext.workspace.network,
4759
5512
  rpcUrl,
@@ -4770,10 +5523,8 @@ function ensureWallet({
4770
5523
  l2AccountingVault: channelContext.workspace.l2AccountingVault,
4771
5524
  liquidBalancesSlot: channelContext.workspace.liquidBalancesSlot,
4772
5525
  l1Address: signerAddress,
4773
- l1PrivateKey: normalizePrivateKey(signerPrivateKey),
4774
5526
  l2Address: l2Identity.l2Address,
4775
- l2PrivateKey: ethers.hexlify(l2Identity.l2PrivateKey),
4776
- l2PublicKey: ethers.hexlify(l2Identity.l2PublicKey),
5527
+ l2PublicKey: l2Identity.l2PublicKey ? ethers.hexlify(l2Identity.l2PublicKey) : null,
4777
5528
  l2DerivationMode: CHANNEL_BOUND_L2_DERIVATION_MODE,
4778
5529
  l2DerivationChannelName: channelContext.workspace.channelName,
4779
5530
  l2StorageKey: storageKey,
@@ -4789,13 +5540,18 @@ function ensureWallet({
4789
5540
  spent: {},
4790
5541
  },
4791
5542
  });
5543
+ if (l2Identity.l2PrivateKey) {
5544
+ wallet.l2PrivateKey = ethers.hexlify(l2Identity.l2PrivateKey);
5545
+ }
5546
+ wallet.noteReceivePrivateKey = normalizePrivateKey(noteReceiveKeyMaterial.privateKey);
4792
5547
 
4793
5548
  const context = {
4794
5549
  walletName,
4795
5550
  walletDir,
4796
5551
  wallet,
4797
- walletSecret,
5552
+ walletSecret: wallet.l2PrivateKey,
4798
5553
  };
5554
+ persistWalletKeys(context);
4799
5555
  persistWallet(context);
4800
5556
  persistWalletMetadata(context);
4801
5557
  return context;
@@ -4811,9 +5567,9 @@ function normalizeWallet(wallet) {
4811
5567
  ...wallet,
4812
5568
  canonicalAssetDecimals: Number(wallet.canonicalAssetDecimals),
4813
5569
  l2Nonce: Number(wallet.l2Nonce),
4814
- l1PrivateKey: normalizePrivateKey(wallet.l1PrivateKey),
4815
- l2PrivateKey: ethers.hexlify(wallet.l2PrivateKey),
4816
- l2PublicKey: ethers.hexlify(wallet.l2PublicKey),
5570
+ l2PrivateKey: wallet.l2PrivateKey ? ethers.hexlify(wallet.l2PrivateKey) : null,
5571
+ l2PublicKey: wallet.l2PublicKey ? ethers.hexlify(wallet.l2PublicKey) : null,
5572
+ noteReceivePrivateKey: wallet.noteReceivePrivateKey ? normalizePrivateKey(wallet.noteReceivePrivateKey) : null,
4817
5573
  noteReceiveDerivationVersion: Number(wallet.noteReceiveDerivationVersion),
4818
5574
  noteReceiveTypedDataMethod: wallet.noteReceiveTypedDataMethod,
4819
5575
  noteReceivePubKeyX: normalizeBytes32Hex(wallet.noteReceivePubKeyX),
@@ -4823,18 +5579,18 @@ function normalizeWallet(wallet) {
4823
5579
  unused: Object.fromEntries(unusedNotes.map((note) => [note.commitment, note])),
4824
5580
  spent: Object.fromEntries(spentNotes.map((note) => [note.nullifier, note])),
4825
5581
  unusedOrder: unusedNotes.map((note) => note.commitment),
4826
- unusedBalance: unusedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n).toString(),
5582
+ unusedBalance: unusedNotes.every((note) => note.value !== null)
5583
+ ? unusedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n).toString()
5584
+ : null,
4827
5585
  },
4828
5586
  };
4829
5587
  }
4830
5588
 
4831
5589
  function assertWalletHasCurrentFormat(wallet, walletName) {
4832
5590
  const requiredKeys = [
5591
+ "walletFormatVersion",
4833
5592
  "canonicalAssetDecimals",
4834
5593
  "l2Nonce",
4835
- "l1PrivateKey",
4836
- "l2PrivateKey",
4837
- "l2PublicKey",
4838
5594
  "noteReceiveDerivationVersion",
4839
5595
  "noteReceiveTypedDataMethod",
4840
5596
  "noteReceivePubKeyX",
@@ -4850,18 +5606,48 @@ function assertWalletHasCurrentFormat(wallet, walletName) {
4850
5606
  wallet.notes && typeof wallet.notes.unused === "object" && typeof wallet.notes.spent === "object",
4851
5607
  `Wallet ${walletName} was not created with the current CLI notes format.`,
4852
5608
  );
5609
+ expect(
5610
+ Number(wallet.walletFormatVersion) === WALLET_WORKSPACE_FORMAT_VERSION,
5611
+ [
5612
+ `Wallet ${walletName} uses unsupported wallet workspace format ${wallet.walletFormatVersion ?? "missing"}.`,
5613
+ "Rebuild the wallet metadata with wallet recover-workspace.",
5614
+ ].join(" "),
5615
+ );
4853
5616
  }
4854
5617
 
4855
5618
  function normalizeTrackedNote(note) {
4856
5619
  return {
4857
- owner: getAddress(note.owner),
4858
- value: ethers.toBigInt(note.value).toString(),
4859
- salt: normalizeBytes32Hex(note.salt),
5620
+ owner: note.owner ? getAddress(note.owner) : null,
5621
+ value: note.value !== undefined && note.value !== null ? ethers.toBigInt(note.value).toString() : null,
5622
+ salt: note.salt ? normalizeBytes32Hex(note.salt) : null,
4860
5623
  commitment: normalizeBytes32Hex(note.commitment),
4861
5624
  nullifier: normalizeBytes32Hex(note.nullifier),
5625
+ encryptedNoteValue: note.encryptedNoteValue ? normalizeEncryptedNoteValueWords(note.encryptedNoteValue) : null,
4862
5626
  status: note.status,
4863
5627
  sourceFunction: note.sourceFunction ?? null,
4864
5628
  sourceTxHash: note.sourceTxHash ?? null,
5629
+ createdAtTxHash: note.createdAtTxHash ?? (note.status === "unused" ? note.sourceTxHash ?? null : null),
5630
+ createdAtBlockNumber: note.createdAtBlockNumber !== undefined && note.createdAtBlockNumber !== null
5631
+ ? Number(note.createdAtBlockNumber)
5632
+ : null,
5633
+ createdAtLogIndex: note.createdAtLogIndex !== undefined && note.createdAtLogIndex !== null
5634
+ ? Number(note.createdAtLogIndex)
5635
+ : null,
5636
+ createdByFunction: note.createdByFunction ?? (note.status === "unused" ? note.sourceFunction ?? null : null),
5637
+ createdOutputIndex: note.createdOutputIndex !== undefined && note.createdOutputIndex !== null
5638
+ ? Number(note.createdOutputIndex)
5639
+ : null,
5640
+ spentAtTxHash: note.spentAtTxHash ?? (note.status === "spent" ? note.sourceTxHash ?? null : null),
5641
+ spentAtBlockNumber: note.spentAtBlockNumber !== undefined && note.spentAtBlockNumber !== null
5642
+ ? Number(note.spentAtBlockNumber)
5643
+ : null,
5644
+ spentByFunction: note.spentByFunction ?? (note.status === "spent" ? note.sourceFunction ?? null : null),
5645
+ spentInputIndex: note.spentInputIndex !== undefined && note.spentInputIndex !== null
5646
+ ? Number(note.spentInputIndex)
5647
+ : null,
5648
+ counterpartyL2Address: note.counterpartyL2Address ? getAddress(note.counterpartyL2Address) : null,
5649
+ counterpartyDirection: note.counterpartyDirection ?? null,
5650
+ counterpartyConfidence: note.counterpartyConfidence ?? null,
4865
5651
  bridgeCommitmentKey: note.bridgeCommitmentKey ? normalizeBytes32Hex(note.bridgeCommitmentKey) : null,
4866
5652
  bridgeNullifierKey: note.bridgeNullifierKey ? normalizeBytes32Hex(note.bridgeNullifierKey) : null,
4867
5653
  };
@@ -4887,15 +5673,28 @@ async function buildWalletNoteBridgeStatus({
4887
5673
  return {
4888
5674
  owner: note.owner,
4889
5675
  valueBaseUnits: note.value,
4890
- valueTokens: ethers.formatUnits(ethers.toBigInt(note.value), canonicalAssetDecimals),
5676
+ valueTokens: note.value === null ? null : ethers.formatUnits(ethers.toBigInt(note.value), canonicalAssetDecimals),
4891
5677
  commitment: note.commitment,
4892
5678
  nullifier: note.nullifier,
5679
+ encryptedNoteValue: note.encryptedNoteValue,
4893
5680
  walletStatus: note.status,
4894
5681
  bridgeCommitmentExists: commitmentExists,
4895
5682
  bridgeNullifierUsed: nullifierUsed,
4896
5683
  walletStatusMatchesBridge: commitmentExists && nullifierUsed === expectedNullifierUsed,
4897
5684
  sourceFunction: note.sourceFunction ?? null,
4898
5685
  sourceTxHash: note.sourceTxHash ?? null,
5686
+ createdAtTxHash: note.createdAtTxHash ?? null,
5687
+ createdAtBlockNumber: note.createdAtBlockNumber ?? null,
5688
+ createdAtLogIndex: note.createdAtLogIndex ?? null,
5689
+ createdByFunction: note.createdByFunction ?? null,
5690
+ createdOutputIndex: note.createdOutputIndex ?? null,
5691
+ spentAtTxHash: note.spentAtTxHash ?? null,
5692
+ spentAtBlockNumber: note.spentAtBlockNumber ?? null,
5693
+ spentByFunction: note.spentByFunction ?? null,
5694
+ spentInputIndex: note.spentInputIndex ?? null,
5695
+ counterpartyL2Address: note.counterpartyL2Address ?? null,
5696
+ counterpartyDirection: note.counterpartyDirection ?? null,
5697
+ counterpartyConfidence: note.counterpartyConfidence ?? null,
4899
5698
  };
4900
5699
  }
4901
5700
 
@@ -4913,8 +5712,8 @@ async function readBooleanStorageValueFromSnapshot({ snapshot, storageAddress, s
4913
5712
  }
4914
5713
 
4915
5714
  function compareNotesByValueDesc(left, right) {
4916
- const leftValue = ethers.toBigInt(left.value);
4917
- const rightValue = ethers.toBigInt(right.value);
5715
+ const leftValue = left.value === null || left.value === undefined ? 0n : ethers.toBigInt(left.value);
5716
+ const rightValue = right.value === null || right.value === undefined ? 0n : ethers.toBigInt(right.value);
4918
5717
  if (leftValue === rightValue) {
4919
5718
  return left.commitment.localeCompare(right.commitment);
4920
5719
  }
@@ -4923,13 +5722,28 @@ function compareNotesByValueDesc(left, right) {
4923
5722
 
4924
5723
  function buildTrackedNote(note, sourceFunction, sourceTxHash, bridgeKeys = {}) {
4925
5724
  const normalizedNote = normalizePlaintextNote(note);
5725
+ const createdTxHash = bridgeKeys.createdAtTxHash ?? sourceTxHash ?? null;
5726
+ const createdFunction = bridgeKeys.createdByFunction ?? sourceFunction ?? null;
4926
5727
  return {
4927
5728
  ...normalizedNote,
4928
5729
  commitment: normalizeBytes32Hex(computeNoteCommitment(normalizedNote)),
4929
5730
  nullifier: normalizeBytes32Hex(computeNullifier(normalizedNote)),
5731
+ encryptedNoteValue: note.encryptedNoteValue ? normalizeEncryptedNoteValueWords(note.encryptedNoteValue) : null,
4930
5732
  status: "unused",
4931
5733
  sourceFunction,
4932
5734
  sourceTxHash,
5735
+ createdAtTxHash: createdTxHash,
5736
+ createdAtBlockNumber: bridgeKeys.createdAtBlockNumber ?? null,
5737
+ createdAtLogIndex: bridgeKeys.createdAtLogIndex ?? null,
5738
+ createdByFunction: createdFunction,
5739
+ createdOutputIndex: bridgeKeys.createdOutputIndex ?? null,
5740
+ spentAtTxHash: bridgeKeys.spentAtTxHash ?? null,
5741
+ spentAtBlockNumber: bridgeKeys.spentAtBlockNumber ?? null,
5742
+ spentByFunction: bridgeKeys.spentByFunction ?? null,
5743
+ spentInputIndex: bridgeKeys.spentInputIndex ?? null,
5744
+ counterpartyL2Address: bridgeKeys.counterpartyL2Address ? getAddress(bridgeKeys.counterpartyL2Address) : null,
5745
+ counterpartyDirection: bridgeKeys.counterpartyDirection ?? null,
5746
+ counterpartyConfidence: bridgeKeys.counterpartyConfidence ?? null,
4933
5747
  bridgeCommitmentKey: bridgeKeys.bridgeCommitmentKey
4934
5748
  ? normalizeBytes32Hex(bridgeKeys.bridgeCommitmentKey)
4935
5749
  : null,
@@ -4944,9 +5758,19 @@ function buildLifecycleTrackedOutputs({
4944
5758
  sourceFunction,
4945
5759
  sourceTxHash,
4946
5760
  bridgeCommitmentKeys,
5761
+ sourceBlockNumber = null,
5762
+ counterpartyL2Addresses = [],
5763
+ counterpartyDirection = null,
4947
5764
  }) {
4948
5765
  return (outputNotes ?? []).map((note, index) => buildTrackedNote(note, sourceFunction, sourceTxHash, {
4949
5766
  bridgeCommitmentKey: bridgeCommitmentKeys?.[index] ?? null,
5767
+ createdAtTxHash: sourceTxHash,
5768
+ createdAtBlockNumber: sourceBlockNumber,
5769
+ createdByFunction: sourceFunction,
5770
+ createdOutputIndex: index,
5771
+ counterpartyL2Address: counterpartyL2Addresses?.[index] ?? null,
5772
+ counterpartyDirection,
5773
+ counterpartyConfidence: counterpartyL2Addresses?.[index] ? "direct-local-metadata" : null,
4950
5774
  }));
4951
5775
  }
4952
5776
 
@@ -4959,13 +5783,11 @@ async function recoverWalletReceivedNotes({
4959
5783
  progressAction = null,
4960
5784
  fromGenesis = false,
4961
5785
  }) {
4962
- const resolvedNoteReceiveKeyMaterial = noteReceiveKeyMaterial ?? await deriveNoteReceiveKeyMaterial({
4963
- signer,
4964
- chainId: context.workspace.chainId,
4965
- channelId: context.workspace.channelId,
4966
- channelName: context.workspace.channelName,
4967
- account: signer.address,
4968
- });
5786
+ const resolvedNoteReceiveKeyMaterial = noteReceiveKeyMaterial ?? {
5787
+ privateKey: walletContext.wallet.noteReceivePrivateKey,
5788
+ noteReceivePubKey: walletNoteReceivePubKey(walletContext),
5789
+ };
5790
+ requireWalletViewingCapability(walletContext);
4969
5791
  const recoveredDeliveryState = await recoverDeliveredNotesFromEventLogs({
4970
5792
  walletContext,
4971
5793
  context,
@@ -5065,12 +5887,22 @@ async function recoverDeliveredNotesFromEventLogs({
5065
5887
  owner: walletContext.wallet.l2Address,
5066
5888
  value: recoveredValue,
5067
5889
  salt: computeEncryptedNoteSalt(encryptedNoteValue),
5890
+ encryptedNoteValue,
5068
5891
  });
5069
5892
  const commitment = normalizeBytes32Hex(computeNoteCommitment(plaintextNote));
5070
5893
  const nullifier = normalizeBytes32Hex(computeNullifier(plaintextNote));
5071
- const trackedNote = buildTrackedNote(plaintextNote, sourceFunction, log.transactionHash, {
5894
+ const trackedNote = buildTrackedNote({
5895
+ ...plaintextNote,
5896
+ encryptedNoteValue,
5897
+ }, sourceFunction, log.transactionHash, {
5072
5898
  bridgeCommitmentKey: derivePrivateStateControllerMappingStorageKey(commitment, commitmentExistsSlot),
5073
5899
  bridgeNullifierKey: derivePrivateStateControllerMappingStorageKey(nullifier, nullifierUsedSlot),
5900
+ createdAtTxHash: log.transactionHash,
5901
+ createdAtBlockNumber: log.blockNumber !== undefined ? Number(log.blockNumber) : null,
5902
+ createdAtLogIndex: log.index ?? log.logIndex ?? null,
5903
+ createdByFunction: sourceFunction,
5904
+ counterpartyDirection: scheme === ENCRYPTED_NOTE_SCHEME_SELF_MINT ? "self-mint" : "received",
5905
+ counterpartyConfidence: scheme === ENCRYPTED_NOTE_SCHEME_SELF_MINT ? "direct-local-metadata" : "unavailable",
5074
5906
  });
5075
5907
  const commitmentExists = await readBooleanStorageValueFromSnapshot({
5076
5908
  snapshot: context.currentSnapshot,
@@ -5142,7 +5974,7 @@ async function ensureWalletNoteReceiveStateCurrent({
5142
5974
  provider,
5143
5975
  signer = null,
5144
5976
  progressAction = null,
5145
- preConsumedLogRequests = 0,
5977
+ preConsumedBlockDelta = 0,
5146
5978
  }) {
5147
5979
  const latestBlock = await provider.getBlockNumber();
5148
5980
  let nextBlock;
@@ -5167,17 +5999,16 @@ async function ensureWalletNoteReceiveStateCurrent({
5167
5999
  nextBlock,
5168
6000
  recoveredWalletWorkspace: false,
5169
6001
  recoveredDeliveryState: null,
5170
- autoRecoveryLogRequests: 0,
6002
+ autoRecoveryBlockDelta: 0,
5171
6003
  };
5172
6004
  }
5173
- const remainingLogRequestBudget = AUTO_RECOVERY_LOG_REQUEST_BUDGET - Math.max(0, Number(preConsumedLogRequests));
5174
- const autoRecoveryLogRequests = assertAutoRecoveryLogScanBudget({
6005
+ const remainingBlockBudget = AUTO_RECOVERY_BLOCK_BUDGET - Math.max(0, Number(preConsumedBlockDelta));
6006
+ const autoRecoveryBlockDelta = assertAutoRecoveryBlockBudget({
5175
6007
  label: `wallet note workspace ${walletContext.walletName}`,
5176
6008
  fromBlock: nextBlock,
5177
6009
  toBlock: latestBlock,
5178
- logScanCount: 1,
5179
6010
  recoveryCommand: `wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT>`,
5180
- logRequestBudget: remainingLogRequestBudget,
6011
+ blockBudget: remainingBlockBudget,
5181
6012
  });
5182
6013
 
5183
6014
  const resolvedSigner = signer ?? restoreWalletParticipant(walletContext, provider).signer;
@@ -5205,7 +6036,7 @@ async function ensureWalletNoteReceiveStateCurrent({
5205
6036
  ...freshness,
5206
6037
  recoveredWalletWorkspace: true,
5207
6038
  recoveredDeliveryState,
5208
- autoRecoveryLogRequests,
6039
+ autoRecoveryBlockDelta,
5209
6040
  };
5210
6041
  } catch (postRecoveryError) {
5211
6042
  throw new Error([
@@ -5422,7 +6253,7 @@ function snapshotRootForAddress(snapshot, storageAddress) {
5422
6253
  return snapshot.stateRoots[addressIndex];
5423
6254
  }
5424
6255
 
5425
- function applyNoteLifecycleToWallet(walletContext, lifecycle, sourceFunction, sourceTxHash) {
6256
+ function applyNoteLifecycleToWallet(walletContext, lifecycle, sourceFunction, sourceTxHash, sourceBlockNumber = null) {
5426
6257
  for (const [index, inputNote] of lifecycle.inputs.entries()) {
5427
6258
  const trackedInput = buildTrackedNote(inputNote, sourceFunction, sourceTxHash);
5428
6259
  const existingUnusedNote = walletContext.wallet.notes.unused[trackedInput.commitment];
@@ -5436,12 +6267,26 @@ function applyNoteLifecycleToWallet(walletContext, lifecycle, sourceFunction, so
5436
6267
  sourceFunction,
5437
6268
  sourceTxHash,
5438
6269
  bridgeNullifierKey: lifecycle.inputNullifierKeys?.[index] ?? existingUnusedNote.bridgeNullifierKey ?? null,
6270
+ spentAtTxHash: sourceTxHash,
6271
+ spentAtBlockNumber: sourceBlockNumber,
6272
+ spentByFunction: sourceFunction,
6273
+ spentInputIndex: index,
6274
+ counterpartyL2Address: lifecycle.spendCounterpartyL2Address ?? existingUnusedNote.counterpartyL2Address ?? null,
6275
+ counterpartyDirection: lifecycle.spendCounterpartyDirection ?? existingUnusedNote.counterpartyDirection ?? null,
6276
+ counterpartyConfidence: lifecycle.spendCounterpartyConfidence ?? existingUnusedNote.counterpartyConfidence ?? null,
5439
6277
  };
5440
6278
  }
5441
6279
 
5442
6280
  for (const [index, outputNote] of lifecycle.outputs.entries()) {
5443
6281
  const trackedOutput = buildTrackedNote(outputNote, sourceFunction, sourceTxHash, {
5444
6282
  bridgeCommitmentKey: lifecycle.outputCommitmentKeys?.[index] ?? null,
6283
+ createdAtTxHash: sourceTxHash,
6284
+ createdAtBlockNumber: sourceBlockNumber,
6285
+ createdByFunction: sourceFunction,
6286
+ createdOutputIndex: index,
6287
+ counterpartyL2Address: lifecycle.outputCounterpartyL2Addresses?.[index] ?? null,
6288
+ counterpartyDirection: lifecycle.outputCounterpartyDirection ?? null,
6289
+ counterpartyConfidence: lifecycle.outputCounterpartyL2Addresses?.[index] ? "direct-local-metadata" : null,
5445
6290
  });
5446
6291
  if (trackedOutput.owner !== walletContext.wallet.l2Address) {
5447
6292
  continue;
@@ -5526,6 +6371,7 @@ function buildMintEncryptedOutputs({ wallet, values }) {
5526
6371
  owner: wallet.wallet.l2Address,
5527
6372
  value: ethers.toBigInt(value).toString(),
5528
6373
  salt: computeEncryptedNoteSalt(encryptedNoteValue),
6374
+ encryptedNoteValue,
5529
6375
  });
5530
6376
  }
5531
6377
  return {
@@ -5583,6 +6429,7 @@ async function buildTransferNotesTemplatePayload({
5583
6429
  owner: recipient,
5584
6430
  value: ethers.toBigInt(outputAmounts[index]).toString(),
5585
6431
  salt,
6432
+ encryptedNoteValue,
5586
6433
  });
5587
6434
  }
5588
6435
  return {
@@ -5591,6 +6438,7 @@ async function buildTransferNotesTemplatePayload({
5591
6438
  args: [transferOutputs, inputNotes],
5592
6439
  lifecycleInputs: inputNotes,
5593
6440
  lifecycleOutputs,
6441
+ recipientAddresses,
5594
6442
  };
5595
6443
  }
5596
6444
 
@@ -5611,6 +6459,10 @@ function loadWalletUnusedInputNotes(walletContext, noteIds) {
5611
6459
  return noteIds.map((noteId) => {
5612
6460
  const trackedNote = walletContext.wallet.notes.unused[noteId];
5613
6461
  expect(trackedNote, `Unknown unused note commitment: ${noteId}.`);
6462
+ expect(
6463
+ trackedNote.owner && trackedNote.value !== null && trackedNote.salt,
6464
+ `Note ${noteId} is encrypted-only. Import the wallet viewing key before spending it.`,
6465
+ );
5614
6466
  return normalizePlaintextNote(trackedNote);
5615
6467
  });
5616
6468
  }
@@ -5721,7 +6573,7 @@ async function loadFreshWalletChannelContext({
5721
6573
  network: resolveCliNetwork(contextResult.context.workspace.network),
5722
6574
  usingWorkspaceCache: !contextResult.recoveredWorkspace,
5723
6575
  recoveredWorkspace: contextResult.recoveredWorkspace,
5724
- autoRecoveryLogRequests: contextResult.autoRecoveryLogRequests,
6576
+ autoRecoveryBlockDelta: contextResult.autoRecoveryBlockDelta,
5725
6577
  };
5726
6578
  }
5727
6579
 
@@ -5753,7 +6605,7 @@ async function loadFreshChannelWorkspaceContextResult({
5753
6605
  return {
5754
6606
  context,
5755
6607
  recoveredWorkspace: false,
5756
- autoRecoveryLogRequests: 0,
6608
+ autoRecoveryBlockDelta: 0,
5757
6609
  };
5758
6610
  } catch (error) {
5759
6611
  const recovery = await recoverChannelWorkspaceFromIndexOnly({
@@ -5766,7 +6618,7 @@ async function loadFreshChannelWorkspaceContextResult({
5766
6618
  return {
5767
6619
  context: recovery.context,
5768
6620
  recoveredWorkspace: true,
5769
- autoRecoveryLogRequests: recovery.autoRecoveryLogRequests,
6621
+ autoRecoveryBlockDelta: recovery.autoRecoveryBlockDelta,
5770
6622
  };
5771
6623
  }
5772
6624
  }
@@ -5790,7 +6642,7 @@ async function recoverChannelWorkspaceFromIndexOnly({
5790
6642
  if (readiness.alreadyCurrent) {
5791
6643
  return {
5792
6644
  context: await loadWorkspaceContext(channelName, networkName, provider),
5793
- autoRecoveryLogRequests: 0,
6645
+ autoRecoveryBlockDelta: 0,
5794
6646
  };
5795
6647
  }
5796
6648
  try {
@@ -5830,7 +6682,7 @@ async function recoverChannelWorkspaceFromIndexOnly({
5830
6682
  }
5831
6683
  return {
5832
6684
  context,
5833
- autoRecoveryLogRequests: readiness.autoRecoveryLogRequests,
6685
+ autoRecoveryBlockDelta: readiness.autoRecoveryBlockDelta,
5834
6686
  };
5835
6687
  }
5836
6688
 
@@ -5886,14 +6738,13 @@ async function requireChannelWorkspaceRecoveryIndexForAutoRefresh({
5886
6738
  if (Number(recoveryIndex.nextBlock) > Number(latestBlock)) {
5887
6739
  fail(`Channel workspace recovery index has already scanned through block ${recoveryIndex.nextBlock - 1}, but the local snapshot is not current.`);
5888
6740
  }
5889
- const autoRecoveryLogRequests = assertAutoRecoveryLogScanBudget({
6741
+ const autoRecoveryBlockDelta = assertAutoRecoveryBlockBudget({
5890
6742
  label: `channel workspace ${channelName} on ${networkName}`,
5891
6743
  fromBlock: recoveryIndex.nextBlock,
5892
6744
  toBlock: latestBlock,
5893
- logScanCount: 2,
5894
6745
  recoveryCommand: `channel recover-workspace --channel-name ${channelName} --network ${networkName}`,
5895
6746
  });
5896
- return { alreadyCurrent: false, autoRecoveryLogRequests };
6747
+ return { alreadyCurrent: false, autoRecoveryBlockDelta };
5897
6748
  }
5898
6749
 
5899
6750
  async function refreshPersistedWorkspaceAfterLocalTransaction({
@@ -5960,6 +6811,7 @@ async function executeWalletDirectTemplateCommand({
5960
6811
  }) {
5961
6812
  emitProgress(operationName, "loading");
5962
6813
  const { signer, l2Identity } = restoreWalletParticipant(wallet, provider);
6814
+ requireWalletSpendingCapability(wallet);
5963
6815
  const {
5964
6816
  txSubmitter,
5965
6817
  source: txSubmitterSource,
@@ -6101,7 +6953,16 @@ async function executeWalletTemplateSend({
6101
6953
 
6102
6954
  emitProgress(operationName, "persisting");
6103
6955
  wallet.wallet.l2Nonce = nonce + 1;
6104
- applyNoteLifecycleToWallet(wallet, noteLifecycle, functionName, receipt.hash);
6956
+ if (functionName.startsWith("transferNotes")) {
6957
+ noteLifecycle.spendCounterpartyL2Address = templatePayload.recipientAddresses?.[0] ?? null;
6958
+ noteLifecycle.spendCounterpartyDirection = "sent";
6959
+ noteLifecycle.spendCounterpartyConfidence = noteLifecycle.spendCounterpartyL2Address
6960
+ ? "direct-local-metadata"
6961
+ : null;
6962
+ noteLifecycle.outputCounterpartyL2Addresses = templatePayload.recipientAddresses ?? [];
6963
+ noteLifecycle.outputCounterpartyDirection = "sent";
6964
+ }
6965
+ applyNoteLifecycleToWallet(wallet, noteLifecycle, functionName, receipt.hash, receipt.blockNumber);
6105
6966
  context.currentSnapshot = nextSnapshot;
6106
6967
  persistWallet(wallet);
6107
6968
  await refreshPersistedWorkspaceAfterLocalTransaction({
@@ -6110,7 +6971,7 @@ async function executeWalletTemplateSend({
6110
6971
  receipt,
6111
6972
  progressAction: operationName,
6112
6973
  });
6113
- sealWalletOperationDir(operationDir, wallet.walletSecret);
6974
+ sealWalletOperationDir(operationDir, walletOperationSealSecret(wallet));
6114
6975
 
6115
6976
  return {
6116
6977
  wallet,
@@ -6198,34 +7059,54 @@ async function loadJoinChannelContext({ args, network, provider }) {
6198
7059
  };
6199
7060
  }
6200
7061
 
6201
- function loadWallet(walletName, walletSecret, networkName) {
7062
+ function loadWallet(walletName, networkName) {
6202
7063
  const normalizedWalletName = requireWalletName({ wallet: walletName });
6203
7064
  const normalizedNetworkName = requireNetworkName({ network: networkName });
6204
7065
  const walletDir = walletPath(normalizedWalletName, normalizedNetworkName);
6205
7066
  if (!walletConfigExists(walletDir)) {
6206
7067
  throw cliError(CLI_ERROR_CODES.UNKNOWN_WALLET, `Unknown wallet: ${normalizedWalletName} on ${normalizedNetworkName}.`);
6207
7068
  }
6208
- const rawWallet = readEncryptedWalletJson(walletConfigPath(walletDir), walletSecret);
7069
+ const rawWallet = readJson(walletNotesMetadataPath(walletDir));
7070
+ const spendingKey = readWalletKeySecretIfExists({
7071
+ networkName: normalizedNetworkName,
7072
+ walletName: normalizedWalletName,
7073
+ keyKind: "spending",
7074
+ });
7075
+ const viewingKey = readWalletKeySecretIfExists({
7076
+ networkName: normalizedNetworkName,
7077
+ walletName: normalizedWalletName,
7078
+ keyKind: "viewing",
7079
+ });
7080
+ if (spendingKey) {
7081
+ rawWallet.l2PrivateKey = spendingKey.privateKey;
7082
+ rawWallet.l2PublicKey = spendingKey.metadata?.l2PublicKey ?? rawWallet.l2PublicKey;
7083
+ }
7084
+ if (viewingKey) {
7085
+ rawWallet.noteReceivePrivateKey = viewingKey.privateKey;
7086
+ }
6209
7087
  assertWalletHasRequiredKeys(rawWallet, normalizedWalletName);
6210
7088
  const wallet = normalizeWallet(rawWallet);
6211
7089
  assertWalletUsesChannelBoundDerivation(wallet, normalizedWalletName);
6212
- const restoredIdentity = restoreParticipantIdentityFromWallet(wallet);
6213
- expect(
6214
- wallet.l2Address === restoredIdentity.l2Address,
6215
- `Wallet ${normalizedWalletName} is internally inconsistent: stored keys do not match the stored L2 address.`,
6216
- );
7090
+ if (wallet.l2PrivateKey) {
7091
+ const restoredIdentity = restoreParticipantIdentityFromWallet(wallet);
7092
+ expect(
7093
+ wallet.l2Address === restoredIdentity.l2Address,
7094
+ `Wallet ${normalizedWalletName} is internally inconsistent: stored keys do not match the stored L2 address.`,
7095
+ );
7096
+ }
7097
+ hydrateWalletNotesWithViewingKey(wallet);
6217
7098
  const context = {
6218
7099
  walletName: normalizedWalletName,
6219
7100
  walletDir,
6220
7101
  wallet,
6221
- walletSecret,
7102
+ walletSecret: wallet.l2PrivateKey ?? wallet.noteReceivePrivateKey ?? null,
6222
7103
  };
6223
7104
  return context;
6224
7105
  }
6225
7106
 
6226
7107
  function loadUnlockedWalletWithMetadata(args) {
6227
7108
  const networkName = requireNetworkName(args);
6228
- const wallet = loadWallet(requireWalletName(args), requireWalletSecret(args), networkName);
7109
+ const wallet = loadWallet(requireWalletName(args), networkName);
6229
7110
  const walletMetadata = loadWalletMetadata(wallet.walletName, networkName);
6230
7111
  assertWalletMatchesMetadata(wallet, walletMetadata);
6231
7112
  expect(
@@ -6241,19 +7122,71 @@ function loadUnlockedWalletWithMetadata(args) {
6241
7122
  };
6242
7123
  }
6243
7124
 
7125
+ function readWalletKeySecretIfExists({ networkName, walletName, keyKind }) {
7126
+ const secretPath = keyKind === "spending"
7127
+ ? walletSpendingKeySecretPath(networkName, walletName)
7128
+ : walletViewingKeySecretPath(networkName, walletName);
7129
+ if (!fs.existsSync(secretPath)) {
7130
+ return null;
7131
+ }
7132
+ const payload = JSON.parse(readSecretFile(secretPath, `${keyKind} key`));
7133
+ validateWalletKeyPayload(payload, keyKind);
7134
+ return payload;
7135
+ }
7136
+
7137
+ function validateWalletKeyPayload(payload, keyKind) {
7138
+ expect(payload?.format === WALLET_KEY_EXPORT_FORMAT, `Invalid ${keyKind} key file format.`);
7139
+ expect(Number(payload.formatVersion) === WALLET_EXPORT_FORMAT_VERSION, `Unsupported ${keyKind} key file version.`);
7140
+ expect(payload.keyKind === keyKind, `Expected ${keyKind} key file, received ${payload.keyKind}.`);
7141
+ expect(typeof payload.privateKey === "string" && payload.privateKey.length > 0, `Missing ${keyKind} private key.`);
7142
+ expect(payload.metadata && typeof payload.metadata === "object", `Missing ${keyKind} key metadata.`);
7143
+ }
7144
+
7145
+ function hydrateWalletNotesWithViewingKey(wallet) {
7146
+ if (!wallet.noteReceivePrivateKey) {
7147
+ return;
7148
+ }
7149
+ const noteGroups = [wallet.notes?.unused ?? {}, wallet.notes?.spent ?? {}];
7150
+ for (const notes of noteGroups) {
7151
+ for (const note of Object.values(notes)) {
7152
+ if (!note.encryptedNoteValue || note.value !== null) {
7153
+ continue;
7154
+ }
7155
+ try {
7156
+ const { scheme } = unpackEncryptedNoteValue(note.encryptedNoteValue);
7157
+ let value;
7158
+ if (scheme === ENCRYPTED_NOTE_SCHEME_TRANSFER) {
7159
+ value = decryptEncryptedNoteValue({
7160
+ encryptedValue: note.encryptedNoteValue,
7161
+ noteReceivePrivateKey: wallet.noteReceivePrivateKey,
7162
+ chainId: wallet.chainId,
7163
+ channelId: wallet.channelId,
7164
+ owner: wallet.l2Address,
7165
+ });
7166
+ } else if (scheme === ENCRYPTED_NOTE_SCHEME_SELF_MINT) {
7167
+ value = decryptMintEncryptedNoteValue({
7168
+ encryptedValue: note.encryptedNoteValue,
7169
+ noteReceivePrivateKey: wallet.noteReceivePrivateKey,
7170
+ chainId: wallet.chainId,
7171
+ channelId: wallet.channelId,
7172
+ owner: wallet.l2Address,
7173
+ });
7174
+ } else {
7175
+ continue;
7176
+ }
7177
+ note.owner = wallet.l2Address;
7178
+ note.value = ethers.toBigInt(value).toString();
7179
+ note.salt = computeEncryptedNoteSalt(note.encryptedNoteValue);
7180
+ } catch {
7181
+ // Keep encrypted-only note records readable even when the local viewing key cannot decrypt them.
7182
+ }
7183
+ }
7184
+ }
7185
+ wallet.notes = normalizeWallet(wallet).notes;
7186
+ }
7187
+
6244
7188
  function assertWalletHasRequiredKeys(wallet, walletName) {
6245
- expect(
6246
- typeof wallet.l1PrivateKey === "string" && wallet.l1PrivateKey.length > 0,
6247
- `Wallet ${walletName} is missing the stored L1 private key.`,
6248
- );
6249
- expect(
6250
- typeof wallet.l2PrivateKey === "string" && wallet.l2PrivateKey.length > 0,
6251
- `Wallet ${walletName} is missing the stored L2 private key.`,
6252
- );
6253
- expect(
6254
- typeof wallet.l2PublicKey === "string" && wallet.l2PublicKey.length > 0,
6255
- `Wallet ${walletName} is missing the stored L2 public key.`,
6256
- );
7189
+ expect(wallet.walletFormatVersion !== undefined, `Wallet ${walletName} is missing walletFormatVersion.`);
6257
7190
  }
6258
7191
 
6259
7192
  function assertWalletUsesChannelBoundDerivation(wallet, walletName) {
@@ -6274,6 +7207,13 @@ function assertWalletUsesChannelBoundDerivation(wallet, walletName) {
6274
7207
  }
6275
7208
 
6276
7209
  function restoreParticipantIdentityFromWallet(wallet) {
7210
+ if (!wallet.l2PrivateKey) {
7211
+ return {
7212
+ l2PrivateKey: null,
7213
+ l2PublicKey: wallet.l2PublicKey ? Uint8Array.from(ethers.getBytes(wallet.l2PublicKey)) : null,
7214
+ l2Address: getAddress(wallet.l2Address),
7215
+ };
7216
+ }
6277
7217
  const l2PrivateKey = Uint8Array.from(ethers.getBytes(wallet.l2PrivateKey));
6278
7218
  const l2PublicKey = Uint8Array.from(ethers.getBytes(wallet.l2PublicKey));
6279
7219
  const l2Address = getAddress(fromEdwardsToAddress(l2PublicKey).toString());
@@ -6285,7 +7225,14 @@ function restoreParticipantIdentityFromWallet(wallet) {
6285
7225
  }
6286
7226
 
6287
7227
  function restoreWalletSigner(walletContext, provider) {
6288
- return new Wallet(normalizePrivateKey(walletContext.wallet.l1PrivateKey), provider);
7228
+ const privateKey = findAccountPrivateKeyForAddress(walletContext.wallet.network, walletContext.wallet.l1Address);
7229
+ if (privateKey) {
7230
+ return new Wallet(privateKey, provider);
7231
+ }
7232
+ return {
7233
+ address: getAddress(walletContext.wallet.l1Address),
7234
+ provider,
7235
+ };
6289
7236
  }
6290
7237
 
6291
7238
  function restoreWalletParticipant(walletContext, provider) {
@@ -6295,6 +7242,75 @@ function restoreWalletParticipant(walletContext, provider) {
6295
7242
  };
6296
7243
  }
6297
7244
 
7245
+ function requireWalletOwnerSigner(walletContext, provider) {
7246
+ const signer = restoreWalletSigner(walletContext, provider);
7247
+ expect(
7248
+ typeof signer.privateKey === "string",
7249
+ [
7250
+ `Missing local account secret for wallet owner ${walletContext.wallet.l1Address}.`,
7251
+ "Import the matching account secret or use a command-specific transaction submitter where supported.",
7252
+ ].join(" "),
7253
+ );
7254
+ return signer;
7255
+ }
7256
+
7257
+ function requireWalletSpendingCapability(walletContext) {
7258
+ expect(
7259
+ walletContext.wallet.l2PrivateKey,
7260
+ [
7261
+ `Wallet ${walletContext.walletName} is missing its spending key.`,
7262
+ "Import it with wallet import spending-key before commands that spend notes or change L2 channel accounting state.",
7263
+ ].join(" "),
7264
+ );
7265
+ }
7266
+
7267
+ function requireWalletViewingCapability(walletContext) {
7268
+ expect(
7269
+ walletContext.wallet.noteReceivePrivateKey,
7270
+ [
7271
+ `Wallet ${walletContext.walletName} is missing its viewing key.`,
7272
+ "Import it with wallet import viewing-key before commands that decrypt or refresh received notes.",
7273
+ ].join(" "),
7274
+ );
7275
+ }
7276
+
7277
+ function walletOperationSealSecret(walletContext) {
7278
+ const secret = walletContext.wallet.l2PrivateKey
7279
+ ?? walletContext.wallet.noteReceivePrivateKey
7280
+ ?? findAccountPrivateKeyForAddress(walletContext.wallet.network, walletContext.wallet.l1Address);
7281
+ expect(
7282
+ secret,
7283
+ `Wallet ${walletContext.walletName} needs a local key to seal operation artifacts.`,
7284
+ );
7285
+ return secret;
7286
+ }
7287
+
7288
+ function findAccountPrivateKeyForAddress(networkName, l1Address) {
7289
+ const accountsRoot = path.join(secretRoot, requireNetworkName({ network: networkName }), "accounts");
7290
+ if (!fs.existsSync(accountsRoot)) {
7291
+ return null;
7292
+ }
7293
+ for (const entry of fs.readdirSync(accountsRoot, { withFileTypes: true })) {
7294
+ if (!entry.isDirectory()) {
7295
+ continue;
7296
+ }
7297
+ const privateKeyPath = path.join(accountsRoot, entry.name, "private-key");
7298
+ if (!fs.existsSync(privateKeyPath)) {
7299
+ continue;
7300
+ }
7301
+ try {
7302
+ const privateKey = normalizePrivateKey(readSecretFile(privateKeyPath, "--account"));
7303
+ const signer = new Wallet(privateKey);
7304
+ if (ethers.toBigInt(getAddress(signer.address)) === ethers.toBigInt(getAddress(l1Address))) {
7305
+ return privateKey;
7306
+ }
7307
+ } catch {
7308
+ continue;
7309
+ }
7310
+ }
7311
+ return null;
7312
+ }
7313
+
6298
7314
  function loadBridgeResources({ chainId }) {
6299
7315
  const bridgeDeploymentPath = defaultBridgeDeploymentPath(chainId);
6300
7316
  const bridgeDeployment = readJson(bridgeDeploymentPath);
@@ -6316,7 +7332,7 @@ function loadWalletMetadata(walletName, networkName) {
6316
7332
  if (!walletConfigExists(walletDir)) {
6317
7333
  throw cliError(CLI_ERROR_CODES.UNKNOWN_WALLET, `Unknown wallet: ${normalizedWalletName} on ${normalizedNetworkName}.`);
6318
7334
  }
6319
- const metadataPath = walletMetadataPath(walletDir);
7335
+ const metadataPath = walletNotesMetadataPath(walletDir);
6320
7336
  if (!fs.existsSync(metadataPath)) {
6321
7337
  throw new Error(`Wallet ${normalizedWalletName} is missing unencrypted metadata at ${metadataPath}.`);
6322
7338
  }
@@ -6345,14 +7361,14 @@ function assertWalletMatchesMetadata(walletContext, walletMetadata) {
6345
7361
  walletContext.wallet.network === walletMetadata.network,
6346
7362
  [
6347
7363
  `Wallet ${walletContext.walletName} metadata network (${walletMetadata.network}) does not match`,
6348
- `the encrypted wallet network (${walletContext.wallet.network}).`,
7364
+ `the wallet note metadata network (${walletContext.wallet.network}).`,
6349
7365
  ].join(" "),
6350
7366
  );
6351
7367
  expect(
6352
7368
  walletContext.wallet.channelName === walletMetadata.channelName,
6353
7369
  [
6354
7370
  `Wallet ${walletContext.walletName} metadata channelName (${walletMetadata.channelName}) does not match`,
6355
- `the encrypted wallet channel (${walletContext.wallet.channelName}).`,
7371
+ `the wallet note metadata channel (${walletContext.wallet.channelName}).`,
6356
7372
  ].join(" "),
6357
7373
  );
6358
7374
  }
@@ -7416,12 +8432,7 @@ async function fetchLogsChunked(provider, {
7416
8432
  return aggregatedLogs;
7417
8433
  }
7418
8434
 
7419
- function estimateLogScanRequestCount({
7420
- fromBlock,
7421
- toBlock,
7422
- logScanCount = 1,
7423
- chunkSize = DEFAULT_LOG_CHUNK_SIZE,
7424
- }) {
8435
+ function recoveryBlockDelta({ fromBlock, toBlock }) {
7425
8436
  const normalizedFromBlock = Number(fromBlock);
7426
8437
  const normalizedToBlock = Number(toBlock);
7427
8438
  if (!Number.isInteger(normalizedFromBlock) || !Number.isInteger(normalizedToBlock)) {
@@ -7430,38 +8441,27 @@ function estimateLogScanRequestCount({
7430
8441
  if (normalizedFromBlock > normalizedToBlock) {
7431
8442
  return 0;
7432
8443
  }
7433
- const totalBlocks = normalizedToBlock - normalizedFromBlock + 1;
7434
- return Math.ceil(totalBlocks / Math.max(1, Number(chunkSize))) * Math.max(1, Number(logScanCount));
8444
+ return normalizedToBlock - normalizedFromBlock + 1;
7435
8445
  }
7436
8446
 
7437
- function assertAutoRecoveryLogScanBudget({
8447
+ function assertAutoRecoveryBlockBudget({
7438
8448
  label,
7439
8449
  fromBlock,
7440
8450
  toBlock,
7441
- logScanCount,
7442
8451
  recoveryCommand,
7443
- logRequestBudget = AUTO_RECOVERY_LOG_REQUEST_BUDGET,
8452
+ blockBudget = AUTO_RECOVERY_BLOCK_BUDGET,
7444
8453
  }) {
7445
- const estimatedRequests = estimateLogScanRequestCount({
7446
- fromBlock,
7447
- toBlock,
7448
- logScanCount,
7449
- });
7450
- const normalizedBudget = Math.max(0, Number(logRequestBudget));
7451
- if (estimatedRequests <= normalizedBudget) {
7452
- return estimatedRequests;
8454
+ const blockDelta = recoveryBlockDelta({ fromBlock, toBlock });
8455
+ const normalizedBudget = Math.max(0, Number(blockBudget));
8456
+ if (blockDelta <= normalizedBudget) {
8457
+ return blockDelta;
7453
8458
  }
7454
8459
  const normalizedFromBlock = Number(fromBlock);
7455
8460
  const normalizedToBlock = Number(toBlock);
7456
- const totalBlocks = normalizedFromBlock <= normalizedToBlock
7457
- ? normalizedToBlock - normalizedFromBlock + 1
7458
- : 0;
7459
- const estimatedSeconds = estimatedRequests / DEFAULT_LOG_REQUESTS_PER_SECOND;
7460
8461
  throw new Error([
7461
- `Automatic recovery for ${label} would exceed the ${AUTO_RECOVERY_TIME_BUDGET_SECONDS}s pre-command budget.`,
7462
- `Recovery delta is ${totalBlocks} blocks from ${normalizedFromBlock} to ${normalizedToBlock}.`,
7463
- `Estimated log requests: ${estimatedRequests}; remaining budget: ${normalizedBudget} of ${AUTO_RECOVERY_LOG_REQUEST_BUDGET} at ${DEFAULT_LOG_REQUESTS_PER_SECOND}/s.`,
7464
- `Estimated minimum scan time: ${estimatedSeconds.toFixed(1)}s.`,
8462
+ `Automatic recovery for ${label} would exceed the ${AUTO_RECOVERY_BLOCK_BUDGET}-block pre-command budget.`,
8463
+ `Recovery delta is ${blockDelta} blocks from ${normalizedFromBlock} to ${normalizedToBlock}.`,
8464
+ `Remaining automatic recovery budget is ${normalizedBudget} blocks.`,
7465
8465
  `Run ${recoveryCommand} first; add --from-genesis only if the saved recovery index is unusable.`,
7466
8466
  ].join(" "));
7467
8467
  }
@@ -7554,6 +8554,7 @@ const OUTPUT_BYTES32_SCALAR_KEYS = new Set([
7554
8554
  "commitment",
7555
8555
  "currentRootVectorHash",
7556
8556
  "currentUserKey",
8557
+ "createdAtTxHash",
7557
8558
  "emittedRootVectorHash",
7558
8559
  "ephemeralPubKeyX",
7559
8560
  "hash",
@@ -7567,6 +8568,7 @@ const OUTPUT_BYTES32_SCALAR_KEYS = new Set([
7567
8568
  "rootVectorHash",
7568
8569
  "salt",
7569
8570
  "sourceTxHash",
8571
+ "spentAtTxHash",
7570
8572
  "topic0",
7571
8573
  "transactionHash",
7572
8574
  "txHash",
@@ -7763,6 +8765,13 @@ function parseArgs(argv) {
7763
8765
  && parsed.positional[1]
7764
8766
  ) {
7765
8767
  parsed.command = `${parsed.command}-${parsed.positional[1]}`;
8768
+ if (
8769
+ parsed.positional[0] === "wallet"
8770
+ && (parsed.positional[1] === "export" || parsed.positional[1] === "import")
8771
+ && parsed.positional[2]
8772
+ ) {
8773
+ parsed.command = `${parsed.command}-${parsed.positional[2]}`;
8774
+ }
7766
8775
  parsed.positional = [parsed.command];
7767
8776
  }
7768
8777
  return parsed;
@@ -7792,18 +8801,6 @@ function parseTokenAmount(value, decimals) {
7792
8801
  }
7793
8802
  }
7794
8803
 
7795
- function requireWalletSecret(args) {
7796
- if (args.wallet !== undefined && args.network !== undefined) {
7797
- return resolveWalletSecretForName({
7798
- networkName: requireNetworkName(args),
7799
- walletName: requireWalletName(args),
7800
- });
7801
- }
7802
- throw new Error(
7803
- "Missing --wallet and --network. Wallet commands use the wallet-local default secret file.",
7804
- );
7805
- }
7806
-
7807
8804
  function requireArg(value, label) {
7808
8805
  if (value === undefined || value === null || value === "") {
7809
8806
  throw new Error(`Missing ${label}.`);
@@ -7868,6 +8865,13 @@ function requireL1Signer(args, provider) {
7868
8865
 
7869
8866
  function resolveTxSubmitterSigner({ args, ownerSigner, provider }) {
7870
8867
  if (args.txSubmitter === undefined) {
8868
+ expect(
8869
+ typeof ownerSigner.privateKey === "string",
8870
+ [
8871
+ `Missing local account secret for wallet owner ${ownerSigner.address}.`,
8872
+ "Pass --tx-submitter <ACCOUNT> or import the matching local account secret.",
8873
+ ].join(" "),
8874
+ );
7871
8875
  return {
7872
8876
  txSubmitter: ownerSigner,
7873
8877
  source: "wallet-owner",
@@ -7902,39 +8906,11 @@ function resolveStandalonePrivateKeySource(args) {
7902
8906
  ));
7903
8907
  }
7904
8908
 
7905
- function resolveWalletSecretForName({ networkName, walletName }) {
7906
- return resolveWalletDefaultSecret(networkName, walletName);
7907
- }
7908
-
7909
- function resolvedWalletSecretSource(args) {
7910
- if (args.walletSecretPath !== undefined) return "wallet-secret-path";
7911
- return "wallet-default";
7912
- }
7913
-
7914
- function resolvedWalletSecretFile(networkName, walletName) {
7915
- return walletSecretPath(networkName, walletName);
7916
- }
7917
-
7918
- function resolveWalletDefaultSecret(networkName, walletName) {
7919
- const secretPath = walletSecretPath(networkName, walletName);
7920
- if (!fs.existsSync(secretPath)) {
7921
- throw cliError(
7922
- CLI_ERROR_CODES.MISSING_WALLET_SECRET,
7923
- [
7924
- `Missing wallet default secret file: ${secretPath}.`,
7925
- "Run channel join with --wallet-secret-path before wallet commands.",
7926
- ].join(" "),
7927
- );
7928
- }
7929
- return readSecretFile(secretPath, "wallet default secret file");
7930
- }
7931
-
7932
8909
  function prepareJoinWalletSecretForName({
7933
8910
  args,
7934
8911
  networkName,
7935
8912
  walletName,
7936
8913
  }) {
7937
- const secretPath = walletSecretPath(networkName, walletName);
7938
8914
  const { channelName } = parseWalletName(walletName);
7939
8915
  const walletDir = walletPath(walletName, networkName);
7940
8916
  expect(
@@ -7949,14 +8925,7 @@ function prepareJoinWalletSecretForName({
7949
8925
  ].join(" "),
7950
8926
  );
7951
8927
  const sourcePath = path.resolve(String(requireArg(args.walletSecretPath, "--wallet-secret-path")));
7952
- const canonicalPath = path.resolve(secretPath);
7953
- const walletSecret = sourcePath === canonicalPath
7954
- ? readSecretFile(sourcePath, "--wallet-secret-path")
7955
- : readImportSecretSourceFile(sourcePath, "--wallet-secret-path");
7956
- if (sourcePath !== canonicalPath) {
7957
- writeSecretFile(canonicalPath, walletSecret);
7958
- }
7959
- return walletSecret;
8928
+ return readImportSecretSourceFile(sourcePath, "--wallet-secret-path");
7960
8929
  }
7961
8930
 
7962
8931
  function channelWorkspacePath(networkName, name) {
@@ -8005,6 +8974,24 @@ function walletSecretPath(networkName, walletName) {
8005
8974
  );
8006
8975
  }
8007
8976
 
8977
+ function walletViewingKeySecretPath(networkName, walletName) {
8978
+ return walletKeySecretPath(networkName, walletName, "viewing");
8979
+ }
8980
+
8981
+ function walletSpendingKeySecretPath(networkName, walletName) {
8982
+ return walletKeySecretPath(networkName, walletName, "spending");
8983
+ }
8984
+
8985
+ function walletKeySecretPath(networkName, walletName, keyKind) {
8986
+ return path.join(
8987
+ secretRoot,
8988
+ requireNetworkName({ network: networkName }),
8989
+ "wallets",
8990
+ slugifyPathComponent(walletName),
8991
+ `${keyKind}.key`,
8992
+ );
8993
+ }
8994
+
8008
8995
  function resolveWalletPathCandidates(walletName) {
8009
8996
  if (!fs.existsSync(workspaceRoot)) {
8010
8997
  return [];
@@ -8063,9 +9050,12 @@ function listLocalWallets({ networkFilter = null, channelFilter = null } = {}) {
8063
9050
  network: networkEntry.name,
8064
9051
  channelName: channelEntry.name,
8065
9052
  walletDir,
8066
- metadataPath: walletMetadataPath(walletDir),
8067
- hasMetadata: fs.existsSync(walletMetadataPath(walletDir)),
8068
- hasEncryptedWallet: walletConfigExists(walletDir),
9053
+ metadataPath: walletNotesMetadataPath(walletDir),
9054
+ hasMetadata: fs.existsSync(walletNotesMetadataPath(walletDir)),
9055
+ hasEncryptedWallet: false,
9056
+ hasBackupMetadata: walletConfigExists(walletDir),
9057
+ hasViewingKey: fs.existsSync(walletViewingKeySecretPath(networkEntry.name, walletEntry.name)),
9058
+ hasSpendingKey: fs.existsSync(walletSpendingKeySecretPath(networkEntry.name, walletEntry.name)),
8069
9059
  });
8070
9060
  }
8071
9061
  }
@@ -8093,8 +9083,8 @@ function resolveExportWalletInfo({ networkName, walletName }) {
8093
9083
  network: networkName,
8094
9084
  channelName: parseWalletName(walletName).channelName,
8095
9085
  walletDir,
8096
- metadataPath: walletMetadataPath(walletDir),
8097
- hasMetadata: fs.existsSync(walletMetadataPath(walletDir)),
9086
+ metadataPath: walletNotesMetadataPath(walletDir),
9087
+ hasMetadata: fs.existsSync(walletNotesMetadataPath(walletDir)),
8098
9088
  hasEncryptedWallet: walletConfigExists(walletDir),
8099
9089
  };
8100
9090
  }
@@ -8103,15 +9093,11 @@ function normalizeExportWalletInfo(walletInfo) {
8103
9093
  const wallet = requireWalletName({ wallet: walletInfo.wallet });
8104
9094
  const network = requireNetworkName({ network: walletInfo.network });
8105
9095
  const walletDir = walletInfo.walletDir ?? walletPath(wallet, network);
8106
- const metadataPath = walletMetadataPath(walletDir);
8107
- const encryptedWalletPath = walletConfigPath(walletDir);
9096
+ const metadataPath = walletNotesMetadataPath(walletDir);
8108
9097
  const metadata = readJsonIfExists(metadataPath);
8109
9098
  const channelName = metadata?.channelName ?? walletInfo.channelName ?? parseWalletName(wallet).channelName;
8110
- const walletSecret = walletSecretPath(network, wallet);
8111
9099
 
8112
- expect(fs.existsSync(encryptedWalletPath), `Wallet export cannot find encrypted wallet file: ${encryptedWalletPath}.`);
8113
9100
  expect(fs.existsSync(metadataPath), `Wallet export cannot find wallet metadata file: ${metadataPath}.`);
8114
- expect(fs.existsSync(walletSecret), `Wallet export cannot find wallet-local secret file: ${walletSecret}.`);
8115
9101
  expect(
8116
9102
  metadata.network === network,
8117
9103
  `Wallet export metadata network ${metadata.network} does not match ${network}.`,
@@ -8126,18 +9112,20 @@ function normalizeExportWalletInfo(walletInfo) {
8126
9112
  channelName,
8127
9113
  wallet,
8128
9114
  walletDir,
8129
- walletSecretPath: walletSecret,
8130
9115
  };
8131
9116
  }
8132
9117
 
8133
- function walletExportFilePaths(walletInfo, { includeNotes }) {
9118
+ function walletBackupExportFilePaths(walletInfo) {
8134
9119
  const walletFiles = [
8135
- walletInfo.walletSecretPath,
8136
- walletConfigPath(walletInfo.walletDir),
8137
- walletMetadataPath(walletInfo.walletDir),
9120
+ walletNotesMetadataPath(walletInfo.walletDir),
8138
9121
  ];
8139
- if (!includeNotes) {
8140
- return walletFiles;
9122
+ for (const metadataPath of [
9123
+ walletViewingKeyMetadataPath(walletInfo.walletDir),
9124
+ walletSpendingKeyMetadataPath(walletInfo.walletDir),
9125
+ ]) {
9126
+ if (fs.existsSync(metadataPath)) {
9127
+ walletFiles.push(metadataPath);
9128
+ }
8141
9129
  }
8142
9130
 
8143
9131
  const workspaceDir = channelWorkspacePath(walletInfo.network, walletInfo.channelName);
@@ -8153,8 +9141,8 @@ function walletExportFilePaths(walletInfo, { includeNotes }) {
8153
9141
  expect(
8154
9142
  fs.existsSync(filePath),
8155
9143
  [
8156
- `wallet export --include-notes requires channel workspace cache file: ${filePath}.`,
8157
- "Run channel recover-workspace first, or export without --include-notes.",
9144
+ `wallet export backup requires channel workspace cache file: ${filePath}.`,
9145
+ "Run channel recover-workspace first.",
8158
9146
  ].join(" "),
8159
9147
  );
8160
9148
  }
@@ -8169,14 +9157,13 @@ function archivePathForLocalCliFile(filePath) {
8169
9157
  }
8170
9158
 
8171
9159
  function validateWalletExportManifest(manifest) {
8172
- expect(manifest?.format === WALLET_EXPORT_FORMAT, "Wallet import ZIP has an unsupported format.");
9160
+ expect(manifest?.format === WALLET_BACKUP_EXPORT_FORMAT, "Wallet import ZIP has an unsupported format.");
8173
9161
  expect(
8174
9162
  Number(manifest.formatVersion) === WALLET_EXPORT_FORMAT_VERSION,
8175
9163
  `Wallet import ZIP format version ${manifest?.formatVersion} is not supported.`,
8176
9164
  );
8177
9165
  expect(Array.isArray(manifest.files), "Wallet import ZIP manifest is missing files[].");
8178
9166
  expect(Array.isArray(manifest.wallets), "Wallet import ZIP manifest is missing wallets[].");
8179
- expect(typeof manifest.includeNotes === "boolean", "Wallet import ZIP manifest is missing includeNotes.");
8180
9167
  expect(manifest.wallets.length > 0, "Wallet import ZIP manifest does not list any wallets.");
8181
9168
  const uniqueFiles = new Set(manifest.files);
8182
9169
  expect(uniqueFiles.size === manifest.files.length, "Wallet import ZIP manifest contains duplicate file paths.");
@@ -8198,8 +9185,8 @@ function validateWalletArchivePath(archivePath) {
8198
9185
  expect(!path.posix.isAbsolute(archivePath), `Wallet import ZIP path must be relative: ${archivePath}.`);
8199
9186
  expect(path.posix.normalize(archivePath) === archivePath, `Wallet import ZIP path is not normalized: ${archivePath}.`);
8200
9187
  expect(
8201
- archivePath.startsWith("secrets/") || archivePath.startsWith("workspace/"),
8202
- `Wallet import ZIP path must start with secrets/ or workspace/: ${archivePath}.`,
9188
+ archivePath.startsWith("workspace/"),
9189
+ `Wallet backup import ZIP path must start with workspace/: ${archivePath}.`,
8203
9190
  );
8204
9191
  }
8205
9192
 
@@ -8210,9 +9197,9 @@ function expectPathWithinRoot(targetPath, rootPath, message) {
8210
9197
 
8211
9198
  function applyImportedWalletFileMode(archivePath, targetPath) {
8212
9199
  if (
8213
- archivePath.startsWith("secrets/")
8214
- || archivePath.endsWith("/wallet.json")
8215
- || archivePath.endsWith("/wallet.metadata.json")
9200
+ archivePath.endsWith("/wallet-notes.metadata.json")
9201
+ || archivePath.endsWith("/wallet-viewing-key.metadata.json")
9202
+ || archivePath.endsWith("/wallet-spending-key.metadata.json")
8216
9203
  ) {
8217
9204
  protectSecretFile(targetPath, `imported wallet file ${archivePath}`);
8218
9205
  }
@@ -8234,16 +9221,20 @@ function channelWorkspaceOperationsPath(workspaceDir) {
8234
9221
  return path.join(channelDataPath(workspaceDir), "operations");
8235
9222
  }
8236
9223
 
8237
- function walletConfigPath(walletDir) {
8238
- return path.join(walletDir, "wallet.json");
9224
+ function walletNotesMetadataPath(walletDir) {
9225
+ return path.join(walletDir, "wallet-notes.metadata.json");
8239
9226
  }
8240
9227
 
8241
- function walletMetadataPath(walletDir) {
8242
- return walletMetadataPathForDir(walletDir);
9228
+ function walletViewingKeyMetadataPath(walletDir) {
9229
+ return path.join(walletDir, "wallet-viewing-key.metadata.json");
9230
+ }
9231
+
9232
+ function walletSpendingKeyMetadataPath(walletDir) {
9233
+ return path.join(walletDir, "wallet-spending-key.metadata.json");
8243
9234
  }
8244
9235
 
8245
9236
  function walletConfigExists(walletDir) {
8246
- return fs.existsSync(walletConfigPath(walletDir));
9237
+ return fs.existsSync(walletNotesMetadataPath(walletDir));
8247
9238
  }
8248
9239
 
8249
9240
  const COMMAND_ARG_SCHEMAS = Object.freeze(
@@ -8300,6 +9291,7 @@ function assertWalletSecretArgs(args, commandName, extraOptionKeys = [], accepte
8300
9291
 
8301
9292
  function assertWalletChannelMoveArgs(args, commandName) {
8302
9293
  assertWalletSecretArgs(args, commandName, ["amount"], "--wallet, --network, and --amount");
9294
+ assertActionImpactArg(args, COMMAND_ARG_SCHEMAS[commandName]?.label ?? commandName);
8303
9295
  }
8304
9296
 
8305
9297
  function assertInstallZkEvmArgs(args) {
@@ -8351,12 +9343,17 @@ function assertTransactionFeesArgs(args) {
8351
9343
  assertAllowedCommandSchema(args, "help-transaction-fees");
8352
9344
  }
8353
9345
 
9346
+ function assertInvestigatorArgs(args) {
9347
+ assertAllowedCommandSchema(args, "investigator");
9348
+ }
9349
+
8354
9350
  function assertAccountImportArgs(args) {
8355
9351
  assertAllowedCommandSchema(args, "account-import");
8356
9352
  }
8357
9353
 
8358
9354
  function assertMintNotesArgs(args) {
8359
9355
  assertAllowedCommandSchema(args, "wallet-mint-notes");
9356
+ assertActionImpactArg(args, "wallet mint-notes");
8360
9357
  assertTxSubmitterArg(args);
8361
9358
  parseAmountVector(args.amounts, {
8362
9359
  allowZeroEntries: true,
@@ -8366,12 +9363,14 @@ function assertMintNotesArgs(args) {
8366
9363
 
8367
9364
  function assertRedeemNotesArgs(args) {
8368
9365
  assertAllowedCommandSchema(args, "wallet-redeem-notes");
9366
+ assertActionImpactArg(args, "wallet redeem-notes");
8369
9367
  assertTxSubmitterArg(args);
8370
9368
  selectRedeemNotesMethod(parseNoteIdVector(args.noteIds).length);
8371
9369
  }
8372
9370
 
8373
9371
  function assertTransferNotesArgs(args) {
8374
9372
  assertAllowedCommandSchema(args, "wallet-transfer-notes");
9373
+ assertActionImpactArg(args, "wallet transfer-notes");
8375
9374
  assertTxSubmitterArg(args);
8376
9375
  const noteIds = parseNoteIdVector(args.noteIds);
8377
9376
  const recipients = parseRecipientVector(args.recipients);
@@ -8392,8 +9391,34 @@ function assertTxSubmitterArg(args) {
8392
9391
  }
8393
9392
  }
8394
9393
 
9394
+ function assertActionImpactArg(args, commandName) {
9395
+ if (
9396
+ args.acknowledgeActionImpact !== undefined
9397
+ && args.acknowledgeActionImpact !== true
9398
+ ) {
9399
+ throw new Error(`${commandName} option --acknowledge-action-impact does not accept a value.`);
9400
+ }
9401
+ if (args.acknowledgeActionImpact !== true && !process.stdin.isTTY) {
9402
+ throw new Error(`${commandName} requires --acknowledge-action-impact after reviewing the action-impact warning.`);
9403
+ }
9404
+ }
9405
+
8395
9406
  function assertWalletGetNotesArgs(args) {
8396
9407
  assertWalletSecretArgs(args, "wallet-get-notes");
9408
+ if (args.exportEvidence !== undefined) {
9409
+ requireArg(args.exportEvidence, "--export-evidence");
9410
+ if (args.acknowledgeFullNotePlaintextExport !== true) {
9411
+ throw new Error(
9412
+ "wallet get-notes --export-evidence requires --acknowledge-full-note-plaintext-export.",
9413
+ );
9414
+ }
9415
+ }
9416
+ if (
9417
+ args.acknowledgeFullNotePlaintextExport !== undefined
9418
+ && args.acknowledgeFullNotePlaintextExport !== true
9419
+ ) {
9420
+ throw new Error("wallet get-notes option --acknowledge-full-note-plaintext-export does not accept a value.");
9421
+ }
8397
9422
  }
8398
9423
 
8399
9424
  function assertCreateChannelArgs(args) {
@@ -8427,6 +9452,7 @@ function assertPublishWorkspaceMirrorArgs(args) {
8427
9452
 
8428
9453
  function assertDepositBridgeArgs(args) {
8429
9454
  assertAllowedCommandSchema(args, "account-deposit-bridge");
9455
+ assertActionImpactArg(args, "account deposit-bridge");
8430
9456
  }
8431
9457
 
8432
9458
  function assertAccountGetBridgeFundArgs(args) {
@@ -8446,6 +9472,7 @@ function assertRecoverWalletArgs(args) {
8446
9472
 
8447
9473
  function assertJoinChannelArgs(args) {
8448
9474
  assertAllowedCommandSchema(args, "channel-join");
9475
+ assertActionImpactArg(args, "channel join");
8449
9476
  }
8450
9477
 
8451
9478
  function assertWalletGetMetaArgs(args) {
@@ -8466,34 +9493,31 @@ function assertListLocalWalletsArgs(args) {
8466
9493
  assertAllowedCommandSchema(args, "wallet-list");
8467
9494
  }
8468
9495
 
8469
- function assertWalletExportArgs(args) {
8470
- assertAllowedCommandSchema(args, "wallet-export");
8471
- assertFlagOption(args, "all", "wallet export");
8472
- assertFlagOption(args, "includeNotes", "wallet export");
9496
+ function assertWalletExportBackupArgs(args) {
9497
+ assertAllowedCommandSchema(args, "wallet-export-backup");
9498
+ requireArg(args.output, "--output");
9499
+ requireNetworkName(args);
9500
+ requireWalletName(args);
9501
+ }
9502
+
9503
+ function assertWalletExportKeyArgs(args, commandName) {
9504
+ assertAllowedCommandSchema(args, commandName);
8473
9505
  requireArg(args.output, "--output");
8474
- if (args.all === true) {
8475
- expect(
8476
- args.network === undefined && args.wallet === undefined,
8477
- "wallet export --all exports every local mainnet wallet and does not accept --network or --wallet.",
8478
- );
8479
- return;
8480
- }
8481
9506
  requireNetworkName(args);
8482
9507
  requireWalletName(args);
8483
9508
  }
8484
9509
 
8485
- function assertWalletImportArgs(args) {
8486
- assertAllowedCommandSchema(args, "wallet-import");
9510
+ function assertWalletImportBackupArgs(args) {
9511
+ assertAllowedCommandSchema(args, "wallet-import-backup");
8487
9512
  }
8488
9513
 
8489
- function assertFlagOption(args, key, commandName) {
8490
- if (args[key] !== undefined && args[key] !== true) {
8491
- throw new Error(`${commandName} option --${toKebabCase(key)} does not accept a value.`);
8492
- }
9514
+ function assertWalletImportKeyArgs(args, commandName) {
9515
+ assertAllowedCommandSchema(args, commandName);
8493
9516
  }
8494
9517
 
8495
9518
  function assertWithdrawBridgeArgs(args) {
8496
9519
  assertAllowedCommandSchema(args, "account-withdraw-bridge");
9520
+ assertActionImpactArg(args, "account withdraw-bridge");
8497
9521
  }
8498
9522
 
8499
9523
  function assertWalletGetChannelFundArgs(args) {
@@ -8516,14 +9540,126 @@ function createWalletOperationDir(walletName, networkName, suffix) {
8516
9540
  }
8517
9541
 
8518
9542
  function persistWallet(context) {
8519
- writeEncryptedWalletJson(path.join(context.walletDir, "wallet.json"), context.wallet, context.walletSecret);
9543
+ writeJson(walletNotesMetadataPath(context.walletDir), sanitizeWalletForNotesMetadata(context.wallet));
9544
+ if (context.wallet?.l2PrivateKey || context.wallet?.l2PublicKey || context.wallet?.l2Address) {
9545
+ writeJson(walletSpendingKeyMetadataPath(context.walletDir), buildWalletSpendingKeyMetadata(context.wallet));
9546
+ }
9547
+ if (context.wallet?.noteReceivePubKeyX || context.wallet?.noteReceivePubKeyYParity !== undefined) {
9548
+ writeJson(walletViewingKeyMetadataPath(context.walletDir), buildWalletViewingKeyMetadata(context.wallet));
9549
+ }
9550
+ }
9551
+
9552
+ function persistWalletKeys(context) {
9553
+ if (context.wallet?.l2PrivateKey) {
9554
+ writeSecretFile(
9555
+ walletSpendingKeySecretPath(context.wallet.network, context.walletName),
9556
+ JSON.stringify({
9557
+ format: WALLET_KEY_EXPORT_FORMAT,
9558
+ formatVersion: WALLET_EXPORT_FORMAT_VERSION,
9559
+ keyKind: "spending",
9560
+ metadata: buildWalletSpendingKeyMetadata(context.wallet),
9561
+ privateKey: normalizePrivateKey(context.wallet.l2PrivateKey),
9562
+ }, null, 2),
9563
+ );
9564
+ }
9565
+ if (context.wallet?.noteReceivePrivateKey) {
9566
+ writeSecretFile(
9567
+ walletViewingKeySecretPath(context.wallet.network, context.walletName),
9568
+ JSON.stringify({
9569
+ format: WALLET_KEY_EXPORT_FORMAT,
9570
+ formatVersion: WALLET_EXPORT_FORMAT_VERSION,
9571
+ keyKind: "viewing",
9572
+ metadata: buildWalletViewingKeyMetadata(context.wallet),
9573
+ privateKey: normalizePrivateKey(context.wallet.noteReceivePrivateKey),
9574
+ }, null, 2),
9575
+ );
9576
+ }
8520
9577
  }
8521
9578
 
8522
9579
  function persistWalletMetadata(context) {
8523
- writeJson(walletMetadataPath(context.walletDir), {
8524
- network: context.wallet.network,
8525
- rpcUrl: context.wallet.rpcUrl,
8526
- channelName: context.wallet.channelName,
9580
+ persistWallet(context);
9581
+ }
9582
+
9583
+ function sanitizeWalletForNotesMetadata(wallet) {
9584
+ const normalized = normalizeWallet({
9585
+ ...wallet,
9586
+ l2PrivateKey: null,
9587
+ noteReceivePrivateKey: null,
9588
+ });
9589
+ const { l2PrivateKey: _l2PrivateKey, noteReceivePrivateKey: _noteReceivePrivateKey, ...publicWallet } = normalized;
9590
+ return {
9591
+ ...publicWallet,
9592
+ notes: {
9593
+ unused: sanitizeTrackedNoteMap(normalized.notes.unused),
9594
+ spent: sanitizeTrackedNoteMap(normalized.notes.spent),
9595
+ unusedOrder: normalized.notes.unusedOrder,
9596
+ unusedBalance: null,
9597
+ },
9598
+ };
9599
+ }
9600
+
9601
+ function sanitizeTrackedNoteMap(notes) {
9602
+ return Object.fromEntries(Object.entries(notes ?? {}).map(([key, note]) => [key, sanitizeTrackedNoteForPersistence(note)]));
9603
+ }
9604
+
9605
+ function sanitizeTrackedNoteForPersistence(note) {
9606
+ const normalized = normalizeTrackedNote(note);
9607
+ return {
9608
+ commitment: normalized.commitment,
9609
+ nullifier: normalized.nullifier,
9610
+ encryptedNoteValue: normalized.encryptedNoteValue,
9611
+ status: normalized.status,
9612
+ sourceFunction: normalized.sourceFunction,
9613
+ sourceTxHash: normalized.sourceTxHash,
9614
+ createdAtTxHash: normalized.createdAtTxHash,
9615
+ createdAtBlockNumber: normalized.createdAtBlockNumber,
9616
+ createdAtLogIndex: normalized.createdAtLogIndex,
9617
+ createdByFunction: normalized.createdByFunction,
9618
+ createdOutputIndex: normalized.createdOutputIndex,
9619
+ spentAtTxHash: normalized.spentAtTxHash,
9620
+ spentAtBlockNumber: normalized.spentAtBlockNumber,
9621
+ spentByFunction: normalized.spentByFunction,
9622
+ spentInputIndex: normalized.spentInputIndex,
9623
+ counterpartyL2Address: normalized.counterpartyL2Address,
9624
+ counterpartyDirection: normalized.counterpartyDirection,
9625
+ counterpartyConfidence: normalized.counterpartyConfidence,
9626
+ bridgeCommitmentKey: normalized.bridgeCommitmentKey,
9627
+ bridgeNullifierKey: normalized.bridgeNullifierKey,
9628
+ };
9629
+ }
9630
+
9631
+ function buildWalletSpendingKeyMetadata(wallet) {
9632
+ return normalizeCliOutput({
9633
+ walletFormatVersion: WALLET_WORKSPACE_FORMAT_VERSION,
9634
+ network: wallet.network,
9635
+ wallet: wallet.name,
9636
+ channelName: wallet.channelName,
9637
+ channelId: wallet.channelId,
9638
+ l1Address: wallet.l1Address,
9639
+ l2Address: wallet.l2Address,
9640
+ l2PublicKey: wallet.l2PublicKey,
9641
+ l2DerivationMode: wallet.l2DerivationMode,
9642
+ l2DerivationChannelName: wallet.l2DerivationChannelName,
9643
+ l2StorageKey: wallet.l2StorageKey,
9644
+ leafIndex: wallet.leafIndex,
9645
+ });
9646
+ }
9647
+
9648
+ function buildWalletViewingKeyMetadata(wallet) {
9649
+ return normalizeCliOutput({
9650
+ walletFormatVersion: WALLET_WORKSPACE_FORMAT_VERSION,
9651
+ network: wallet.network,
9652
+ wallet: wallet.name,
9653
+ channelName: wallet.channelName,
9654
+ channelId: wallet.channelId,
9655
+ l1Address: wallet.l1Address,
9656
+ l2Address: wallet.l2Address,
9657
+ noteReceiveDerivationVersion: wallet.noteReceiveDerivationVersion,
9658
+ noteReceiveTypedDataMethod: wallet.noteReceiveTypedDataMethod,
9659
+ noteReceivePubKey: {
9660
+ x: wallet.noteReceivePubKeyX,
9661
+ yParity: wallet.noteReceivePubKeyYParity,
9662
+ },
8527
9663
  });
8528
9664
  }
8529
9665
 
@@ -8543,10 +9679,10 @@ Secret source options:
8543
9679
  A wallet secret source file is arbitrary high-entropy secret text read once by channel join.
8544
9680
  Create one before joining a channel, for example:
8545
9681
  openssl rand -hex 32 > ./wallet-secret.txt
8546
- private-state-cli channel join --channel-name <NAME> --network <NAME> --account <NAME> --wallet-secret-path ./wallet-secret.txt
9682
+ private-state-cli channel join --channel-name <NAME> --network <NAME> --account <NAME> --wallet-secret-path ./wallet-secret.txt --acknowledge-action-impact
8547
9683
  Bridge-facing commands accept optional --rpc-url. When provided, it is saved to
8548
9684
  ~/tokamak-private-channels/secrets/<network>/.env as RPC_URL. When omitted, the CLI reads RPC_URL from that file.
8549
- Wallet commands use wallet-local default secret files only.
9685
+ Wallet commands use separate protected viewing-key and spending-key files when those capabilities are needed.
8550
9686
  Source files passed to --private-key-file and --wallet-secret-path are not required to use 0600 permissions, but
8551
9687
  canonical CLI secret files remain protected. On macOS/Linux this means 0600; on Windows the CLI repairs ACLs when possible.
8552
9688
 
@@ -8880,23 +10016,6 @@ function getUsableWorkspaceRecoveryIndex({
8880
10016
  };
8881
10017
  }
8882
10018
 
8883
- function writeEncryptedWalletJson(filePath, value, walletSecret) {
8884
- const normalizedValue = normalizeCliOutput(value);
8885
- writeEncryptedWalletFile(filePath, Buffer.from(`${JSON.stringify(normalizedValue, null, 2)}\n`, "utf8"), walletSecret);
8886
- }
8887
-
8888
- function readEncryptedWalletJson(filePath, walletSecret) {
8889
- try {
8890
- return JSON.parse(readEncryptedWalletFile(filePath, walletSecret).toString("utf8"));
8891
- } catch (error) {
8892
- throw cliError(
8893
- CLI_ERROR_CODES.WALLET_DECRYPT_FAILED,
8894
- `Unable to decrypt wallet data at ${filePath}. Check the wallet-local default secret file.`,
8895
- { cause: error },
8896
- );
8897
- }
8898
- }
8899
-
8900
10019
  function writeEncryptedWalletFile(filePath, plaintextBytes, walletSecret) {
8901
10020
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
8902
10021
  const salt = randomBytes(16);
@@ -8917,23 +10036,6 @@ function writeEncryptedWalletFile(filePath, plaintextBytes, walletSecret) {
8917
10036
  fs.writeFileSync(filePath, `${JSON.stringify(envelope, null, 2)}\n`);
8918
10037
  }
8919
10038
 
8920
- function readEncryptedWalletFile(filePath, walletSecret) {
8921
- const envelope = readJson(filePath);
8922
- expect(
8923
- envelope.version === WALLET_ENCRYPTION_VERSION
8924
- && envelope.algorithm === WALLET_ENCRYPTION_ALGORITHM
8925
- && envelope.kdf === "scrypt",
8926
- `Unsupported wallet encryption envelope at ${filePath}.`,
8927
- );
8928
- const encryptionKey = deriveWalletEncryptionKey(walletSecret, Buffer.from(ethers.getBytes(envelope.salt)));
8929
- const decipher = createDecipheriv("aes-256-gcm", encryptionKey, Buffer.from(ethers.getBytes(envelope.iv)));
8930
- decipher.setAuthTag(Buffer.from(ethers.getBytes(envelope.tag)));
8931
- return Buffer.concat([
8932
- decipher.update(Buffer.from(ethers.getBytes(envelope.ciphertext))),
8933
- decipher.final(),
8934
- ]);
8935
- }
8936
-
8937
10039
  function deriveWalletEncryptionKey(walletSecret, salt) {
8938
10040
  return scryptSync(String(walletSecret), salt, 32);
8939
10041
  }
@@ -8982,6 +10084,7 @@ function loadWalletCommandRuntime(args) {
8982
10084
 
8983
10085
  const HUMAN_RESULT_RENDERERS = Object.freeze({
8984
10086
  guide: printGuideHumanResult,
10087
+ investigator: printInvestigatorHumanResult,
8985
10088
  "transaction-fees": printTransactionFeesHumanResult,
8986
10089
  update: printUpdateHumanResult,
8987
10090
  });
@@ -9041,6 +10144,29 @@ function printGuideHumanResult(guide) {
9041
10144
  console.log(lines.join("\n"));
9042
10145
  }
9043
10146
 
10147
+ function printInvestigatorHumanResult(result) {
10148
+ const lines = [
10149
+ "Private-State Evidence Investigator",
10150
+ `HTML path: ${formatHumanValue(result.htmlPath)}`,
10151
+ `File URL: ${formatHumanValue(result.fileUrl)}`,
10152
+ `Browser opened: ${result.browserOpened ? "yes" : "no"}`,
10153
+ ];
10154
+ if (!result.browserOpened) {
10155
+ lines.push(
10156
+ `Open command: ${formatHumanValue(result.browserOpenCommand)}`,
10157
+ `Open error: ${formatHumanValue(result.browserOpenError ?? "none")}`,
10158
+ );
10159
+ }
10160
+ if (Array.isArray(result.nextSteps) && result.nextSteps.length > 0) {
10161
+ lines.push(
10162
+ "",
10163
+ "Next Steps",
10164
+ ...result.nextSteps.map((step) => `- ${step}`),
10165
+ );
10166
+ }
10167
+ console.log(lines.join("\n"));
10168
+ }
10169
+
9044
10170
  function printTransactionFeesHumanResult(report) {
9045
10171
  const lines = [
9046
10172
  "Transaction Fees",
@@ -9375,17 +10501,6 @@ function buildRecoveryHints(error, args = {}) {
9375
10501
  hints.push(`private-state-cli help guide --network ${networkName} --wallet ${walletName}`);
9376
10502
  }
9377
10503
 
9378
- if (error?.code === CLI_ERROR_CODES.MISSING_WALLET_SECRET) {
9379
- hints.push("restore the wallet-local default secret file from backup before running wallet commands.");
9380
- hints.push(`private-state-cli help guide --network ${networkName} --wallet ${walletName}`);
9381
- }
9382
-
9383
- if (error?.code === CLI_ERROR_CODES.WALLET_DECRYPT_FAILED) {
9384
- hints.push("verify that the wallet-local default secret file is the same secret used when the wallet was created.");
9385
- hints.push("if the encrypted wallet file is corrupted but the wallet secret and L1 account secret still exist, rerun wallet recover-workspace.");
9386
- hints.push("if the wallet secret was lost, the local L2 key cannot be recovered from the encrypted wallet file.");
9387
- }
9388
-
9389
10504
  if (
9390
10505
  message.startsWith("Missing --account:")
9391
10506
  || message.includes("Missing --account.")
@@ -9403,7 +10518,7 @@ function buildRecoveryHints(error, args = {}) {
9403
10518
  }
9404
10519
 
9405
10520
  if (error?.code === CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION) {
9406
- hints.push(`private-state-cli channel join --channel-name ${channelName} --network ${networkName} --account ${accountName} --wallet-secret-path <PATH>`);
10521
+ hints.push(`private-state-cli channel join --channel-name ${channelName} --network ${networkName} --account ${accountName} --wallet-secret-path <PATH> --acknowledge-action-impact`);
9407
10522
  hints.push(`private-state-cli help guide --network ${networkName} --channel-name ${channelName} --account ${accountName}`);
9408
10523
  }
9409
10524