@tokamak-private-dapps/private-state-cli 1.2.1 → 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",
@@ -247,6 +250,206 @@ function printImmutableChannelPolicyWarning({
247
250
  console.error(details.join("\n"));
248
251
  }
249
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
+
250
453
  function normalizeDAppPolicySnapshot({
251
454
  dappId,
252
455
  metadataDigest,
@@ -386,6 +589,12 @@ async function main() {
386
589
  return;
387
590
  }
388
591
 
592
+ if (args.command === "investigator") {
593
+ assertInvestigatorArgs(args);
594
+ handleInvestigator();
595
+ return;
596
+ }
597
+
389
598
  if (args.command === "account-get-l1-address") {
390
599
  assertAccountGetL1AddressArgs(args);
391
600
  handleAccountGetL1Address({ args });
@@ -404,15 +613,39 @@ async function main() {
404
613
  return;
405
614
  }
406
615
 
407
- if (args.command === "wallet-export") {
408
- assertWalletExportArgs(args);
409
- 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" });
410
631
  return;
411
632
  }
412
633
 
413
- if (args.command === "wallet-import") {
414
- assertWalletImportArgs(args);
415
- 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" });
416
649
  return;
417
650
  }
418
651
 
@@ -2161,6 +2394,11 @@ async function handleDepositBridge({ args, network, provider }) {
2161
2394
  const bridgeVaultContext = await loadBridgeVaultContext({ provider, chainId: network.chainId });
2162
2395
  const amountInput = requireArg(args.amount, "--amount");
2163
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
+ });
2164
2402
  const bridgeTokenVault = new Contract(
2165
2403
  bridgeVaultContext.bridgeTokenVaultAddress,
2166
2404
  bridgeVaultContext.bridgeAbiManifest.contracts.bridgeTokenVault.abi,
@@ -2223,10 +2461,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2223
2461
  const channelName = requireArg(args.channelName, "--channel-name");
2224
2462
  const signer = requireL1Signer(args, provider);
2225
2463
  const walletName = walletNameForChannelAndAddress(channelName, signer.address);
2226
- const walletSecret = resolveWalletSecretForName({
2227
- networkName: network.name,
2228
- walletName,
2229
- });
2230
2464
  const bridgeResources = loadBridgeResources({ chainId: network.chainId });
2231
2465
  const initialized = await syncChannelWorkspace({
2232
2466
  workspaceName: channelName,
@@ -2260,11 +2494,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2260
2494
  provider,
2261
2495
  ),
2262
2496
  };
2263
- const l2Identity = await deriveParticipantIdentityFromSigner({
2264
- channelName,
2265
- walletSecret,
2266
- signer,
2267
- });
2268
2497
  const noteReceiveKeyMaterial = await deriveNoteReceiveKeyMaterial({
2269
2498
  signer,
2270
2499
  chainId: network.chainId,
@@ -2272,8 +2501,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2272
2501
  channelName,
2273
2502
  account: signer.address,
2274
2503
  });
2275
- const storageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
2276
- const leafIndex = deriveChannelTokenVaultLeafIndex(storageKey);
2277
2504
  const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
2278
2505
 
2279
2506
  if (!registration.exists) {
@@ -2285,15 +2512,13 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2285
2512
  wallet: walletName,
2286
2513
  removedWalletDir: cleanup.removedWalletDir ? cleanup.walletDir : null,
2287
2514
  removedWalletSecretFile: cleanup.removedWalletSecret ? cleanup.walletSecretFile : null,
2288
- walletSecretSource: resolvedWalletSecretSource(args),
2289
- walletSecretFile: resolvedWalletSecretFile(network.name, walletName),
2290
2515
  workspace: context.workspaceName,
2291
2516
  channelName: context.workspace.channelName,
2292
2517
  channelId: context.workspace.channelId,
2293
2518
  l1Address: signer.address,
2294
- l2Address: l2Identity.l2Address,
2295
- l2StorageKey: storageKey,
2296
- leafIndex: leafIndex.toString(),
2519
+ l2Address: null,
2520
+ l2StorageKey: null,
2521
+ leafIndex: null,
2297
2522
  reason: "The local wallet existed, but the L1 address is no longer registered in the channel.",
2298
2523
  nextAction: buildRecoverWalletRemovedNextAction({
2299
2524
  channelName,
@@ -2311,19 +2536,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2311
2536
  ),
2312
2537
  );
2313
2538
  }
2314
- expect(
2315
- ethers.toBigInt(getAddress(registration.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address)),
2316
- "The existing channel registration L2 address does not match the derived L2 address.",
2317
- );
2318
- expect(
2319
- ethers.toBigInt(normalizeBytes32Hex(registration.channelTokenVaultKey))
2320
- === ethers.toBigInt(normalizeBytes32Hex(storageKey)),
2321
- "The existing channel registration key does not match the derived channelTokenVault key.",
2322
- );
2323
- expect(
2324
- ethers.toBigInt(registration.leafIndex) === ethers.toBigInt(leafIndex),
2325
- "The existing channel registration leaf index does not match the derived leaf index.",
2326
- );
2327
2539
  expect(
2328
2540
  ethers.toBigInt(normalizeBytes32Hex(registration.noteReceivePubKey.x))
2329
2541
  === ethers.toBigInt(normalizeBytes32Hex(noteReceiveKeyMaterial.noteReceivePubKey.x)),
@@ -2333,21 +2545,21 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2333
2545
  Number(registration.noteReceivePubKey.yParity) === Number(noteReceiveKeyMaterial.noteReceivePubKey.yParity),
2334
2546
  "The existing note-receive public key parity does not match the derived note-receive public key.",
2335
2547
  );
2548
+ const l2Identity = {
2549
+ l2PrivateKey: null,
2550
+ l2PublicKey: null,
2551
+ l2Address: getAddress(registration.l2Address),
2552
+ };
2553
+ const storageKey = normalizeBytes32Hex(registration.channelTokenVaultKey);
2336
2554
 
2337
- const existingWallet = tryLoadRecoverableWallet({
2338
- walletName,
2339
- walletSecret,
2340
- signerAddress: signer.address,
2341
- signerPrivateKey: signer.privateKey,
2342
- l2Identity,
2343
- storageKey,
2344
- leafIndex,
2345
- rpcUrl,
2346
- channelContext: context,
2347
- noteReceiveKeyMaterial,
2348
- });
2555
+ const walletDir = walletPath(walletName, context.workspace.network);
2556
+ const existingWallet = walletConfigExists(walletDir)
2557
+ ? loadWallet(walletName, context.workspace.network)
2558
+ : null;
2349
2559
 
2350
2560
  if (existingWallet) {
2561
+ existingWallet.wallet.noteReceivePrivateKey = noteReceiveKeyMaterial.privateKey;
2562
+ persistWalletKeys(existingWallet);
2351
2563
  const { recoveredDeliveryState } = await recoverWalletReceivedNotes({
2352
2564
  walletContext: existingWallet,
2353
2565
  context,
@@ -2362,8 +2574,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2362
2574
  status: "already-recovered",
2363
2575
  wallet: walletName,
2364
2576
  walletDir: existingWallet.walletDir,
2365
- walletSecretSource: resolvedWalletSecretSource(args),
2366
- walletSecretFile: resolvedWalletSecretFile(network.name, walletName),
2367
2577
  workspace: context.workspaceName,
2368
2578
  channelName: context.workspace.channelName,
2369
2579
  channelId: context.workspace.channelId,
@@ -2387,7 +2597,7 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2387
2597
  signerAddress: signer.address,
2388
2598
  signerPrivateKey: signer.privateKey,
2389
2599
  l2Identity,
2390
- walletSecret,
2600
+ walletSecret: noteReceiveKeyMaterial.privateKey,
2391
2601
  storageKey,
2392
2602
  leafIndex: registration.leafIndex,
2393
2603
  noteReceiveKeyMaterial,
@@ -2411,8 +2621,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2411
2621
  status: "recovered",
2412
2622
  wallet: walletName,
2413
2623
  walletDir: walletContext.walletDir,
2414
- walletSecretSource: resolvedWalletSecretSource(args),
2415
- walletSecretFile: resolvedWalletSecretFile(network.name, walletName),
2416
2624
  workspace: context.workspaceName,
2417
2625
  channelName: context.workspace.channelName,
2418
2626
  channelId: context.workspace.channelId,
@@ -2428,135 +2636,6 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2428
2636
  });
2429
2637
  }
2430
2638
 
2431
- function tryLoadRecoverableWallet({
2432
- walletName,
2433
- walletSecret,
2434
- signerAddress,
2435
- signerPrivateKey,
2436
- l2Identity,
2437
- storageKey,
2438
- leafIndex,
2439
- rpcUrl,
2440
- channelContext,
2441
- noteReceiveKeyMaterial,
2442
- }) {
2443
- const walletDir = walletPath(walletName, channelContext.workspace.network);
2444
- if (!walletConfigExists(walletDir)) {
2445
- return null;
2446
- }
2447
-
2448
- try {
2449
- const walletMetadata = loadWalletMetadata(walletName, channelContext.workspace.network);
2450
- const walletContext = loadWallet(walletName, walletSecret, channelContext.workspace.network);
2451
- assertWalletMatchesMetadata(walletContext, walletMetadata);
2452
- assertExistingRecoverableWallet({
2453
- walletContext,
2454
- walletMetadata,
2455
- signerAddress,
2456
- signerPrivateKey,
2457
- l2Identity,
2458
- storageKey,
2459
- leafIndex,
2460
- rpcUrl,
2461
- channelContext,
2462
- noteReceiveKeyMaterial,
2463
- });
2464
- return walletContext;
2465
- } catch {
2466
- return null;
2467
- }
2468
- }
2469
-
2470
- function assertExistingRecoverableWallet({
2471
- walletContext,
2472
- walletMetadata,
2473
- signerAddress,
2474
- signerPrivateKey,
2475
- l2Identity,
2476
- storageKey,
2477
- leafIndex,
2478
- rpcUrl,
2479
- channelContext,
2480
- noteReceiveKeyMaterial,
2481
- }) {
2482
- const wallet = walletContext.wallet;
2483
- expect(
2484
- walletMetadata.network === channelContext.workspace.network,
2485
- `Wallet ${walletContext.walletName} metadata network does not match the requested network.`,
2486
- );
2487
- expect(
2488
- walletMetadata.channelName === channelContext.workspace.channelName,
2489
- `Wallet ${walletContext.walletName} metadata channel does not match the requested channel.`,
2490
- );
2491
- expect(
2492
- walletMetadata.rpcUrl === rpcUrl,
2493
- `Wallet ${walletContext.walletName} metadata rpcUrl does not match the requested runtime RPC URL.`,
2494
- );
2495
- expect(
2496
- normalizePrivateKey(wallet.l1PrivateKey) === normalizePrivateKey(signerPrivateKey),
2497
- `Wallet ${walletContext.walletName} does not decrypt to the requested L1 private key.`,
2498
- );
2499
- expect(
2500
- ethers.toBigInt(getAddress(wallet.l1Address)) === ethers.toBigInt(getAddress(signerAddress)),
2501
- `Wallet ${walletContext.walletName} L1 address does not match the requested signer.`,
2502
- );
2503
- expect(
2504
- ethers.toBigInt(getAddress(wallet.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address)),
2505
- `Wallet ${walletContext.walletName} L2 address does not match the derived channel identity.`,
2506
- );
2507
- expect(
2508
- ethers.toBigInt(normalizeBytes32Hex(wallet.l2StorageKey))
2509
- === ethers.toBigInt(normalizeBytes32Hex(storageKey)),
2510
- `Wallet ${walletContext.walletName} storage key does not match the derived registration key.`,
2511
- );
2512
- expect(
2513
- ethers.toBigInt(wallet.leafIndex) === ethers.toBigInt(leafIndex),
2514
- `Wallet ${walletContext.walletName} leaf index does not match the derived registration leaf index.`,
2515
- );
2516
- expect(
2517
- ethers.toBigInt(wallet.channelId) === ethers.toBigInt(channelContext.workspace.channelId),
2518
- `Wallet ${walletContext.walletName} channel ID does not match the requested channel.`,
2519
- );
2520
- expect(
2521
- wallet.channelName === channelContext.workspace.channelName,
2522
- `Wallet ${walletContext.walletName} channel name does not match the requested channel.`,
2523
- );
2524
- expect(
2525
- wallet.network === channelContext.workspace.network,
2526
- `Wallet ${walletContext.walletName} network does not match the requested network.`,
2527
- );
2528
- expect(
2529
- wallet.rpcUrl === rpcUrl,
2530
- `Wallet ${walletContext.walletName} rpcUrl does not match the requested runtime RPC URL.`,
2531
- );
2532
- expect(
2533
- ethers.toBigInt(getAddress(wallet.channelManager)) === ethers.toBigInt(getAddress(channelContext.workspace.channelManager)),
2534
- `Wallet ${walletContext.walletName} channel manager does not match the recovered workspace.`,
2535
- );
2536
- expect(
2537
- ethers.toBigInt(getAddress(wallet.bridgeTokenVault)) === ethers.toBigInt(getAddress(channelContext.workspace.bridgeTokenVault)),
2538
- `Wallet ${walletContext.walletName} bridge token vault does not match the recovered workspace.`,
2539
- );
2540
- expect(
2541
- ethers.toBigInt(getAddress(wallet.controller)) === ethers.toBigInt(getAddress(channelContext.workspace.controller)),
2542
- `Wallet ${walletContext.walletName} controller does not match the recovered workspace.`,
2543
- );
2544
- expect(
2545
- ethers.toBigInt(getAddress(wallet.l2AccountingVault))
2546
- === ethers.toBigInt(getAddress(channelContext.workspace.l2AccountingVault)),
2547
- `Wallet ${walletContext.walletName} L2 accounting vault does not match the recovered workspace.`,
2548
- );
2549
- expect(
2550
- ethers.toBigInt(normalizeBytes32Hex(wallet.noteReceivePubKeyX))
2551
- === ethers.toBigInt(normalizeBytes32Hex(noteReceiveKeyMaterial.noteReceivePubKey.x)),
2552
- `Wallet ${walletContext.walletName} note-receive public key X does not match the derived key.`,
2553
- );
2554
- expect(
2555
- Number(wallet.noteReceivePubKeyYParity) === Number(noteReceiveKeyMaterial.noteReceivePubKey.yParity),
2556
- `Wallet ${walletContext.walletName} note-receive public key parity does not match the derived key.`,
2557
- );
2558
- }
2559
-
2560
2639
  function removeLocalWalletArtifacts(walletName, networkName) {
2561
2640
  const walletDir = walletPath(walletName, networkName);
2562
2641
  const walletSecretFile = walletSecretPath(networkName, walletName);
@@ -2580,7 +2659,7 @@ function removeLocalWalletArtifacts(walletName, networkName) {
2580
2659
 
2581
2660
  function buildRecoverWalletRemovedNextAction({ channelName, networkName, accountName }) {
2582
2661
  const account = accountName ? String(accountName) : "<ACCOUNT>";
2583
- 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`;
2584
2663
  }
2585
2664
 
2586
2665
  async function handleInstallZkEvm({ args }) {
@@ -2880,6 +2959,68 @@ async function handleDoctor({ args }) {
2880
2959
  }
2881
2960
  }
2882
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
+
2883
3024
  async function handleTransactionFees({ network, provider, rpcUrl }) {
2884
3025
  const feeAsset = loadTransactionFeeAsset();
2885
3026
  const feeData = await provider.getFeeData();
@@ -3079,24 +3220,19 @@ function handleListLocalWallets({ args }) {
3079
3220
  });
3080
3221
  }
3081
3222
 
3082
- function handleWalletExport({ args }) {
3223
+ function handleWalletExportBackup({ args }) {
3083
3224
  const outputPath = path.resolve(String(requireArg(args.output, "--output")));
3084
3225
  expect(!fs.existsSync(outputPath), `Export output already exists: ${outputPath}.`);
3085
3226
  ensureDir(path.dirname(outputPath));
3086
3227
 
3087
- const includeNotes = args.includeNotes === true;
3088
- const wallets = args.all === true
3089
- ? listLocalWallets({ networkFilter: "mainnet" }).filter((wallet) => wallet.hasEncryptedWallet)
3090
- : [resolveExportWalletInfo({
3091
- networkName: requireNetworkName(args),
3092
- walletName: requireWalletName(args),
3093
- })];
3228
+ const wallets = [resolveExportWalletInfo({
3229
+ networkName: requireNetworkName(args),
3230
+ walletName: requireWalletName(args),
3231
+ })];
3094
3232
 
3095
3233
  expect(
3096
3234
  wallets.length > 0,
3097
- args.all === true
3098
- ? "No local mainnet wallets are available to export."
3099
- : "No local wallet is available to export.",
3235
+ "No local wallet is available to export.",
3100
3236
  );
3101
3237
 
3102
3238
  const archive = new AdmZip();
@@ -3109,7 +3245,7 @@ function handleWalletExport({ args }) {
3109
3245
  channelName: normalized.channelName,
3110
3246
  wallet: normalized.wallet,
3111
3247
  });
3112
- for (const filePath of walletExportFilePaths(normalized, { includeNotes })) {
3248
+ for (const filePath of walletBackupExportFilePaths(normalized)) {
3113
3249
  const archivePath = archivePathForLocalCliFile(filePath);
3114
3250
  if (!files.has(archivePath)) {
3115
3251
  files.set(archivePath, filePath);
@@ -3118,44 +3254,65 @@ function handleWalletExport({ args }) {
3118
3254
  }
3119
3255
 
3120
3256
  const manifest = {
3121
- format: WALLET_EXPORT_FORMAT,
3257
+ format: WALLET_BACKUP_EXPORT_FORMAT,
3122
3258
  formatVersion: WALLET_EXPORT_FORMAT_VERSION,
3123
3259
  createdAt: new Date().toISOString(),
3124
3260
  cliPackage: PRIVATE_STATE_CLI_PACKAGE_NAME,
3125
3261
  cliVersion: privateStateCliPackageJson.version,
3126
- exportMode: args.all === true ? "all-mainnet" : "single-wallet",
3127
- includeNotes,
3128
- notes: includeNotes
3129
- ? [
3130
- "Includes the channel workspace cache required for immediate wallet command use when the cache is still chain-aligned.",
3131
- ]
3132
- : [
3133
- "Includes wallet identity, encrypted wallet state, metadata, and wallet-local secret only.",
3134
- "Run channel recover-workspace after import before wallet commands need channel state.",
3135
- ],
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
+ ],
3136
3267
  wallets: exportedWallets,
3137
3268
  files: [...files.keys()].sort(),
3138
3269
  };
3139
3270
 
3140
3271
  archive.addFile("manifest.json", Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"));
3141
3272
  for (const archivePath of manifest.files) {
3142
- archive.addFile(archivePath, fs.readFileSync(files.get(archivePath)));
3273
+ const filePath = files.get(archivePath);
3274
+ validateBackupExportFile(filePath);
3275
+ archive.addFile(archivePath, fs.readFileSync(filePath));
3143
3276
  }
3144
3277
  archive.writeZip(outputPath);
3145
3278
  protectSecretFile(outputPath, "wallet export ZIP");
3146
3279
 
3147
3280
  printJson({
3148
- action: "wallet export",
3281
+ action: "wallet export backup",
3149
3282
  output: outputPath,
3150
3283
  exportMode: manifest.exportMode,
3151
- includeNotes,
3152
3284
  walletCount: exportedWallets.length,
3153
3285
  fileCount: manifest.files.length,
3154
3286
  wallets: exportedWallets.map(({ network, channelName, wallet }) => ({ network, channelName, wallet })),
3155
3287
  });
3156
3288
  }
3157
3289
 
3158
- 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 }) {
3159
3316
  const inputPath = path.resolve(String(requireArg(args.input, "--input")));
3160
3317
  expect(fs.existsSync(inputPath), `Import ZIP does not exist: ${inputPath}.`);
3161
3318
 
@@ -3191,16 +3348,51 @@ function handleWalletImport({ args }) {
3191
3348
  commitWalletImportFiles({ targetRoot, plannedWrites });
3192
3349
 
3193
3350
  printJson({
3194
- action: "wallet import",
3351
+ action: "wallet import backup",
3195
3352
  input: inputPath,
3196
3353
  exportMode: manifest.exportMode,
3197
- includeNotes: Boolean(manifest.includeNotes),
3198
3354
  walletCount: manifest.wallets.length,
3199
3355
  fileCount: plannedWrites.length,
3200
3356
  wallets: manifest.wallets.map(({ network, channelName, wallet }) => ({ network, channelName, wallet })),
3201
- nextStep: manifest.includeNotes
3202
- ? "Wallet commands can run immediately if the imported channel workspace cache is still chain-aligned."
3203
- : "Run channel recover-workspace before wallet commands need channel state.",
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,
3204
3396
  });
3205
3397
  }
3206
3398
 
@@ -3217,6 +3409,47 @@ function readWalletImportArchive(inputPath) {
3217
3409
  }
3218
3410
  }
3219
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
+
3220
3453
  function commitWalletImportFiles({ targetRoot, plannedWrites }) {
3221
3454
  const stagingRoot = fs.mkdtempSync(path.join(targetRoot, ".wallet-import-"));
3222
3455
  const committedPaths = [];
@@ -3610,14 +3843,18 @@ async function inspectGuideAccount({ account, networkName, network, provider, ar
3610
3843
 
3611
3844
  async function inspectGuideWallet({ walletName, networkName, provider, artifactsInstalled }) {
3612
3845
  const walletDir = walletPath(walletName, networkName);
3846
+ const viewingKeyFile = walletViewingKeySecretPath(networkName, walletName);
3847
+ const spendingKeyFile = walletSpendingKeySecretPath(networkName, walletName);
3613
3848
  const result = {
3614
3849
  wallet: walletName,
3615
3850
  network: networkName,
3616
3851
  walletDir,
3617
3852
  exists: walletConfigExists(walletDir),
3618
- metadataExists: fs.existsSync(walletMetadataPath(walletDir)),
3619
- secretFile: walletSecretPath(networkName, walletName),
3620
- 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),
3621
3858
  channelName: null,
3622
3859
  l1Address: null,
3623
3860
  l2Address: null,
@@ -3635,7 +3872,7 @@ async function inspectGuideWallet({ walletName, networkName, provider, artifacts
3635
3872
  }
3636
3873
 
3637
3874
  try {
3638
- const walletContext = loadWallet(walletName, resolveWalletDefaultSecret(networkName, walletName), networkName);
3875
+ const walletContext = loadWallet(walletName, networkName);
3639
3876
  const walletMetadata = loadWalletMetadata(walletName, networkName);
3640
3877
  assertWalletMatchesMetadata(walletContext, walletMetadata);
3641
3878
  result.channelName = walletContext.wallet.channelName;
@@ -3643,10 +3880,13 @@ async function inspectGuideWallet({ walletName, networkName, provider, artifacts
3643
3880
  result.l2Address = getAddress(walletContext.wallet.l2Address);
3644
3881
  result.unusedNoteCount = Object.keys(walletContext.wallet.notes.unused).length;
3645
3882
  result.spentNoteCount = Object.keys(walletContext.wallet.notes.spent).length;
3646
- const unusedNoteBalance = Object.values(walletContext.wallet.notes.unused)
3647
- .reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n);
3648
- result.unusedNoteBalanceBaseUnits = unusedNoteBalance.toString();
3649
- result.unusedNoteBalanceTokens = ethers.formatUnits(unusedNoteBalance, Number(walletContext.wallet.canonicalAssetDecimals));
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
+ }
3650
3890
 
3651
3891
  if (provider && artifactsInstalled && walletChannelWorkspaceIsReady(walletContext)) {
3652
3892
  const context = await loadWorkspaceContext(walletContext.wallet.channelName, networkName, provider);
@@ -3717,7 +3957,7 @@ function applyGuideNextAction(guide) {
3717
3957
  const channelName = guide.selectors.channelName ?? guide.state.channel?.channelName ?? "<CHANNEL>";
3718
3958
  const account = guide.selectors.account ?? "<ACCOUNT>";
3719
3959
  setGuideNextAction(guide, {
3720
- 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`,
3721
3961
  why: "The selected local wallet does not exist. Join the channel to create the wallet and register the channel L2 identity.",
3722
3962
  });
3723
3963
  return;
@@ -3726,7 +3966,7 @@ function applyGuideNextAction(guide) {
3726
3966
  const channelName = guide.state.wallet.channelName ?? guide.selectors.channelName ?? "<CHANNEL>";
3727
3967
  const account = guide.selectors.account ?? "<ACCOUNT>";
3728
3968
  setGuideNextAction(guide, {
3729
- 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`,
3730
3970
  why: "The local wallet exists, but the corresponding L1 address is not registered in the channel.",
3731
3971
  });
3732
3972
  return;
@@ -3743,32 +3983,32 @@ function applyGuideNextAction(guide) {
3743
3983
  if (guide.state.wallet?.exists && bridgeBalance === 0n && (channelBalance === null || channelBalance === 0n) && unusedNotes === 0) {
3744
3984
  const account = guide.selectors.account ?? "<ACCOUNT>";
3745
3985
  setGuideNextAction(guide, {
3746
- 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`,
3747
3987
  why: "The wallet is joined, but there is no bridge balance, channel balance, or local unused note to spend.",
3748
3988
  });
3749
3989
  return;
3750
3990
  }
3751
3991
  if (guide.state.wallet?.exists && bridgeBalance !== null && bridgeBalance > 0n && channelBalance === 0n) {
3752
3992
  setGuideNextAction(guide, {
3753
- 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`,
3754
3994
  why: "The account has funds in the shared bridge vault, but the wallet has no channel L2 accounting balance.",
3755
3995
  });
3756
3996
  return;
3757
3997
  }
3758
3998
  if (guide.state.wallet?.exists && channelBalance !== null && channelBalance > 0n && unusedNotes === 0) {
3759
3999
  setGuideNextAction(guide, {
3760
- 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>]`,
3761
4001
  why: "The wallet has channel L2 balance and no unused private notes yet. Use --tx-submitter for stronger transaction-submission privacy.",
3762
4002
  });
3763
4003
  return;
3764
4004
  }
3765
4005
  if (guide.state.wallet?.exists && unusedNotes !== null && unusedNotes > 0) {
3766
4006
  setGuideNextAction(guide, {
3767
- command: `wallet transfer-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID,ID> --recipients <ADDR,ADDR> --amounts <A,B> [--tx-submitter <ACCOUNT>]`,
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>]`,
3768
4008
  why: "The wallet has unused private notes. It can transfer or redeem those notes. Use --tx-submitter for stronger transaction-submission privacy.",
3769
4009
  candidates: [
3770
4010
  `wallet get-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network}`,
3771
- `wallet redeem-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID> [--tx-submitter <ACCOUNT>]`,
4011
+ `wallet redeem-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID> --acknowledge-action-impact [--tx-submitter <ACCOUNT>]`,
3772
4012
  ],
3773
4013
  });
3774
4014
  return;
@@ -3879,6 +4119,12 @@ async function handleWalletGetMeta({ args, provider }) {
3879
4119
  registeredL2Address: registration.exists ? getAddress(registration.l2Address) : null,
3880
4120
  registeredL2StorageKey: registration.exists ? normalizeBytes32Hex(registration.channelTokenVaultKey) : null,
3881
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,
3882
4128
  });
3883
4129
  }
3884
4130
 
@@ -3924,7 +4170,8 @@ async function loadWalletChannelRegistrationState({
3924
4170
  provider,
3925
4171
  requireRegistration = false,
3926
4172
  }) {
3927
- const { signer, l2Identity } = restoreWalletParticipant(walletContext, provider);
4173
+ const signer = requireWalletOwnerSigner(walletContext, provider);
4174
+ const l2Identity = restoreParticipantIdentityFromWallet(walletContext.wallet);
3928
4175
  const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
3929
4176
  const expectedStorageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
3930
4177
  const matchesWallet = registration.exists
@@ -4025,13 +4272,9 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4025
4272
  const storageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
4026
4273
  const leafIndex = deriveChannelTokenVaultLeafIndex(storageKey);
4027
4274
 
4028
- const resolvedLeafIndex = leafIndex;
4029
4275
  let approveReceipt = null;
4030
4276
  let receipt = null;
4031
- let joinToll = 0n;
4032
- let status = null;
4033
-
4034
- joinToll = ethers.toBigInt(await context.channelManager.joinToll());
4277
+ const joinToll = ethers.toBigInt(await context.channelManager.joinToll());
4035
4278
  const asset = new Contract(
4036
4279
  context.workspace.canonicalAsset,
4037
4280
  context.bridgeAbiManifest.contracts.erc20.abi,
@@ -4045,6 +4288,14 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4045
4288
  channelManager: context.workspace.channelManager,
4046
4289
  policySnapshot: context.workspace.policySnapshot,
4047
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
+ });
4048
4299
  if (joinToll !== 0n) {
4049
4300
  approveReceipt = await waitForReceipt(
4050
4301
  await asset.approve(context.workspace.bridgeTokenVault, joinToll, { nonce: nextNonce++ }),
@@ -4060,8 +4311,6 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4060
4311
  { nonce: nextNonce++ },
4061
4312
  ),
4062
4313
  );
4063
- status = "joined";
4064
-
4065
4314
  await refreshPersistedWorkspaceAfterLocalTransaction({
4066
4315
  context,
4067
4316
  provider,
@@ -4076,7 +4325,7 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4076
4325
  l2Identity,
4077
4326
  walletSecret,
4078
4327
  storageKey,
4079
- leafIndex: resolvedLeafIndex,
4328
+ leafIndex,
4080
4329
  noteReceiveKeyMaterial,
4081
4330
  rpcUrl,
4082
4331
  });
@@ -4085,14 +4334,15 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4085
4334
  action: "channel join",
4086
4335
  workspace: context.workspaceName,
4087
4336
  wallet: walletContext.walletName,
4088
- walletSecretSource: resolvedWalletSecretSource(args),
4089
- 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.",
4090
4340
  channelName: context.workspace.channelName,
4091
4341
  channelId: context.workspace.channelId,
4092
4342
  l1Address: signer.address,
4093
4343
  l2Address: l2Identity.l2Address,
4094
4344
  l2StorageKey: storageKey,
4095
- leafIndex: resolvedLeafIndex.toString(),
4345
+ leafIndex: leafIndex.toString(),
4096
4346
  joinTollBaseUnits: joinToll.toString(),
4097
4347
  joinTollTokens: ethers.formatUnits(joinToll, Number(context.workspace.canonicalAssetDecimals)),
4098
4348
  noteReceivePubKey: noteReceiveKeyMaterial.noteReceivePubKey,
@@ -4103,7 +4353,7 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4103
4353
  txUrl: receipt ? explorerTxUrl(network, receipt.hash) : null,
4104
4354
  approveReceipt: approveReceipt ? sanitizeReceipt(approveReceipt) : null,
4105
4355
  receipt: receipt ? sanitizeReceipt(receipt) : null,
4106
- status,
4356
+ status: "joined",
4107
4357
  });
4108
4358
  }
4109
4359
 
@@ -4114,18 +4364,19 @@ async function handleExitChannel({ args, provider }) {
4114
4364
  provider,
4115
4365
  progressAction: "channel exit",
4116
4366
  });
4367
+ const ownerSigner = requireWalletOwnerSigner(walletContext, provider);
4117
4368
  const network = contextResult.network;
4118
4369
  expect(
4119
4370
  channelFund === 0n,
4120
4371
  [
4121
- `The current channel fund for ${signer.address} is ${channelFund.toString()}.`,
4372
+ `The current channel fund for ${ownerSigner.address} is ${channelFund.toString()}.`,
4122
4373
  "channel exit requires a zero channel balance.",
4123
4374
  "Run wallet withdraw-channel first, then retry channel exit.",
4124
4375
  ].join(" "),
4125
4376
  );
4126
- const [refundAmount, refundBps] = await context.channelManager.getExitTollRefundQuote(signer.address);
4377
+ const [refundAmount, refundBps] = await context.channelManager.getExitTollRefundQuote(ownerSigner.address);
4127
4378
  const receipt = await waitForReceipt(
4128
- await context.bridgeTokenVault.connect(signer).exitChannel(ethers.toBigInt(context.workspace.channelId)),
4379
+ await context.bridgeTokenVault.connect(ownerSigner).exitChannel(ethers.toBigInt(context.workspace.channelId)),
4129
4380
  );
4130
4381
  const cleanup = removeLocalWalletArtifacts(walletContext.walletName, walletMetadata.network);
4131
4382
 
@@ -4135,7 +4386,7 @@ async function handleExitChannel({ args, provider }) {
4135
4386
  network: walletMetadata.network,
4136
4387
  channelName: walletMetadata.channelName,
4137
4388
  channelId: context.workspace.channelId,
4138
- l1Address: signer.address,
4389
+ l1Address: ownerSigner.address,
4139
4390
  currentUserValue: channelFund.toString(),
4140
4391
  refundAmountBaseUnits: refundAmount.toString(),
4141
4392
  refundAmountTokens: ethers.formatUnits(refundAmount, Number(context.workspace.canonicalAssetDecimals)),
@@ -4174,6 +4425,13 @@ async function handleGrothVaultMove({ args, provider, direction }) {
4174
4425
  const { signer, l2Identity } = restoreWalletParticipant(walletContext, provider);
4175
4426
  const amountInput = requireArg(args.amount, "--amount");
4176
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
+ });
4177
4435
  const storageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
4178
4436
  const bridgeTokenVault = new Contract(
4179
4437
  context.workspace.bridgeTokenVault,
@@ -4256,7 +4514,7 @@ async function handleGrothVaultMove({ args, provider, direction }) {
4256
4514
  writeJson(path.join(operationDir, `${operationName}-receipt.json`), sanitizeReceipt(receipt));
4257
4515
  writeJson(path.join(operationDir, "state_snapshot.json"), transition.nextSnapshot);
4258
4516
  writeJson(path.join(operationDir, "state_snapshot.normalized.json"), transition.nextSnapshot);
4259
- sealWalletOperationDir(operationDir, walletContext.walletSecret);
4517
+ sealWalletOperationDir(operationDir, walletOperationSealSecret(walletContext));
4260
4518
 
4261
4519
  context.currentSnapshot = transition.nextSnapshot;
4262
4520
  await refreshPersistedWorkspaceAfterLocalTransaction({
@@ -4291,6 +4549,11 @@ async function handleWithdrawBridge({ args, network, provider }) {
4291
4549
  const bridgeVaultContext = await loadBridgeVaultContext({ provider, chainId });
4292
4550
  const amountInput = requireArg(args.amount, "--amount");
4293
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
+ });
4294
4557
  const bridgeTokenVault = new Contract(
4295
4558
  bridgeVaultContext.bridgeTokenVaultAddress,
4296
4559
  bridgeVaultContext.bridgeAbiManifest.contracts.bridgeTokenVault.abi,
@@ -4371,6 +4634,7 @@ function resolveFunctionMetadataProofForExecution({
4371
4634
 
4372
4635
  async function handleMintNotes({ args, provider }) {
4373
4636
  const { wallet } = loadUnlockedWalletWithMetadata(args);
4637
+ requireWalletSpendingCapability(wallet);
4374
4638
  const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
4375
4639
  const amountInputs = parseAmountVector(requireArg(args.amounts, "--amounts"), {
4376
4640
  allowZeroEntries: true,
@@ -4400,6 +4664,19 @@ async function handleMintNotes({ args, provider }) {
4400
4664
  `${channelFund.toString()}. Run wallet get-channel-fund to inspect the available balance.`,
4401
4665
  ].join(" "),
4402
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
+ });
4403
4680
  const templatePayload = buildMintNotesTemplatePayload({
4404
4681
  wallet,
4405
4682
  baseUnitAmounts: baseUnitAmounts.map(({ amountBaseUnits }) => amountBaseUnits),
@@ -4432,6 +4709,7 @@ async function handleMintNotes({ args, provider }) {
4432
4709
  sourceFunction: templatePayload.method,
4433
4710
  sourceTxHash: execution.receipt.hash,
4434
4711
  bridgeCommitmentKeys: execution.noteLifecycle.outputCommitmentKeys,
4712
+ sourceBlockNumber: execution.receipt.blockNumber,
4435
4713
  }),
4436
4714
  gasUsed: receiptGasUsed(execution.receipt),
4437
4715
  txUrl: explorerTxUrl(contextResult.network, execution.receipt.hash),
@@ -4444,6 +4722,8 @@ async function handleMintNotes({ args, provider }) {
4444
4722
 
4445
4723
  async function handleRedeemNotes({ args, provider }) {
4446
4724
  const { wallet } = loadUnlockedWalletWithMetadata(args);
4725
+ requireWalletViewingCapability(wallet);
4726
+ requireWalletSpendingCapability(wallet);
4447
4727
  const noteIds = parseNoteIdVector(requireArg(args.noteIds, "--note-ids"));
4448
4728
  const preparedContextResult = await loadFreshWalletChannelContext({
4449
4729
  walletContext: wallet,
@@ -4458,6 +4738,19 @@ async function handleRedeemNotes({ args, provider }) {
4458
4738
  preConsumedBlockDelta: preparedContextResult.autoRecoveryBlockDelta,
4459
4739
  });
4460
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
+ });
4461
4754
  const templatePayload = buildRedeemNotesTemplatePayload({
4462
4755
  wallet,
4463
4756
  inputNotes,
@@ -4513,13 +4806,20 @@ async function handleWalletGetNotes({ args, provider }) {
4513
4806
  progressAction: "wallet get-notes",
4514
4807
  });
4515
4808
  const context = contextResult.context;
4516
- const noteReceiveFreshness = await ensureWalletNoteReceiveStateCurrent({
4517
- walletContext: wallet,
4518
- context,
4519
- provider,
4520
- progressAction: "wallet get-notes",
4521
- preConsumedBlockDelta: contextResult.autoRecoveryBlockDelta,
4522
- });
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
+ };
4523
4823
 
4524
4824
  const unusedTrackedNotes = wallet.wallet.notes.unusedOrder
4525
4825
  .map((commitment) => wallet.wallet.notes.unused[commitment])
@@ -4539,8 +4839,20 @@ async function handleWalletGetNotes({ args, provider }) {
4539
4839
  canonicalAssetDecimals,
4540
4840
  })));
4541
4841
 
4542
- const unusedTotal = unusedTrackedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n);
4543
- const spentTotal = spentTrackedNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n);
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;
4544
4856
 
4545
4857
  printJson({
4546
4858
  action: "wallet get-notes",
@@ -4550,22 +4862,447 @@ async function handleWalletGetNotes({ args, provider }) {
4550
4862
  controller: wallet.wallet.controller,
4551
4863
  unusedNotes,
4552
4864
  spentNotes,
4553
- unusedTotalBaseUnits: unusedTotal.toString(),
4554
- unusedTotalTokens: ethers.formatUnits(unusedTotal, canonicalAssetDecimals),
4555
- spentTotalBaseUnits: spentTotal.toString(),
4556
- 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),
4557
4869
  bridgeStatusMismatches: [...unusedNotes, ...spentNotes].filter((note) => !note.walletStatusMatchesBridge).length,
4558
4870
  noteReceiveLastScannedBlock: noteReceiveFreshness.nextBlock,
4559
4871
  latestBlock: noteReceiveFreshness.latestBlock,
4872
+ viewingKeyAvailable: Boolean(wallet.wallet.noteReceivePrivateKey),
4560
4873
  recoveredWalletWorkspace: noteReceiveFreshness.recoveredWalletWorkspace,
4561
4874
  recoveredFromLogs: noteReceiveFreshness.recoveredDeliveryState?.importedNotes ?? 0,
4562
4875
  scannedDeliveryLogs: noteReceiveFreshness.recoveredDeliveryState?.scannedLogs ?? 0,
4563
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
+ },
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
+ },
4564
5277
  });
4565
5278
  }
4566
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))];
5300
+ }
5301
+
4567
5302
  async function handleTransferNotes({ args, provider }) {
4568
5303
  const { wallet } = loadUnlockedWalletWithMetadata(args);
5304
+ requireWalletViewingCapability(wallet);
5305
+ requireWalletSpendingCapability(wallet);
4569
5306
  const { signer } = restoreWalletParticipant(wallet, provider);
4570
5307
  const preparedContextResult = await loadFreshWalletChannelContext({
4571
5308
  walletContext: wallet,
@@ -4603,6 +5340,19 @@ async function handleTransferNotes({ args, provider }) {
4603
5340
  "The sum of --amounts must equal the sum of the selected input note values.",
4604
5341
  );
4605
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
+ });
4606
5356
  const templatePayload = await buildTransferNotesTemplatePayload({
4607
5357
  context,
4608
5358
  signer,
@@ -4623,6 +5373,9 @@ async function handleTransferNotes({ args, provider }) {
4623
5373
  sourceFunction: templatePayload.method,
4624
5374
  sourceTxHash: execution.receipt.hash,
4625
5375
  bridgeCommitmentKeys: execution.noteLifecycle.outputCommitmentKeys,
5376
+ sourceBlockNumber: execution.receipt.blockNumber,
5377
+ counterpartyL2Addresses: templatePayload.recipientAddresses,
5378
+ counterpartyDirection: "sent",
4626
5379
  });
4627
5380
 
4628
5381
  printJson({
@@ -4753,6 +5506,7 @@ function ensureWallet({
4753
5506
  ensureDir(path.join(walletDir, "operations"));
4754
5507
 
4755
5508
  const wallet = normalizeWallet({
5509
+ walletFormatVersion: WALLET_WORKSPACE_FORMAT_VERSION,
4756
5510
  name: walletName,
4757
5511
  network: channelContext.workspace.network,
4758
5512
  rpcUrl,
@@ -4769,10 +5523,8 @@ function ensureWallet({
4769
5523
  l2AccountingVault: channelContext.workspace.l2AccountingVault,
4770
5524
  liquidBalancesSlot: channelContext.workspace.liquidBalancesSlot,
4771
5525
  l1Address: signerAddress,
4772
- l1PrivateKey: normalizePrivateKey(signerPrivateKey),
4773
5526
  l2Address: l2Identity.l2Address,
4774
- l2PrivateKey: ethers.hexlify(l2Identity.l2PrivateKey),
4775
- l2PublicKey: ethers.hexlify(l2Identity.l2PublicKey),
5527
+ l2PublicKey: l2Identity.l2PublicKey ? ethers.hexlify(l2Identity.l2PublicKey) : null,
4776
5528
  l2DerivationMode: CHANNEL_BOUND_L2_DERIVATION_MODE,
4777
5529
  l2DerivationChannelName: channelContext.workspace.channelName,
4778
5530
  l2StorageKey: storageKey,
@@ -4788,13 +5540,18 @@ function ensureWallet({
4788
5540
  spent: {},
4789
5541
  },
4790
5542
  });
5543
+ if (l2Identity.l2PrivateKey) {
5544
+ wallet.l2PrivateKey = ethers.hexlify(l2Identity.l2PrivateKey);
5545
+ }
5546
+ wallet.noteReceivePrivateKey = normalizePrivateKey(noteReceiveKeyMaterial.privateKey);
4791
5547
 
4792
5548
  const context = {
4793
5549
  walletName,
4794
5550
  walletDir,
4795
5551
  wallet,
4796
- walletSecret,
5552
+ walletSecret: wallet.l2PrivateKey,
4797
5553
  };
5554
+ persistWalletKeys(context);
4798
5555
  persistWallet(context);
4799
5556
  persistWalletMetadata(context);
4800
5557
  return context;
@@ -4810,9 +5567,9 @@ function normalizeWallet(wallet) {
4810
5567
  ...wallet,
4811
5568
  canonicalAssetDecimals: Number(wallet.canonicalAssetDecimals),
4812
5569
  l2Nonce: Number(wallet.l2Nonce),
4813
- l1PrivateKey: normalizePrivateKey(wallet.l1PrivateKey),
4814
- l2PrivateKey: ethers.hexlify(wallet.l2PrivateKey),
4815
- 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,
4816
5573
  noteReceiveDerivationVersion: Number(wallet.noteReceiveDerivationVersion),
4817
5574
  noteReceiveTypedDataMethod: wallet.noteReceiveTypedDataMethod,
4818
5575
  noteReceivePubKeyX: normalizeBytes32Hex(wallet.noteReceivePubKeyX),
@@ -4822,18 +5579,18 @@ function normalizeWallet(wallet) {
4822
5579
  unused: Object.fromEntries(unusedNotes.map((note) => [note.commitment, note])),
4823
5580
  spent: Object.fromEntries(spentNotes.map((note) => [note.nullifier, note])),
4824
5581
  unusedOrder: unusedNotes.map((note) => note.commitment),
4825
- 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,
4826
5585
  },
4827
5586
  };
4828
5587
  }
4829
5588
 
4830
5589
  function assertWalletHasCurrentFormat(wallet, walletName) {
4831
5590
  const requiredKeys = [
5591
+ "walletFormatVersion",
4832
5592
  "canonicalAssetDecimals",
4833
5593
  "l2Nonce",
4834
- "l1PrivateKey",
4835
- "l2PrivateKey",
4836
- "l2PublicKey",
4837
5594
  "noteReceiveDerivationVersion",
4838
5595
  "noteReceiveTypedDataMethod",
4839
5596
  "noteReceivePubKeyX",
@@ -4849,18 +5606,48 @@ function assertWalletHasCurrentFormat(wallet, walletName) {
4849
5606
  wallet.notes && typeof wallet.notes.unused === "object" && typeof wallet.notes.spent === "object",
4850
5607
  `Wallet ${walletName} was not created with the current CLI notes format.`,
4851
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
+ );
4852
5616
  }
4853
5617
 
4854
5618
  function normalizeTrackedNote(note) {
4855
5619
  return {
4856
- owner: getAddress(note.owner),
4857
- value: ethers.toBigInt(note.value).toString(),
4858
- 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,
4859
5623
  commitment: normalizeBytes32Hex(note.commitment),
4860
5624
  nullifier: normalizeBytes32Hex(note.nullifier),
5625
+ encryptedNoteValue: note.encryptedNoteValue ? normalizeEncryptedNoteValueWords(note.encryptedNoteValue) : null,
4861
5626
  status: note.status,
4862
5627
  sourceFunction: note.sourceFunction ?? null,
4863
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,
4864
5651
  bridgeCommitmentKey: note.bridgeCommitmentKey ? normalizeBytes32Hex(note.bridgeCommitmentKey) : null,
4865
5652
  bridgeNullifierKey: note.bridgeNullifierKey ? normalizeBytes32Hex(note.bridgeNullifierKey) : null,
4866
5653
  };
@@ -4886,15 +5673,28 @@ async function buildWalletNoteBridgeStatus({
4886
5673
  return {
4887
5674
  owner: note.owner,
4888
5675
  valueBaseUnits: note.value,
4889
- valueTokens: ethers.formatUnits(ethers.toBigInt(note.value), canonicalAssetDecimals),
5676
+ valueTokens: note.value === null ? null : ethers.formatUnits(ethers.toBigInt(note.value), canonicalAssetDecimals),
4890
5677
  commitment: note.commitment,
4891
5678
  nullifier: note.nullifier,
5679
+ encryptedNoteValue: note.encryptedNoteValue,
4892
5680
  walletStatus: note.status,
4893
5681
  bridgeCommitmentExists: commitmentExists,
4894
5682
  bridgeNullifierUsed: nullifierUsed,
4895
5683
  walletStatusMatchesBridge: commitmentExists && nullifierUsed === expectedNullifierUsed,
4896
5684
  sourceFunction: note.sourceFunction ?? null,
4897
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,
4898
5698
  };
4899
5699
  }
4900
5700
 
@@ -4912,8 +5712,8 @@ async function readBooleanStorageValueFromSnapshot({ snapshot, storageAddress, s
4912
5712
  }
4913
5713
 
4914
5714
  function compareNotesByValueDesc(left, right) {
4915
- const leftValue = ethers.toBigInt(left.value);
4916
- 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);
4917
5717
  if (leftValue === rightValue) {
4918
5718
  return left.commitment.localeCompare(right.commitment);
4919
5719
  }
@@ -4922,13 +5722,28 @@ function compareNotesByValueDesc(left, right) {
4922
5722
 
4923
5723
  function buildTrackedNote(note, sourceFunction, sourceTxHash, bridgeKeys = {}) {
4924
5724
  const normalizedNote = normalizePlaintextNote(note);
5725
+ const createdTxHash = bridgeKeys.createdAtTxHash ?? sourceTxHash ?? null;
5726
+ const createdFunction = bridgeKeys.createdByFunction ?? sourceFunction ?? null;
4925
5727
  return {
4926
5728
  ...normalizedNote,
4927
5729
  commitment: normalizeBytes32Hex(computeNoteCommitment(normalizedNote)),
4928
5730
  nullifier: normalizeBytes32Hex(computeNullifier(normalizedNote)),
5731
+ encryptedNoteValue: note.encryptedNoteValue ? normalizeEncryptedNoteValueWords(note.encryptedNoteValue) : null,
4929
5732
  status: "unused",
4930
5733
  sourceFunction,
4931
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,
4932
5747
  bridgeCommitmentKey: bridgeKeys.bridgeCommitmentKey
4933
5748
  ? normalizeBytes32Hex(bridgeKeys.bridgeCommitmentKey)
4934
5749
  : null,
@@ -4943,9 +5758,19 @@ function buildLifecycleTrackedOutputs({
4943
5758
  sourceFunction,
4944
5759
  sourceTxHash,
4945
5760
  bridgeCommitmentKeys,
5761
+ sourceBlockNumber = null,
5762
+ counterpartyL2Addresses = [],
5763
+ counterpartyDirection = null,
4946
5764
  }) {
4947
5765
  return (outputNotes ?? []).map((note, index) => buildTrackedNote(note, sourceFunction, sourceTxHash, {
4948
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,
4949
5774
  }));
4950
5775
  }
4951
5776
 
@@ -4958,13 +5783,11 @@ async function recoverWalletReceivedNotes({
4958
5783
  progressAction = null,
4959
5784
  fromGenesis = false,
4960
5785
  }) {
4961
- const resolvedNoteReceiveKeyMaterial = noteReceiveKeyMaterial ?? await deriveNoteReceiveKeyMaterial({
4962
- signer,
4963
- chainId: context.workspace.chainId,
4964
- channelId: context.workspace.channelId,
4965
- channelName: context.workspace.channelName,
4966
- account: signer.address,
4967
- });
5786
+ const resolvedNoteReceiveKeyMaterial = noteReceiveKeyMaterial ?? {
5787
+ privateKey: walletContext.wallet.noteReceivePrivateKey,
5788
+ noteReceivePubKey: walletNoteReceivePubKey(walletContext),
5789
+ };
5790
+ requireWalletViewingCapability(walletContext);
4968
5791
  const recoveredDeliveryState = await recoverDeliveredNotesFromEventLogs({
4969
5792
  walletContext,
4970
5793
  context,
@@ -5064,12 +5887,22 @@ async function recoverDeliveredNotesFromEventLogs({
5064
5887
  owner: walletContext.wallet.l2Address,
5065
5888
  value: recoveredValue,
5066
5889
  salt: computeEncryptedNoteSalt(encryptedNoteValue),
5890
+ encryptedNoteValue,
5067
5891
  });
5068
5892
  const commitment = normalizeBytes32Hex(computeNoteCommitment(plaintextNote));
5069
5893
  const nullifier = normalizeBytes32Hex(computeNullifier(plaintextNote));
5070
- const trackedNote = buildTrackedNote(plaintextNote, sourceFunction, log.transactionHash, {
5894
+ const trackedNote = buildTrackedNote({
5895
+ ...plaintextNote,
5896
+ encryptedNoteValue,
5897
+ }, sourceFunction, log.transactionHash, {
5071
5898
  bridgeCommitmentKey: derivePrivateStateControllerMappingStorageKey(commitment, commitmentExistsSlot),
5072
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",
5073
5906
  });
5074
5907
  const commitmentExists = await readBooleanStorageValueFromSnapshot({
5075
5908
  snapshot: context.currentSnapshot,
@@ -5420,7 +6253,7 @@ function snapshotRootForAddress(snapshot, storageAddress) {
5420
6253
  return snapshot.stateRoots[addressIndex];
5421
6254
  }
5422
6255
 
5423
- function applyNoteLifecycleToWallet(walletContext, lifecycle, sourceFunction, sourceTxHash) {
6256
+ function applyNoteLifecycleToWallet(walletContext, lifecycle, sourceFunction, sourceTxHash, sourceBlockNumber = null) {
5424
6257
  for (const [index, inputNote] of lifecycle.inputs.entries()) {
5425
6258
  const trackedInput = buildTrackedNote(inputNote, sourceFunction, sourceTxHash);
5426
6259
  const existingUnusedNote = walletContext.wallet.notes.unused[trackedInput.commitment];
@@ -5434,12 +6267,26 @@ function applyNoteLifecycleToWallet(walletContext, lifecycle, sourceFunction, so
5434
6267
  sourceFunction,
5435
6268
  sourceTxHash,
5436
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,
5437
6277
  };
5438
6278
  }
5439
6279
 
5440
6280
  for (const [index, outputNote] of lifecycle.outputs.entries()) {
5441
6281
  const trackedOutput = buildTrackedNote(outputNote, sourceFunction, sourceTxHash, {
5442
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,
5443
6290
  });
5444
6291
  if (trackedOutput.owner !== walletContext.wallet.l2Address) {
5445
6292
  continue;
@@ -5524,6 +6371,7 @@ function buildMintEncryptedOutputs({ wallet, values }) {
5524
6371
  owner: wallet.wallet.l2Address,
5525
6372
  value: ethers.toBigInt(value).toString(),
5526
6373
  salt: computeEncryptedNoteSalt(encryptedNoteValue),
6374
+ encryptedNoteValue,
5527
6375
  });
5528
6376
  }
5529
6377
  return {
@@ -5581,6 +6429,7 @@ async function buildTransferNotesTemplatePayload({
5581
6429
  owner: recipient,
5582
6430
  value: ethers.toBigInt(outputAmounts[index]).toString(),
5583
6431
  salt,
6432
+ encryptedNoteValue,
5584
6433
  });
5585
6434
  }
5586
6435
  return {
@@ -5589,6 +6438,7 @@ async function buildTransferNotesTemplatePayload({
5589
6438
  args: [transferOutputs, inputNotes],
5590
6439
  lifecycleInputs: inputNotes,
5591
6440
  lifecycleOutputs,
6441
+ recipientAddresses,
5592
6442
  };
5593
6443
  }
5594
6444
 
@@ -5609,6 +6459,10 @@ function loadWalletUnusedInputNotes(walletContext, noteIds) {
5609
6459
  return noteIds.map((noteId) => {
5610
6460
  const trackedNote = walletContext.wallet.notes.unused[noteId];
5611
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
+ );
5612
6466
  return normalizePlaintextNote(trackedNote);
5613
6467
  });
5614
6468
  }
@@ -5957,6 +6811,7 @@ async function executeWalletDirectTemplateCommand({
5957
6811
  }) {
5958
6812
  emitProgress(operationName, "loading");
5959
6813
  const { signer, l2Identity } = restoreWalletParticipant(wallet, provider);
6814
+ requireWalletSpendingCapability(wallet);
5960
6815
  const {
5961
6816
  txSubmitter,
5962
6817
  source: txSubmitterSource,
@@ -6098,7 +6953,16 @@ async function executeWalletTemplateSend({
6098
6953
 
6099
6954
  emitProgress(operationName, "persisting");
6100
6955
  wallet.wallet.l2Nonce = nonce + 1;
6101
- 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);
6102
6966
  context.currentSnapshot = nextSnapshot;
6103
6967
  persistWallet(wallet);
6104
6968
  await refreshPersistedWorkspaceAfterLocalTransaction({
@@ -6107,7 +6971,7 @@ async function executeWalletTemplateSend({
6107
6971
  receipt,
6108
6972
  progressAction: operationName,
6109
6973
  });
6110
- sealWalletOperationDir(operationDir, wallet.walletSecret);
6974
+ sealWalletOperationDir(operationDir, walletOperationSealSecret(wallet));
6111
6975
 
6112
6976
  return {
6113
6977
  wallet,
@@ -6195,34 +7059,54 @@ async function loadJoinChannelContext({ args, network, provider }) {
6195
7059
  };
6196
7060
  }
6197
7061
 
6198
- function loadWallet(walletName, walletSecret, networkName) {
7062
+ function loadWallet(walletName, networkName) {
6199
7063
  const normalizedWalletName = requireWalletName({ wallet: walletName });
6200
7064
  const normalizedNetworkName = requireNetworkName({ network: networkName });
6201
7065
  const walletDir = walletPath(normalizedWalletName, normalizedNetworkName);
6202
7066
  if (!walletConfigExists(walletDir)) {
6203
7067
  throw cliError(CLI_ERROR_CODES.UNKNOWN_WALLET, `Unknown wallet: ${normalizedWalletName} on ${normalizedNetworkName}.`);
6204
7068
  }
6205
- 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
+ }
6206
7087
  assertWalletHasRequiredKeys(rawWallet, normalizedWalletName);
6207
7088
  const wallet = normalizeWallet(rawWallet);
6208
7089
  assertWalletUsesChannelBoundDerivation(wallet, normalizedWalletName);
6209
- const restoredIdentity = restoreParticipantIdentityFromWallet(wallet);
6210
- expect(
6211
- wallet.l2Address === restoredIdentity.l2Address,
6212
- `Wallet ${normalizedWalletName} is internally inconsistent: stored keys do not match the stored L2 address.`,
6213
- );
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);
6214
7098
  const context = {
6215
7099
  walletName: normalizedWalletName,
6216
7100
  walletDir,
6217
7101
  wallet,
6218
- walletSecret,
7102
+ walletSecret: wallet.l2PrivateKey ?? wallet.noteReceivePrivateKey ?? null,
6219
7103
  };
6220
7104
  return context;
6221
7105
  }
6222
7106
 
6223
7107
  function loadUnlockedWalletWithMetadata(args) {
6224
7108
  const networkName = requireNetworkName(args);
6225
- const wallet = loadWallet(requireWalletName(args), requireWalletSecret(args), networkName);
7109
+ const wallet = loadWallet(requireWalletName(args), networkName);
6226
7110
  const walletMetadata = loadWalletMetadata(wallet.walletName, networkName);
6227
7111
  assertWalletMatchesMetadata(wallet, walletMetadata);
6228
7112
  expect(
@@ -6238,19 +7122,71 @@ function loadUnlockedWalletWithMetadata(args) {
6238
7122
  };
6239
7123
  }
6240
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
+
6241
7188
  function assertWalletHasRequiredKeys(wallet, walletName) {
6242
- expect(
6243
- typeof wallet.l1PrivateKey === "string" && wallet.l1PrivateKey.length > 0,
6244
- `Wallet ${walletName} is missing the stored L1 private key.`,
6245
- );
6246
- expect(
6247
- typeof wallet.l2PrivateKey === "string" && wallet.l2PrivateKey.length > 0,
6248
- `Wallet ${walletName} is missing the stored L2 private key.`,
6249
- );
6250
- expect(
6251
- typeof wallet.l2PublicKey === "string" && wallet.l2PublicKey.length > 0,
6252
- `Wallet ${walletName} is missing the stored L2 public key.`,
6253
- );
7189
+ expect(wallet.walletFormatVersion !== undefined, `Wallet ${walletName} is missing walletFormatVersion.`);
6254
7190
  }
6255
7191
 
6256
7192
  function assertWalletUsesChannelBoundDerivation(wallet, walletName) {
@@ -6271,6 +7207,13 @@ function assertWalletUsesChannelBoundDerivation(wallet, walletName) {
6271
7207
  }
6272
7208
 
6273
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
+ }
6274
7217
  const l2PrivateKey = Uint8Array.from(ethers.getBytes(wallet.l2PrivateKey));
6275
7218
  const l2PublicKey = Uint8Array.from(ethers.getBytes(wallet.l2PublicKey));
6276
7219
  const l2Address = getAddress(fromEdwardsToAddress(l2PublicKey).toString());
@@ -6282,7 +7225,14 @@ function restoreParticipantIdentityFromWallet(wallet) {
6282
7225
  }
6283
7226
 
6284
7227
  function restoreWalletSigner(walletContext, provider) {
6285
- 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
+ };
6286
7236
  }
6287
7237
 
6288
7238
  function restoreWalletParticipant(walletContext, provider) {
@@ -6292,6 +7242,75 @@ function restoreWalletParticipant(walletContext, provider) {
6292
7242
  };
6293
7243
  }
6294
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
+
6295
7314
  function loadBridgeResources({ chainId }) {
6296
7315
  const bridgeDeploymentPath = defaultBridgeDeploymentPath(chainId);
6297
7316
  const bridgeDeployment = readJson(bridgeDeploymentPath);
@@ -6313,7 +7332,7 @@ function loadWalletMetadata(walletName, networkName) {
6313
7332
  if (!walletConfigExists(walletDir)) {
6314
7333
  throw cliError(CLI_ERROR_CODES.UNKNOWN_WALLET, `Unknown wallet: ${normalizedWalletName} on ${normalizedNetworkName}.`);
6315
7334
  }
6316
- const metadataPath = walletMetadataPath(walletDir);
7335
+ const metadataPath = walletNotesMetadataPath(walletDir);
6317
7336
  if (!fs.existsSync(metadataPath)) {
6318
7337
  throw new Error(`Wallet ${normalizedWalletName} is missing unencrypted metadata at ${metadataPath}.`);
6319
7338
  }
@@ -6342,14 +7361,14 @@ function assertWalletMatchesMetadata(walletContext, walletMetadata) {
6342
7361
  walletContext.wallet.network === walletMetadata.network,
6343
7362
  [
6344
7363
  `Wallet ${walletContext.walletName} metadata network (${walletMetadata.network}) does not match`,
6345
- `the encrypted wallet network (${walletContext.wallet.network}).`,
7364
+ `the wallet note metadata network (${walletContext.wallet.network}).`,
6346
7365
  ].join(" "),
6347
7366
  );
6348
7367
  expect(
6349
7368
  walletContext.wallet.channelName === walletMetadata.channelName,
6350
7369
  [
6351
7370
  `Wallet ${walletContext.walletName} metadata channelName (${walletMetadata.channelName}) does not match`,
6352
- `the encrypted wallet channel (${walletContext.wallet.channelName}).`,
7371
+ `the wallet note metadata channel (${walletContext.wallet.channelName}).`,
6353
7372
  ].join(" "),
6354
7373
  );
6355
7374
  }
@@ -7535,6 +8554,7 @@ const OUTPUT_BYTES32_SCALAR_KEYS = new Set([
7535
8554
  "commitment",
7536
8555
  "currentRootVectorHash",
7537
8556
  "currentUserKey",
8557
+ "createdAtTxHash",
7538
8558
  "emittedRootVectorHash",
7539
8559
  "ephemeralPubKeyX",
7540
8560
  "hash",
@@ -7548,6 +8568,7 @@ const OUTPUT_BYTES32_SCALAR_KEYS = new Set([
7548
8568
  "rootVectorHash",
7549
8569
  "salt",
7550
8570
  "sourceTxHash",
8571
+ "spentAtTxHash",
7551
8572
  "topic0",
7552
8573
  "transactionHash",
7553
8574
  "txHash",
@@ -7744,6 +8765,13 @@ function parseArgs(argv) {
7744
8765
  && parsed.positional[1]
7745
8766
  ) {
7746
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
+ }
7747
8775
  parsed.positional = [parsed.command];
7748
8776
  }
7749
8777
  return parsed;
@@ -7773,18 +8801,6 @@ function parseTokenAmount(value, decimals) {
7773
8801
  }
7774
8802
  }
7775
8803
 
7776
- function requireWalletSecret(args) {
7777
- if (args.wallet !== undefined && args.network !== undefined) {
7778
- return resolveWalletSecretForName({
7779
- networkName: requireNetworkName(args),
7780
- walletName: requireWalletName(args),
7781
- });
7782
- }
7783
- throw new Error(
7784
- "Missing --wallet and --network. Wallet commands use the wallet-local default secret file.",
7785
- );
7786
- }
7787
-
7788
8804
  function requireArg(value, label) {
7789
8805
  if (value === undefined || value === null || value === "") {
7790
8806
  throw new Error(`Missing ${label}.`);
@@ -7849,6 +8865,13 @@ function requireL1Signer(args, provider) {
7849
8865
 
7850
8866
  function resolveTxSubmitterSigner({ args, ownerSigner, provider }) {
7851
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
+ );
7852
8875
  return {
7853
8876
  txSubmitter: ownerSigner,
7854
8877
  source: "wallet-owner",
@@ -7883,39 +8906,11 @@ function resolveStandalonePrivateKeySource(args) {
7883
8906
  ));
7884
8907
  }
7885
8908
 
7886
- function resolveWalletSecretForName({ networkName, walletName }) {
7887
- return resolveWalletDefaultSecret(networkName, walletName);
7888
- }
7889
-
7890
- function resolvedWalletSecretSource(args) {
7891
- if (args.walletSecretPath !== undefined) return "wallet-secret-path";
7892
- return "wallet-default";
7893
- }
7894
-
7895
- function resolvedWalletSecretFile(networkName, walletName) {
7896
- return walletSecretPath(networkName, walletName);
7897
- }
7898
-
7899
- function resolveWalletDefaultSecret(networkName, walletName) {
7900
- const secretPath = walletSecretPath(networkName, walletName);
7901
- if (!fs.existsSync(secretPath)) {
7902
- throw cliError(
7903
- CLI_ERROR_CODES.MISSING_WALLET_SECRET,
7904
- [
7905
- `Missing wallet default secret file: ${secretPath}.`,
7906
- "Run channel join with --wallet-secret-path before wallet commands.",
7907
- ].join(" "),
7908
- );
7909
- }
7910
- return readSecretFile(secretPath, "wallet default secret file");
7911
- }
7912
-
7913
8909
  function prepareJoinWalletSecretForName({
7914
8910
  args,
7915
8911
  networkName,
7916
8912
  walletName,
7917
8913
  }) {
7918
- const secretPath = walletSecretPath(networkName, walletName);
7919
8914
  const { channelName } = parseWalletName(walletName);
7920
8915
  const walletDir = walletPath(walletName, networkName);
7921
8916
  expect(
@@ -7930,14 +8925,7 @@ function prepareJoinWalletSecretForName({
7930
8925
  ].join(" "),
7931
8926
  );
7932
8927
  const sourcePath = path.resolve(String(requireArg(args.walletSecretPath, "--wallet-secret-path")));
7933
- const canonicalPath = path.resolve(secretPath);
7934
- const walletSecret = sourcePath === canonicalPath
7935
- ? readSecretFile(sourcePath, "--wallet-secret-path")
7936
- : readImportSecretSourceFile(sourcePath, "--wallet-secret-path");
7937
- if (sourcePath !== canonicalPath) {
7938
- writeSecretFile(canonicalPath, walletSecret);
7939
- }
7940
- return walletSecret;
8928
+ return readImportSecretSourceFile(sourcePath, "--wallet-secret-path");
7941
8929
  }
7942
8930
 
7943
8931
  function channelWorkspacePath(networkName, name) {
@@ -7986,6 +8974,24 @@ function walletSecretPath(networkName, walletName) {
7986
8974
  );
7987
8975
  }
7988
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
+
7989
8995
  function resolveWalletPathCandidates(walletName) {
7990
8996
  if (!fs.existsSync(workspaceRoot)) {
7991
8997
  return [];
@@ -8044,9 +9050,12 @@ function listLocalWallets({ networkFilter = null, channelFilter = null } = {}) {
8044
9050
  network: networkEntry.name,
8045
9051
  channelName: channelEntry.name,
8046
9052
  walletDir,
8047
- metadataPath: walletMetadataPath(walletDir),
8048
- hasMetadata: fs.existsSync(walletMetadataPath(walletDir)),
8049
- 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)),
8050
9059
  });
8051
9060
  }
8052
9061
  }
@@ -8074,8 +9083,8 @@ function resolveExportWalletInfo({ networkName, walletName }) {
8074
9083
  network: networkName,
8075
9084
  channelName: parseWalletName(walletName).channelName,
8076
9085
  walletDir,
8077
- metadataPath: walletMetadataPath(walletDir),
8078
- hasMetadata: fs.existsSync(walletMetadataPath(walletDir)),
9086
+ metadataPath: walletNotesMetadataPath(walletDir),
9087
+ hasMetadata: fs.existsSync(walletNotesMetadataPath(walletDir)),
8079
9088
  hasEncryptedWallet: walletConfigExists(walletDir),
8080
9089
  };
8081
9090
  }
@@ -8084,15 +9093,11 @@ function normalizeExportWalletInfo(walletInfo) {
8084
9093
  const wallet = requireWalletName({ wallet: walletInfo.wallet });
8085
9094
  const network = requireNetworkName({ network: walletInfo.network });
8086
9095
  const walletDir = walletInfo.walletDir ?? walletPath(wallet, network);
8087
- const metadataPath = walletMetadataPath(walletDir);
8088
- const encryptedWalletPath = walletConfigPath(walletDir);
9096
+ const metadataPath = walletNotesMetadataPath(walletDir);
8089
9097
  const metadata = readJsonIfExists(metadataPath);
8090
9098
  const channelName = metadata?.channelName ?? walletInfo.channelName ?? parseWalletName(wallet).channelName;
8091
- const walletSecret = walletSecretPath(network, wallet);
8092
9099
 
8093
- expect(fs.existsSync(encryptedWalletPath), `Wallet export cannot find encrypted wallet file: ${encryptedWalletPath}.`);
8094
9100
  expect(fs.existsSync(metadataPath), `Wallet export cannot find wallet metadata file: ${metadataPath}.`);
8095
- expect(fs.existsSync(walletSecret), `Wallet export cannot find wallet-local secret file: ${walletSecret}.`);
8096
9101
  expect(
8097
9102
  metadata.network === network,
8098
9103
  `Wallet export metadata network ${metadata.network} does not match ${network}.`,
@@ -8107,18 +9112,20 @@ function normalizeExportWalletInfo(walletInfo) {
8107
9112
  channelName,
8108
9113
  wallet,
8109
9114
  walletDir,
8110
- walletSecretPath: walletSecret,
8111
9115
  };
8112
9116
  }
8113
9117
 
8114
- function walletExportFilePaths(walletInfo, { includeNotes }) {
9118
+ function walletBackupExportFilePaths(walletInfo) {
8115
9119
  const walletFiles = [
8116
- walletInfo.walletSecretPath,
8117
- walletConfigPath(walletInfo.walletDir),
8118
- walletMetadataPath(walletInfo.walletDir),
9120
+ walletNotesMetadataPath(walletInfo.walletDir),
8119
9121
  ];
8120
- if (!includeNotes) {
8121
- 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
+ }
8122
9129
  }
8123
9130
 
8124
9131
  const workspaceDir = channelWorkspacePath(walletInfo.network, walletInfo.channelName);
@@ -8134,8 +9141,8 @@ function walletExportFilePaths(walletInfo, { includeNotes }) {
8134
9141
  expect(
8135
9142
  fs.existsSync(filePath),
8136
9143
  [
8137
- `wallet export --include-notes requires channel workspace cache file: ${filePath}.`,
8138
- "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.",
8139
9146
  ].join(" "),
8140
9147
  );
8141
9148
  }
@@ -8150,14 +9157,13 @@ function archivePathForLocalCliFile(filePath) {
8150
9157
  }
8151
9158
 
8152
9159
  function validateWalletExportManifest(manifest) {
8153
- 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.");
8154
9161
  expect(
8155
9162
  Number(manifest.formatVersion) === WALLET_EXPORT_FORMAT_VERSION,
8156
9163
  `Wallet import ZIP format version ${manifest?.formatVersion} is not supported.`,
8157
9164
  );
8158
9165
  expect(Array.isArray(manifest.files), "Wallet import ZIP manifest is missing files[].");
8159
9166
  expect(Array.isArray(manifest.wallets), "Wallet import ZIP manifest is missing wallets[].");
8160
- expect(typeof manifest.includeNotes === "boolean", "Wallet import ZIP manifest is missing includeNotes.");
8161
9167
  expect(manifest.wallets.length > 0, "Wallet import ZIP manifest does not list any wallets.");
8162
9168
  const uniqueFiles = new Set(manifest.files);
8163
9169
  expect(uniqueFiles.size === manifest.files.length, "Wallet import ZIP manifest contains duplicate file paths.");
@@ -8179,8 +9185,8 @@ function validateWalletArchivePath(archivePath) {
8179
9185
  expect(!path.posix.isAbsolute(archivePath), `Wallet import ZIP path must be relative: ${archivePath}.`);
8180
9186
  expect(path.posix.normalize(archivePath) === archivePath, `Wallet import ZIP path is not normalized: ${archivePath}.`);
8181
9187
  expect(
8182
- archivePath.startsWith("secrets/") || archivePath.startsWith("workspace/"),
8183
- `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}.`,
8184
9190
  );
8185
9191
  }
8186
9192
 
@@ -8191,9 +9197,9 @@ function expectPathWithinRoot(targetPath, rootPath, message) {
8191
9197
 
8192
9198
  function applyImportedWalletFileMode(archivePath, targetPath) {
8193
9199
  if (
8194
- archivePath.startsWith("secrets/")
8195
- || archivePath.endsWith("/wallet.json")
8196
- || 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")
8197
9203
  ) {
8198
9204
  protectSecretFile(targetPath, `imported wallet file ${archivePath}`);
8199
9205
  }
@@ -8215,16 +9221,20 @@ function channelWorkspaceOperationsPath(workspaceDir) {
8215
9221
  return path.join(channelDataPath(workspaceDir), "operations");
8216
9222
  }
8217
9223
 
8218
- function walletConfigPath(walletDir) {
8219
- return path.join(walletDir, "wallet.json");
9224
+ function walletNotesMetadataPath(walletDir) {
9225
+ return path.join(walletDir, "wallet-notes.metadata.json");
8220
9226
  }
8221
9227
 
8222
- function walletMetadataPath(walletDir) {
8223
- 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");
8224
9234
  }
8225
9235
 
8226
9236
  function walletConfigExists(walletDir) {
8227
- return fs.existsSync(walletConfigPath(walletDir));
9237
+ return fs.existsSync(walletNotesMetadataPath(walletDir));
8228
9238
  }
8229
9239
 
8230
9240
  const COMMAND_ARG_SCHEMAS = Object.freeze(
@@ -8281,6 +9291,7 @@ function assertWalletSecretArgs(args, commandName, extraOptionKeys = [], accepte
8281
9291
 
8282
9292
  function assertWalletChannelMoveArgs(args, commandName) {
8283
9293
  assertWalletSecretArgs(args, commandName, ["amount"], "--wallet, --network, and --amount");
9294
+ assertActionImpactArg(args, COMMAND_ARG_SCHEMAS[commandName]?.label ?? commandName);
8284
9295
  }
8285
9296
 
8286
9297
  function assertInstallZkEvmArgs(args) {
@@ -8332,12 +9343,17 @@ function assertTransactionFeesArgs(args) {
8332
9343
  assertAllowedCommandSchema(args, "help-transaction-fees");
8333
9344
  }
8334
9345
 
9346
+ function assertInvestigatorArgs(args) {
9347
+ assertAllowedCommandSchema(args, "investigator");
9348
+ }
9349
+
8335
9350
  function assertAccountImportArgs(args) {
8336
9351
  assertAllowedCommandSchema(args, "account-import");
8337
9352
  }
8338
9353
 
8339
9354
  function assertMintNotesArgs(args) {
8340
9355
  assertAllowedCommandSchema(args, "wallet-mint-notes");
9356
+ assertActionImpactArg(args, "wallet mint-notes");
8341
9357
  assertTxSubmitterArg(args);
8342
9358
  parseAmountVector(args.amounts, {
8343
9359
  allowZeroEntries: true,
@@ -8347,12 +9363,14 @@ function assertMintNotesArgs(args) {
8347
9363
 
8348
9364
  function assertRedeemNotesArgs(args) {
8349
9365
  assertAllowedCommandSchema(args, "wallet-redeem-notes");
9366
+ assertActionImpactArg(args, "wallet redeem-notes");
8350
9367
  assertTxSubmitterArg(args);
8351
9368
  selectRedeemNotesMethod(parseNoteIdVector(args.noteIds).length);
8352
9369
  }
8353
9370
 
8354
9371
  function assertTransferNotesArgs(args) {
8355
9372
  assertAllowedCommandSchema(args, "wallet-transfer-notes");
9373
+ assertActionImpactArg(args, "wallet transfer-notes");
8356
9374
  assertTxSubmitterArg(args);
8357
9375
  const noteIds = parseNoteIdVector(args.noteIds);
8358
9376
  const recipients = parseRecipientVector(args.recipients);
@@ -8373,8 +9391,34 @@ function assertTxSubmitterArg(args) {
8373
9391
  }
8374
9392
  }
8375
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
+
8376
9406
  function assertWalletGetNotesArgs(args) {
8377
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
+ }
8378
9422
  }
8379
9423
 
8380
9424
  function assertCreateChannelArgs(args) {
@@ -8408,6 +9452,7 @@ function assertPublishWorkspaceMirrorArgs(args) {
8408
9452
 
8409
9453
  function assertDepositBridgeArgs(args) {
8410
9454
  assertAllowedCommandSchema(args, "account-deposit-bridge");
9455
+ assertActionImpactArg(args, "account deposit-bridge");
8411
9456
  }
8412
9457
 
8413
9458
  function assertAccountGetBridgeFundArgs(args) {
@@ -8427,6 +9472,7 @@ function assertRecoverWalletArgs(args) {
8427
9472
 
8428
9473
  function assertJoinChannelArgs(args) {
8429
9474
  assertAllowedCommandSchema(args, "channel-join");
9475
+ assertActionImpactArg(args, "channel join");
8430
9476
  }
8431
9477
 
8432
9478
  function assertWalletGetMetaArgs(args) {
@@ -8447,34 +9493,31 @@ function assertListLocalWalletsArgs(args) {
8447
9493
  assertAllowedCommandSchema(args, "wallet-list");
8448
9494
  }
8449
9495
 
8450
- function assertWalletExportArgs(args) {
8451
- assertAllowedCommandSchema(args, "wallet-export");
8452
- assertFlagOption(args, "all", "wallet export");
8453
- 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);
8454
9505
  requireArg(args.output, "--output");
8455
- if (args.all === true) {
8456
- expect(
8457
- args.network === undefined && args.wallet === undefined,
8458
- "wallet export --all exports every local mainnet wallet and does not accept --network or --wallet.",
8459
- );
8460
- return;
8461
- }
8462
9506
  requireNetworkName(args);
8463
9507
  requireWalletName(args);
8464
9508
  }
8465
9509
 
8466
- function assertWalletImportArgs(args) {
8467
- assertAllowedCommandSchema(args, "wallet-import");
9510
+ function assertWalletImportBackupArgs(args) {
9511
+ assertAllowedCommandSchema(args, "wallet-import-backup");
8468
9512
  }
8469
9513
 
8470
- function assertFlagOption(args, key, commandName) {
8471
- if (args[key] !== undefined && args[key] !== true) {
8472
- throw new Error(`${commandName} option --${toKebabCase(key)} does not accept a value.`);
8473
- }
9514
+ function assertWalletImportKeyArgs(args, commandName) {
9515
+ assertAllowedCommandSchema(args, commandName);
8474
9516
  }
8475
9517
 
8476
9518
  function assertWithdrawBridgeArgs(args) {
8477
9519
  assertAllowedCommandSchema(args, "account-withdraw-bridge");
9520
+ assertActionImpactArg(args, "account withdraw-bridge");
8478
9521
  }
8479
9522
 
8480
9523
  function assertWalletGetChannelFundArgs(args) {
@@ -8497,14 +9540,126 @@ function createWalletOperationDir(walletName, networkName, suffix) {
8497
9540
  }
8498
9541
 
8499
9542
  function persistWallet(context) {
8500
- 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
+ }
8501
9577
  }
8502
9578
 
8503
9579
  function persistWalletMetadata(context) {
8504
- writeJson(walletMetadataPath(context.walletDir), {
8505
- network: context.wallet.network,
8506
- rpcUrl: context.wallet.rpcUrl,
8507
- 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
+ },
8508
9663
  });
8509
9664
  }
8510
9665
 
@@ -8524,10 +9679,10 @@ Secret source options:
8524
9679
  A wallet secret source file is arbitrary high-entropy secret text read once by channel join.
8525
9680
  Create one before joining a channel, for example:
8526
9681
  openssl rand -hex 32 > ./wallet-secret.txt
8527
- private-state-cli channel join --channel-name <NAME> --network <NAME> --account <NAME> --wallet-secret-path ./wallet-secret.txt
9682
+ private-state-cli channel join --channel-name <NAME> --network <NAME> --account <NAME> --wallet-secret-path ./wallet-secret.txt --acknowledge-action-impact
8528
9683
  Bridge-facing commands accept optional --rpc-url. When provided, it is saved to
8529
9684
  ~/tokamak-private-channels/secrets/<network>/.env as RPC_URL. When omitted, the CLI reads RPC_URL from that file.
8530
- Wallet commands use wallet-local default secret files only.
9685
+ Wallet commands use separate protected viewing-key and spending-key files when those capabilities are needed.
8531
9686
  Source files passed to --private-key-file and --wallet-secret-path are not required to use 0600 permissions, but
8532
9687
  canonical CLI secret files remain protected. On macOS/Linux this means 0600; on Windows the CLI repairs ACLs when possible.
8533
9688
 
@@ -8861,23 +10016,6 @@ function getUsableWorkspaceRecoveryIndex({
8861
10016
  };
8862
10017
  }
8863
10018
 
8864
- function writeEncryptedWalletJson(filePath, value, walletSecret) {
8865
- const normalizedValue = normalizeCliOutput(value);
8866
- writeEncryptedWalletFile(filePath, Buffer.from(`${JSON.stringify(normalizedValue, null, 2)}\n`, "utf8"), walletSecret);
8867
- }
8868
-
8869
- function readEncryptedWalletJson(filePath, walletSecret) {
8870
- try {
8871
- return JSON.parse(readEncryptedWalletFile(filePath, walletSecret).toString("utf8"));
8872
- } catch (error) {
8873
- throw cliError(
8874
- CLI_ERROR_CODES.WALLET_DECRYPT_FAILED,
8875
- `Unable to decrypt wallet data at ${filePath}. Check the wallet-local default secret file.`,
8876
- { cause: error },
8877
- );
8878
- }
8879
- }
8880
-
8881
10019
  function writeEncryptedWalletFile(filePath, plaintextBytes, walletSecret) {
8882
10020
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
8883
10021
  const salt = randomBytes(16);
@@ -8898,23 +10036,6 @@ function writeEncryptedWalletFile(filePath, plaintextBytes, walletSecret) {
8898
10036
  fs.writeFileSync(filePath, `${JSON.stringify(envelope, null, 2)}\n`);
8899
10037
  }
8900
10038
 
8901
- function readEncryptedWalletFile(filePath, walletSecret) {
8902
- const envelope = readJson(filePath);
8903
- expect(
8904
- envelope.version === WALLET_ENCRYPTION_VERSION
8905
- && envelope.algorithm === WALLET_ENCRYPTION_ALGORITHM
8906
- && envelope.kdf === "scrypt",
8907
- `Unsupported wallet encryption envelope at ${filePath}.`,
8908
- );
8909
- const encryptionKey = deriveWalletEncryptionKey(walletSecret, Buffer.from(ethers.getBytes(envelope.salt)));
8910
- const decipher = createDecipheriv("aes-256-gcm", encryptionKey, Buffer.from(ethers.getBytes(envelope.iv)));
8911
- decipher.setAuthTag(Buffer.from(ethers.getBytes(envelope.tag)));
8912
- return Buffer.concat([
8913
- decipher.update(Buffer.from(ethers.getBytes(envelope.ciphertext))),
8914
- decipher.final(),
8915
- ]);
8916
- }
8917
-
8918
10039
  function deriveWalletEncryptionKey(walletSecret, salt) {
8919
10040
  return scryptSync(String(walletSecret), salt, 32);
8920
10041
  }
@@ -8963,6 +10084,7 @@ function loadWalletCommandRuntime(args) {
8963
10084
 
8964
10085
  const HUMAN_RESULT_RENDERERS = Object.freeze({
8965
10086
  guide: printGuideHumanResult,
10087
+ investigator: printInvestigatorHumanResult,
8966
10088
  "transaction-fees": printTransactionFeesHumanResult,
8967
10089
  update: printUpdateHumanResult,
8968
10090
  });
@@ -9022,6 +10144,29 @@ function printGuideHumanResult(guide) {
9022
10144
  console.log(lines.join("\n"));
9023
10145
  }
9024
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
+
9025
10170
  function printTransactionFeesHumanResult(report) {
9026
10171
  const lines = [
9027
10172
  "Transaction Fees",
@@ -9356,17 +10501,6 @@ function buildRecoveryHints(error, args = {}) {
9356
10501
  hints.push(`private-state-cli help guide --network ${networkName} --wallet ${walletName}`);
9357
10502
  }
9358
10503
 
9359
- if (error?.code === CLI_ERROR_CODES.MISSING_WALLET_SECRET) {
9360
- hints.push("restore the wallet-local default secret file from backup before running wallet commands.");
9361
- hints.push(`private-state-cli help guide --network ${networkName} --wallet ${walletName}`);
9362
- }
9363
-
9364
- if (error?.code === CLI_ERROR_CODES.WALLET_DECRYPT_FAILED) {
9365
- hints.push("verify that the wallet-local default secret file is the same secret used when the wallet was created.");
9366
- hints.push("if the encrypted wallet file is corrupted but the wallet secret and L1 account secret still exist, rerun wallet recover-workspace.");
9367
- hints.push("if the wallet secret was lost, the local L2 key cannot be recovered from the encrypted wallet file.");
9368
- }
9369
-
9370
10504
  if (
9371
10505
  message.startsWith("Missing --account:")
9372
10506
  || message.includes("Missing --account.")
@@ -9384,7 +10518,7 @@ function buildRecoveryHints(error, args = {}) {
9384
10518
  }
9385
10519
 
9386
10520
  if (error?.code === CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION) {
9387
- hints.push(`private-state-cli channel join --channel-name ${channelName} --network ${networkName} --account ${accountName} --wallet-secret-path <PATH>`);
10521
+ hints.push(`private-state-cli channel join --channel-name ${channelName} --network ${networkName} --account ${accountName} --wallet-secret-path <PATH> --acknowledge-action-impact`);
9388
10522
  hints.push(`private-state-cli help guide --network ${networkName} --channel-name ${channelName} --account ${accountName}`);
9389
10523
  }
9390
10524