@tokamak-private-dapps/private-state-cli 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.3.1 - 2026-05-20
6
+
7
+ - Added `wallet recover-workspace --wallet-secret-path` support for rederiving and storing an active
8
+ wallet spending key from the original L1 account and wallet secret source.
9
+ - Validated recovered spending keys against the current on-chain L2 address and channel token-vault
10
+ storage key before received-note recovery starts.
11
+ - Documented the active-wallet-only spending-key recovery policy in the CLI README and private-state
12
+ DApp README.
13
+
14
+ ## 2.3.0 - 2026-05-20
15
+
16
+ - Added `help observer` to print the deployed public observer URL for the private-state monitoring
17
+ surface.
18
+ - Documented the deployed public observer in the monitoring audit packet and observability matrix.
19
+
5
20
  ## 2.2.1 - 2026-05-18
6
21
 
7
22
  - Added `channel recover-workspace --source rpc --output-raw` to append raw JSON-RPC request and response history
package/README.md CHANGED
@@ -388,6 +388,13 @@ flow. The spending key needs the same L1 private key, the same channel context,
388
388
  spending-key file is lost and the wallet secret source is also lost, the CLI cannot reconstruct the spending key and the
389
389
  notes for that wallet cannot be spent, transferred, or redeemed through the normal note flow.
390
390
 
391
+ `wallet recover-workspace` restores the viewing key by default. Add `--wallet-secret-path <PATH>` only when the
392
+ account is currently active in the channel and you need to rederive the spending key. In that mode, the CLI checks the
393
+ derived L2 address and channel token-vault storage key against the current on-chain registration before received-note
394
+ recovery starts, then stores the protected spending-key file. Exited or non-active accounts must be recovered without
395
+ `--wallet-secret-path`; that restores viewing/evidence history but not spending authority. The wallet secret source is
396
+ read for derivation and is not stored.
397
+
391
398
  ### Wallet Backup, Viewing, And Spending Authority
392
399
 
393
400
  The wallet workspace is split so that a backup is not a full-control wallet export. Backup metadata stores the
@@ -405,8 +412,9 @@ transactions and then proves authorized note use when inputs are consumed.
405
412
 
406
413
  Key recovery is intentionally split. Recreating the viewing key requires the original L1 private key and the same channel
407
414
  context. Recreating the spending key requires the original L1 private key, the same channel context, and the same wallet
408
- secret source used at `channel join`. Importing `wallet-viewing.key` or `wallet-spending.key` restores the corresponding
409
- capability without rerunning derivation, but a backup ZIP alone never restores either capability.
415
+ secret source used at `channel join`. `wallet recover-workspace --wallet-secret-path <PATH>` performs this spending-key
416
+ rederivation only for active channel registrations. Importing `wallet-viewing.key` or `wallet-spending.key` restores the
417
+ corresponding capability without rerunning derivation, but a backup ZIP alone never restores either capability.
410
418
 
411
419
  `wallet get-notes --export-evidence <PATH> --acknowledge-full-note-plaintext-export` writes a local raw evidence ZIP.
412
420
  The bundle is not a key export. It includes plaintext note facts for locally known notes so that
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  assertDoctorArgs,
3
3
  assertGuideArgs,
4
+ assertObserverArgs,
4
5
  assertInstallZkEvmArgs,
5
6
  assertSetRpcArgs,
6
7
  assertTransactionFeesArgs,
@@ -8,6 +9,7 @@ import {
8
9
  assertUpdateArgs,
9
10
  handleDoctor,
10
11
  handleGuide,
12
+ handleObserver,
11
13
  handleInstallZkEvm,
12
14
  handleSetRpc,
13
15
  handleTransactionFees,
@@ -41,6 +43,10 @@ export const systemCommands = Object.freeze({
41
43
  assertGuideArgs(args);
42
44
  await handleGuide({ args });
43
45
  },
46
+ "help-observer": async (args) => {
47
+ assertObserverArgs(args);
48
+ handleObserver();
49
+ },
44
50
  "help-transaction-fees": async (args) => {
45
51
  assertTransactionFeesArgs(args);
46
52
  const { network, provider, rpcUrl } = loadExplicitCommandRuntime(args);
@@ -329,6 +329,17 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
329
329
  usage: "optional --network, --channel-name, --account, and --wallet",
330
330
  help: ["Does not accept --rpc-url and never writes RPC configuration"],
331
331
  },
332
+ {
333
+ id: "help-observer",
334
+ display: "help observer",
335
+ description: "Show the deployed public observer URL.",
336
+ fields: [],
337
+ usage: "no options",
338
+ help: [
339
+ "Prints the deployed observer URL so terminals can present it as a clickable link",
340
+ "The observer is a public monitoring surface; it is not a wallet, key manager, or disclosure authority",
341
+ ],
342
+ },
332
343
  {
333
344
  id: "help-transaction-fees",
334
345
  display: "help transaction-fees",
@@ -473,11 +484,15 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
473
484
  display: "wallet recover-workspace",
474
485
  description: "Rebuild a recoverable local wallet from on-chain channel state.",
475
486
  installMode: "read-only",
476
- fields: ["channelName", "network", "account", "fromGenesis"],
477
- usage: "--channel-name, --network, --account, optional --from-genesis",
487
+ fields: ["channelName", "network", "account", "walletSecretPath", "fromGenesis"],
488
+ optionalFields: ["walletSecretPath"],
489
+ usage: "--channel-name, --network, --account, optional --wallet-secret-path, optional --from-genesis",
478
490
  help: [
479
- "Rebuilds backup metadata from channel state without recreating the spending key",
491
+ "Rebuilds backup metadata from channel state without recreating the spending key by default",
480
492
  "Derives and stores the viewing key when the local account signer can reproduce the registered viewing public key",
493
+ "Use --wallet-secret-path only for an active channel registration when you need to rederive and store the spending key",
494
+ "--wallet-secret-path requires the derived spending key to match the current on-chain L2 address and storage key before note recovery starts",
495
+ "Exited or non-active accounts can be recovered for viewing/evidence history only; omit --wallet-secret-path for those wallets",
481
496
  "Before wallet recovery, refreshes stale channel workspace state only when the saved recovery index delta fits the pre-command budget",
482
497
  "Fails and asks for channel recover-workspace first when the channel workspace is missing, unusable, or too stale for automatic recovery",
483
498
  "Use --from-genesis to restart received-note scanning from channel genesis; it does not rebuild the channel workspace from genesis",
package/lib/runtime.mjs CHANGED
@@ -137,6 +137,7 @@ const PRIVATE_STATE_UNINSTALL_CONFIRMATION =
137
137
  const ACTION_IMPACT_CONFIRMATION =
138
138
  "I understand the public and private impact of this action";
139
139
  const PRIVATE_STATE_CLI_PACKAGE_NAME = privateStateCliPackageJson.name;
140
+ const PRIVATE_STATE_OBSERVER_URL = "https://project-scw1r.vercel.app";
140
141
  const GROTH16_PACKAGE_NAME = "@tokamak-private-dapps/groth16";
141
142
  const TOKAMAK_ZKEVM_CLI_PACKAGE_NAME = "@tokamak-zk-evm/cli";
142
143
  const WALLET_BACKUP_EXPORT_FORMAT = "tokamak-private-state-wallet-backup-export";
@@ -2330,6 +2331,13 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2330
2331
  account: signer.address,
2331
2332
  });
2332
2333
  const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
2334
+ const recoveredSpendingIdentity = await deriveRecoverWalletSpendingIdentity({
2335
+ args,
2336
+ signer,
2337
+ channelName,
2338
+ context,
2339
+ registration,
2340
+ });
2333
2341
  const recoveryEventScan = await scanWalletRecoveryEvents({
2334
2342
  context,
2335
2343
  provider,
@@ -2357,7 +2365,27 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2357
2365
  Number(registeredNoteReceivePubKey.yParity) === Number(noteReceiveKeyMaterial.noteReceivePubKey.yParity),
2358
2366
  "The existing note-receive public key parity does not match the derived note-receive public key.",
2359
2367
  );
2360
- const l2Identity = {
2368
+ if (recoveredSpendingIdentity) {
2369
+ const expectedRecoveredStorageKey = deriveLiquidBalanceStorageKey(
2370
+ recoveredSpendingIdentity.l2Address,
2371
+ context.workspace.liquidBalancesSlot,
2372
+ );
2373
+ expect(
2374
+ lifecycleEpoch.lifecycleStatus === "active",
2375
+ "--wallet-secret-path can only recover the spending key for an active wallet epoch.",
2376
+ );
2377
+ expect(
2378
+ ethers.toBigInt(getAddress(lifecycleEpoch.l2Address))
2379
+ === ethers.toBigInt(getAddress(recoveredSpendingIdentity.l2Address)),
2380
+ "The recovered spending key does not match the recovered wallet lifecycle L2 address.",
2381
+ );
2382
+ expect(
2383
+ ethers.toBigInt(normalizeBytes32Hex(lifecycleEpoch.channelTokenVaultKey))
2384
+ === ethers.toBigInt(normalizeBytes32Hex(expectedRecoveredStorageKey)),
2385
+ "The recovered spending key does not match the recovered wallet lifecycle storage key.",
2386
+ );
2387
+ }
2388
+ const l2Identity = recoveredSpendingIdentity ?? {
2361
2389
  l2PrivateKey: null,
2362
2390
  l2PublicKey: null,
2363
2391
  l2Address: getAddress(lifecycleEpoch.l2Address),
@@ -2376,6 +2404,14 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2376
2404
  if (existingWallet) {
2377
2405
  existingWallet.wallet.noteReceivePrivateKey = noteReceiveKeyMaterial.privateKey;
2378
2406
  applyWalletLifecycleEpoch(existingWallet.wallet, lifecycleEpoch);
2407
+ if (recoveredSpendingIdentity) {
2408
+ existingWallet.wallet.l2PrivateKey = ethers.hexlify(recoveredSpendingIdentity.l2PrivateKey);
2409
+ existingWallet.wallet.l2PublicKey = ethers.hexlify(recoveredSpendingIdentity.l2PublicKey);
2410
+ existingWallet.wallet.l2Address = recoveredSpendingIdentity.l2Address;
2411
+ existingWallet.wallet.l2DerivationMode = CHANNEL_BOUND_L2_DERIVATION_MODE;
2412
+ existingWallet.wallet.l2DerivationChannelName = channelName;
2413
+ existingWallet.wallet.l2StorageKey = storageKey;
2414
+ }
2379
2415
  persistWalletKeys(existingWallet);
2380
2416
  persistWallet(existingWallet);
2381
2417
  persistWalletIndexForContext(existingWallet);
@@ -2408,6 +2444,7 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2408
2444
  l1Address: signer.address,
2409
2445
  l2Address: l2Identity.l2Address,
2410
2446
  l2StorageKey: storageKey,
2447
+ spendingKeyRecovered: Boolean(recoveredSpendingIdentity),
2411
2448
  leafIndex: lifecycleEpoch.leafIndex.toString(),
2412
2449
  epochId: lifecycleEpoch.epochId,
2413
2450
  lifecycleStatus: lifecycleEpoch.lifecycleStatus,
@@ -2467,6 +2504,7 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2467
2504
  l1Address: signer.address,
2468
2505
  l2Address: l2Identity.l2Address,
2469
2506
  l2StorageKey: storageKey,
2507
+ spendingKeyRecovered: Boolean(recoveredSpendingIdentity),
2470
2508
  leafIndex: lifecycleEpoch.leafIndex.toString(),
2471
2509
  epochId: lifecycleEpoch.epochId,
2472
2510
  lifecycleStatus: lifecycleEpoch.lifecycleStatus,
@@ -2480,6 +2518,41 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2480
2518
  });
2481
2519
  }
2482
2520
 
2521
+ async function deriveRecoverWalletSpendingIdentity({
2522
+ args,
2523
+ signer,
2524
+ channelName,
2525
+ context,
2526
+ registration,
2527
+ }) {
2528
+ if (args.walletSecretPath === undefined) {
2529
+ return null;
2530
+ }
2531
+ expect(
2532
+ registration.exists,
2533
+ [
2534
+ "--wallet-secret-path can only recover a spending key for an active channel registration.",
2535
+ "This account is not currently registered in the channel.",
2536
+ "Run wallet recover-workspace without --wallet-secret-path to recover viewing/evidence history for exited wallets.",
2537
+ ].join(" "),
2538
+ );
2539
+ const walletSecret = readWalletSecretSourceFile(args);
2540
+ const l2Identity = await deriveParticipantIdentityFromSigner({
2541
+ channelName,
2542
+ walletSecret,
2543
+ signer,
2544
+ });
2545
+ const expectedStorageKey = deriveLiquidBalanceStorageKey(
2546
+ l2Identity.l2Address,
2547
+ context.workspace.liquidBalancesSlot,
2548
+ );
2549
+ expect(
2550
+ walletRegistrationMatchesIdentity({ registration, l2Identity, expectedStorageKey }),
2551
+ "The recovered spending key does not match the current registered L2 address or channel token vault key.",
2552
+ );
2553
+ return l2Identity;
2554
+ }
2555
+
2483
2556
  async function handleInstallZkEvm({ args }) {
2484
2557
  const installMode = args.readOnly === true
2485
2558
  ? PRIVATE_STATE_INSTALL_MODES.READ_ONLY
@@ -3078,6 +3151,18 @@ async function handleDoctor({ args }) {
3078
3151
  }
3079
3152
  }
3080
3153
 
3154
+ function handleObserver() {
3155
+ printJson({
3156
+ action: "observer",
3157
+ url: PRIVATE_STATE_OBSERVER_URL,
3158
+ scope: "Public monitoring observer for Tokamak Private App Channels and the private-state DApp.",
3159
+ notes: [
3160
+ "The observer helps users and reviewers inspect public monitoring surfaces.",
3161
+ "The observer does not receive wallet secrets, spending keys, viewing keys, or private note plaintext.",
3162
+ ],
3163
+ });
3164
+ }
3165
+
3081
3166
  function handleInvestigator() {
3082
3167
  const htmlPath = resolveInvestigatorIndexPath();
3083
3168
  const fileUrl = pathToFileURL(htmlPath).href;
@@ -4295,10 +4380,7 @@ async function loadWalletChannelRegistrationState({
4295
4380
  const l2Identity = restoreParticipantIdentityFromWallet(walletContext.wallet);
4296
4381
  const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
4297
4382
  const expectedStorageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
4298
- const matchesWallet = registration.exists
4299
- && ethers.toBigInt(getAddress(registration.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address))
4300
- && ethers.toBigInt(normalizeBytes32Hex(registration.channelTokenVaultKey))
4301
- === ethers.toBigInt(normalizeBytes32Hex(expectedStorageKey));
4383
+ const matchesWallet = walletRegistrationMatchesIdentity({ registration, l2Identity, expectedStorageKey });
4302
4384
 
4303
4385
  if (requireRegistration) {
4304
4386
  expect(
@@ -4323,6 +4405,13 @@ async function loadWalletChannelRegistrationState({
4323
4405
  };
4324
4406
  }
4325
4407
 
4408
+ function walletRegistrationMatchesIdentity({ registration, l2Identity, expectedStorageKey }) {
4409
+ return registration.exists
4410
+ && ethers.toBigInt(getAddress(registration.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address))
4411
+ && ethers.toBigInt(normalizeBytes32Hex(registration.channelTokenVaultKey))
4412
+ === ethers.toBigInt(normalizeBytes32Hex(expectedStorageKey));
4413
+ }
4414
+
4326
4415
  function selectWalletLifecycleEpoch({ epochs, registration = null }) {
4327
4416
  if (registration?.exists) {
4328
4417
  const active = [...epochs].reverse().find((epoch) => (
@@ -9722,6 +9811,10 @@ function prepareJoinWalletSecretForName({
9722
9811
  `For exited history, keep using wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account ${args.account ?? "<ACCOUNT>"}.`,
9723
9812
  ].join(" "),
9724
9813
  );
9814
+ return readWalletSecretSourceFile(args);
9815
+ }
9816
+
9817
+ function readWalletSecretSourceFile(args) {
9725
9818
  const sourcePath = path.resolve(String(requireArg(args.walletSecretPath, "--wallet-secret-path")));
9726
9819
  return readImportSecretSourceFile(sourcePath, "--wallet-secret-path");
9727
9820
  }
@@ -10255,6 +10348,10 @@ function assertGuideArgs(args) {
10255
10348
  assertAllowedCommandSchema(args, "help-guide");
10256
10349
  }
10257
10350
 
10351
+ function assertObserverArgs(args) {
10352
+ assertAllowedCommandSchema(args, "help-observer");
10353
+ }
10354
+
10258
10355
  function assertTransactionFeesArgs(args) {
10259
10356
  assertAllowedCommandSchema(args, "help-transaction-fees");
10260
10357
  }
@@ -11206,6 +11303,7 @@ function loadWalletCommandRuntime(args, { prepareArtifacts = false } = {}) {
11206
11303
  const HUMAN_RESULT_RENDERERS = Object.freeze({
11207
11304
  guide: printGuideHumanResult,
11208
11305
  investigator: printInvestigatorHumanResult,
11306
+ observer: printObserverHumanResult,
11209
11307
  "transaction-fees": printTransactionFeesHumanResult,
11210
11308
  update: printUpdateHumanResult,
11211
11309
  });
@@ -11288,6 +11386,24 @@ function printInvestigatorHumanResult(result) {
11288
11386
  console.log(lines.join("\n"));
11289
11387
  }
11290
11388
 
11389
+ function printObserverHumanResult(result) {
11390
+ const lines = [
11391
+ "Private-State Public Observer",
11392
+ `URL: ${formatHumanValue(result.url)}`,
11393
+ ];
11394
+ if (result.scope) {
11395
+ lines.push(`Scope: ${formatHumanValue(result.scope)}`);
11396
+ }
11397
+ if (Array.isArray(result.notes) && result.notes.length > 0) {
11398
+ lines.push(
11399
+ "",
11400
+ "Notes",
11401
+ ...result.notes.map((note) => `- ${note}`),
11402
+ );
11403
+ }
11404
+ console.log(lines.join("\n"));
11405
+ }
11406
+
11291
11407
  function printTransactionFeesHumanResult(report) {
11292
11408
  const lines = [
11293
11409
  "Transaction Fees",
@@ -11700,6 +11816,7 @@ export {
11700
11816
  assertUpdateArgs,
11701
11817
  assertDoctorArgs,
11702
11818
  assertGuideArgs,
11819
+ assertObserverArgs,
11703
11820
  assertTransactionFeesArgs,
11704
11821
  assertInvestigatorArgs,
11705
11822
  assertAccountGetL1AddressArgs,
@@ -11733,6 +11850,7 @@ export {
11733
11850
  handleUpdate,
11734
11851
  handleDoctor,
11735
11852
  handleGuide,
11853
+ handleObserver,
11736
11854
  handleTransactionFees,
11737
11855
  handleInvestigator,
11738
11856
  handleAccountGetL1Address,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamak-private-dapps/private-state-cli",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "Command-line client for the Tokamak private-state DApp.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": "Tokamak Network",