@tokamak-private-dapps/private-state-cli 2.3.0 → 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,15 @@
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
+
5
14
  ## 2.3.0 - 2026-05-20
6
15
 
7
16
  - Added `help observer` to print the deployed public observer URL for the private-state monitoring
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
@@ -484,11 +484,15 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
484
484
  display: "wallet recover-workspace",
485
485
  description: "Rebuild a recoverable local wallet from on-chain channel state.",
486
486
  installMode: "read-only",
487
- fields: ["channelName", "network", "account", "fromGenesis"],
488
- 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",
489
490
  help: [
490
- "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",
491
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",
492
496
  "Before wallet recovery, refreshes stale channel workspace state only when the saved recovery index delta fits the pre-command budget",
493
497
  "Fails and asks for channel recover-workspace first when the channel workspace is missing, unusable, or too stale for automatic recovery",
494
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
@@ -2331,6 +2331,13 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2331
2331
  account: signer.address,
2332
2332
  });
2333
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
+ });
2334
2341
  const recoveryEventScan = await scanWalletRecoveryEvents({
2335
2342
  context,
2336
2343
  provider,
@@ -2358,7 +2365,27 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2358
2365
  Number(registeredNoteReceivePubKey.yParity) === Number(noteReceiveKeyMaterial.noteReceivePubKey.yParity),
2359
2366
  "The existing note-receive public key parity does not match the derived note-receive public key.",
2360
2367
  );
2361
- 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 ?? {
2362
2389
  l2PrivateKey: null,
2363
2390
  l2PublicKey: null,
2364
2391
  l2Address: getAddress(lifecycleEpoch.l2Address),
@@ -2377,6 +2404,14 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2377
2404
  if (existingWallet) {
2378
2405
  existingWallet.wallet.noteReceivePrivateKey = noteReceiveKeyMaterial.privateKey;
2379
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
+ }
2380
2415
  persistWalletKeys(existingWallet);
2381
2416
  persistWallet(existingWallet);
2382
2417
  persistWalletIndexForContext(existingWallet);
@@ -2409,6 +2444,7 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2409
2444
  l1Address: signer.address,
2410
2445
  l2Address: l2Identity.l2Address,
2411
2446
  l2StorageKey: storageKey,
2447
+ spendingKeyRecovered: Boolean(recoveredSpendingIdentity),
2412
2448
  leafIndex: lifecycleEpoch.leafIndex.toString(),
2413
2449
  epochId: lifecycleEpoch.epochId,
2414
2450
  lifecycleStatus: lifecycleEpoch.lifecycleStatus,
@@ -2468,6 +2504,7 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2468
2504
  l1Address: signer.address,
2469
2505
  l2Address: l2Identity.l2Address,
2470
2506
  l2StorageKey: storageKey,
2507
+ spendingKeyRecovered: Boolean(recoveredSpendingIdentity),
2471
2508
  leafIndex: lifecycleEpoch.leafIndex.toString(),
2472
2509
  epochId: lifecycleEpoch.epochId,
2473
2510
  lifecycleStatus: lifecycleEpoch.lifecycleStatus,
@@ -2481,6 +2518,41 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2481
2518
  });
2482
2519
  }
2483
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
+
2484
2556
  async function handleInstallZkEvm({ args }) {
2485
2557
  const installMode = args.readOnly === true
2486
2558
  ? PRIVATE_STATE_INSTALL_MODES.READ_ONLY
@@ -4308,10 +4380,7 @@ async function loadWalletChannelRegistrationState({
4308
4380
  const l2Identity = restoreParticipantIdentityFromWallet(walletContext.wallet);
4309
4381
  const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
4310
4382
  const expectedStorageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
4311
- const matchesWallet = registration.exists
4312
- && ethers.toBigInt(getAddress(registration.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address))
4313
- && ethers.toBigInt(normalizeBytes32Hex(registration.channelTokenVaultKey))
4314
- === ethers.toBigInt(normalizeBytes32Hex(expectedStorageKey));
4383
+ const matchesWallet = walletRegistrationMatchesIdentity({ registration, l2Identity, expectedStorageKey });
4315
4384
 
4316
4385
  if (requireRegistration) {
4317
4386
  expect(
@@ -4336,6 +4405,13 @@ async function loadWalletChannelRegistrationState({
4336
4405
  };
4337
4406
  }
4338
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
+
4339
4415
  function selectWalletLifecycleEpoch({ epochs, registration = null }) {
4340
4416
  if (registration?.exists) {
4341
4417
  const active = [...epochs].reverse().find((epoch) => (
@@ -9735,6 +9811,10 @@ function prepareJoinWalletSecretForName({
9735
9811
  `For exited history, keep using wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account ${args.account ?? "<ACCOUNT>"}.`,
9736
9812
  ].join(" "),
9737
9813
  );
9814
+ return readWalletSecretSourceFile(args);
9815
+ }
9816
+
9817
+ function readWalletSecretSourceFile(args) {
9738
9818
  const sourcePath = path.resolve(String(requireArg(args.walletSecretPath, "--wallet-secret-path")));
9739
9819
  return readImportSecretSourceFile(sourcePath, "--wallet-secret-path");
9740
9820
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamak-private-dapps/private-state-cli",
3
- "version": "2.3.0",
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",