@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 +9 -0
- package/README.md +10 -2
- package/lib/private-state-cli-command-registry.mjs +7 -3
- package/lib/runtime.mjs +85 -5
- package/package.json +1 -1
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`.
|
|
409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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