@tokamak-private-dapps/private-state-cli 2.1.0 → 2.1.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,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.1.1 - 2026-05-14
6
+
7
+ - Changed `channel recover-workspace --from-genesis` to move any existing local channel workspace
8
+ to `workspace-rebuild-backups/` before writing the current-format workspace. The clean rebuild
9
+ path is limited to workspace files and preserves local account and wallet key secrets under
10
+ `secrets/`.
11
+ - Added channel workspace recovery checkpointing at the existing RPC log chunk boundary so
12
+ interrupted RPC recovery can resume from the last completed chunk.
13
+ - Changed mirror recovery to fall back to a newer verified full mirror checkpoint when no matching
14
+ delta bundle exists for the local recovery index.
15
+ - Changed `wallet recover-workspace` to use the same bounded channel-workspace freshness preflight
16
+ as other wallet commands. `wallet recover-workspace --from-genesis` now restarts received-note
17
+ scanning from channel genesis but does not rebuild the channel workspace from genesis.
18
+ - Added received-note recovery checkpointing at the existing RPC log chunk boundary so ordinary
19
+ `wallet recover-workspace` resumes from the last completed chunk after an interruption.
20
+
5
21
  ## 2.1.0 - 2026-05-14
6
22
 
7
23
  - Required current epoch-aware wallet workspaces for wallet commands and backup imports. Local
package/README.md CHANGED
@@ -151,16 +151,25 @@ Static warning scope:
151
151
  centralized-exchange controlled address as a self-custody bridge source or as the direct bridge withdrawal target
152
152
  unless the user explicitly understands the compliance implications. Prefer a self-custody L1 wallet.
153
153
 
154
- Workspace recovery commands use the saved recovery index by default. If the local workspace is missing, corrupted, or
155
- does not contain a usable index, `channel recover-workspace` and `wallet recover-workspace` stop with an explicit error instead of
156
- silently replaying logs from channel genesis. Use `--source rpc --from-genesis` only when you intentionally want to
157
- rebuild channel workspace state from the channel creation block:
154
+ Workspace recovery commands use saved recovery indexes by default. If the local channel workspace is missing,
155
+ corrupted, or does not contain a usable index, `channel recover-workspace` stops with an explicit error instead of
156
+ silently replaying logs from channel genesis. Use `channel recover-workspace --source rpc --from-genesis` only when
157
+ you intentionally want to rebuild channel workspace state from the channel creation block:
158
158
 
159
159
  ```bash
160
160
  private-state-cli channel recover-workspace --channel-name <CHANNEL> --network mainnet --source rpc --from-genesis
161
- private-state-cli wallet recover-workspace --channel-name <CHANNEL> --network mainnet --account <ACCOUNT> --from-genesis
162
161
  ```
163
162
 
163
+ When `channel recover-workspace --from-genesis` is used, the CLI treats the local channel workspace as a clean rebuild target.
164
+ If `~/tokamak-private-channels/workspace/<network>/<channel>` already exists, it is moved under
165
+ `~/tokamak-private-channels/workspace-rebuild-backups/` before the current-format workspace is
166
+ created. This backup step is workspace-only; files under `~/tokamak-private-channels/secrets/`,
167
+ including account private keys and wallet viewing/spending key files, are not removed.
168
+ During RPC recovery, the CLI writes a usable channel workspace checkpoint after each RPC log chunk. If an RPC recovery
169
+ run is interrupted, the next non-`--from-genesis` RPC recovery resumes from the last completed chunk. Mirror recovery
170
+ can also start from that local checkpoint: it uses a matching delta bundle when one is available, otherwise a newer
171
+ verified full mirror checkpoint replaces the local checkpoint before RPC catch-up.
172
+
164
173
  `channel create` is the exception: after the channel is created on-chain, the CLI initializes that new local workspace
165
174
  by replaying from the channel's genesis block because no prior recovery index can exist for a new channel.
166
175
 
@@ -169,12 +178,18 @@ registration transaction. For a channel that was created elsewhere, run `channel
169
178
  once before joining, or recover from a registered workspace mirror; later joins and wallet commands resume from the
170
179
  saved index instead of silently replaying from genesis.
171
180
 
172
- Wallet getter commands that need channel state, including `wallet get-meta`, `wallet get-channel-fund`, and
173
- `wallet get-notes`, refresh stale local workspaces through saved recovery indexes before reading state. `wallet get-notes`
174
- also refreshes received-note logs through the saved wallet note recovery index. Automatic refresh never replays from
175
- channel genesis and only runs when the recovery delta fits within the 7,200-block pre-command budget. If a saved index
176
- is missing, unusable, or too far behind, the command stops and asks the user to run the appropriate recovery command
177
- with `--from-genesis` explicitly when needed.
181
+ Wallet commands that need channel state, including `wallet recover-workspace`, `wallet get-meta`,
182
+ `wallet get-channel-fund`, and `wallet get-notes`, refresh stale local channel workspaces through saved recovery
183
+ indexes before reading state. `wallet get-notes` and `wallet recover-workspace` also refresh received-note logs
184
+ through the saved wallet note recovery index. Automatic refresh never replays from channel genesis and only runs when
185
+ the recovery delta fits within the 7,200-block pre-command budget. If a saved index is missing, unusable, or too far
186
+ behind, the command stops and asks the user to run the appropriate recovery command first.
187
+
188
+ Wallet note-delivery recovery checkpoints after each RPC log chunk by updating
189
+ `noteReceiveLastScannedBlock`. If an ordinary `wallet recover-workspace` run is interrupted during note recovery, the
190
+ next run resumes from the last completed chunk. This does not add a special resume path for
191
+ `wallet recover-workspace --from-genesis`; that command intentionally starts received-note scanning from channel
192
+ genesis after the channel workspace has passed the same freshness preflight used by other wallet commands.
178
193
 
179
194
  Local wallet workspaces are epoch-aware. Each successful channel registration creates a wallet epoch under the
180
195
  canonical wallet directory. `channel exit` does not delete the local wallet workspace; it marks the active epoch as
@@ -419,6 +434,12 @@ Operating rules:
419
434
  - A workspace recovery index is the saved block pointer and state-root hash that lets the CLI resume log scanning
420
435
  without replaying the channel from its creation block. If it is missing, explain `--from-genesis` before using it
421
436
  because genesis replay can take much longer.
437
+ - Before guiding a user to run `channel recover-workspace --source rpc --from-genesis`, explain that RPC genesis
438
+ recovery can be very slow because it scans channel logs from the creation block. If a channel workspace mirror is
439
+ available, try mirror-based recovery first, and use RPC genesis replay only when mirror recovery is unavailable or
440
+ unsuitable.
441
+ - When a CLI command fails, read the error message and any printed `Try:` hints first. Prefer the corrective action
442
+ suggested by the CLI before inventing a different recovery sequence.
422
443
  - When the user does not have a network RPC URL yet, explain that they need an Ethereum JSON-RPC endpoint for the
423
444
  selected network. They can obtain one from an infrastructure provider such as Alchemy, Infura, QuickNode, or from
424
445
  their own node. Ask the user to create or select the endpoint in that provider's UI, then paste only the endpoint URL
@@ -481,7 +502,7 @@ Suggested interaction flow:
481
502
  `wallet mint-notes`.
482
503
  7. For a confidential note transfer, select available note IDs from `wallet get-notes`, find the recipient L2 address from
483
504
  `wallet get-meta`, then build `wallet transfer-notes`.
484
- 8. After transfer, guide the recipient to run `wallet get-notes`; it refreshes received notes from the saved recovery index when the delta fits the 7,200-block pre-command budget. If the index is missing or too far behind, explain `wallet recover-workspace --from-genesis`.
505
+ 8. After transfer, guide the recipient to run `wallet get-notes`; it refreshes received notes from the saved recovery index when the delta fits the 7,200-block pre-command budget. If the index is missing or too far behind, explain `wallet recover-workspace`.
485
506
 
486
507
  Example onboarding explanation for `channel join`:
487
508
 
@@ -337,8 +337,11 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
337
337
  help: [
338
338
  "By default, --source rpc resumes RPC log scanning from the workspace recovery index when available",
339
339
  "--source mirror validates the channel leader's registered checkpoint manifest, downloads only the needed checkpoint or delta bundle, and then replays RPC logs to latest",
340
+ "RPC recovery writes a usable channel workspace checkpoint after each RPC log chunk, so interrupted runs can resume from the last completed chunk",
341
+ "Mirror recovery uses a matching delta bundle when available; otherwise a newer verified full checkpoint replaces the local checkpoint before RPC catch-up",
340
342
  "Fails instead of falling back to genesis when no usable recovery index exists",
341
343
  "Use --source rpc --from-genesis to ignore the recovery index and replay logs from channel genesis",
344
+ "--from-genesis moves the existing local channel workspace to workspace-rebuild-backups before writing the current-format workspace; local secrets are preserved",
342
345
  "Prints RPC log scan progress while rebuilding the workspace",
343
346
  ],
344
347
  },
@@ -410,10 +413,11 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
410
413
  help: [
411
414
  "Rebuilds backup metadata from channel state without recreating the spending key",
412
415
  "Derives and stores the viewing key when the local account signer can reproduce the registered viewing public key",
413
- "By default, resumes RPC log scanning from the workspace recovery index when available",
414
- "Fails instead of falling back to genesis when no usable recovery index exists",
415
- "Use --from-genesis to ignore the recovery index and replay channel logs from channel genesis",
416
- "Prints RPC log scan progress while rebuilding channel state and received-note state",
416
+ "Before wallet recovery, refreshes stale channel workspace state only when the saved recovery index delta fits the pre-command budget",
417
+ "Fails and asks for channel recover-workspace first when the channel workspace is missing, unusable, or too stale for automatic recovery",
418
+ "Use --from-genesis to restart received-note scanning from channel genesis; it does not rebuild the channel workspace from genesis",
419
+ "Received-note recovery checkpoints after each RPC log chunk during ordinary recovery; --from-genesis still starts received-note scanning from channel genesis",
420
+ "Prints RPC log scan progress while refreshing channel state and rebuilding received-note state",
417
421
  ],
418
422
  },
419
423
  {
@@ -619,7 +623,7 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
619
623
  help: [
620
624
  "Refreshes the local channel workspace through the saved recovery index before reading notes when the scan fits the 10 second pre-command budget",
621
625
  "Refreshes received-note logs through the saved wallet note recovery index when the scan fits the 10 second pre-command budget",
622
- "Fails instead of replaying from genesis; run wallet recover-workspace --from-genesis when a genesis rebuild is required",
626
+ "Fails instead of replaying from genesis; run wallet recover-workspace first when explicit wallet recovery is required",
623
627
  "Use --export-evidence <PATH> with --acknowledge-full-note-plaintext-export to write a local full-note evidence ZIP for private-state-cli investigator",
624
628
  "Evidence export includes all local epochs for the selected wallet, including exited epochs retained for dispute evidence",
625
629
  ],
@@ -1,7 +1,12 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { AbiCoder, ethers } from "ethers";
3
- import { deriveL2KeysFromSignature, poseidon } from "tokamak-l2js";
3
+ import { deriveL2KeysFromSignature } from "tokamak-l2js";
4
4
  import { jubjub } from "@noble/curves/jubjub";
5
+ import {
6
+ normalizeBytesHex,
7
+ normalizeBytes32Hex,
8
+ poseidonHexFromBytes,
9
+ } from "@tokamak-private-dapps/common-library/tokamak-l2-helpers";
5
10
 
6
11
  const abiCoder = AbiCoder.defaultAbiCoder();
7
12
 
@@ -42,18 +47,6 @@ function expect(condition, message) {
42
47
  }
43
48
  }
44
49
 
45
- function normalizeBytes32Hex(value) {
46
- return ethers.hexlify(ethers.zeroPadValue(ethers.hexlify(value), 32)).toLowerCase();
47
- }
48
-
49
- function normalizeBytes16Hex(value) {
50
- return ethers.hexlify(ethers.zeroPadValue(ethers.hexlify(value), 16)).toLowerCase();
51
- }
52
-
53
- function poseidonHexFromBytes(bytesLike) {
54
- return ethers.hexlify(poseidon(ethers.getBytes(bytesLike))).toLowerCase();
55
- }
56
-
57
50
  function noteReceivePubKeyFromPoint(point) {
58
51
  const affine = point.toAffine();
59
52
  return {
@@ -371,7 +364,7 @@ function decryptFieldEncryptedNoteValue({
371
364
  encryptionInfo,
372
365
  });
373
366
  expect(
374
- normalizeBytes16Hex(expectedTag) === normalizeBytes16Hex(normalized.tag),
367
+ normalizeBytesHex(expectedTag, 16) === normalizeBytesHex(normalized.tag, 16),
375
368
  "Encrypted note value integrity tag mismatch.",
376
369
  );
377
370
  const fieldMask = deriveFieldMask({
@@ -9,7 +9,6 @@ import { fetchNpmPackageMetadata } from "@tokamak-private-dapps/common-library/n
9
9
  import {
10
10
  normalizePackageVersionToCompatibleBackendVersion,
11
11
  readTokamakZkEvmCompatibleBackendVersionFromPackageJson,
12
- requireCanonicalCompatibleBackendVersion,
13
12
  requireExactSemverVersion,
14
13
  } from "@tokamak-private-dapps/common-library/proof-backend-versioning";
15
14
  import {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamak-private-dapps/private-state-cli",
3
- "version": "2.1.0",
3
+ "version": "2.1.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",
@@ -44,7 +44,7 @@
44
44
  "dependencies": {
45
45
  "@ethereumjs/util": "^10.1.1",
46
46
  "@noble/curves": "1.9.7",
47
- "@tokamak-private-dapps/common-library": "^0.1.1",
47
+ "@tokamak-private-dapps/common-library": "^0.1.2",
48
48
  "@tokamak-private-dapps/groth16": "^0.2.0",
49
49
  "@tokamak-zk-evm/cli": "^2.1.0",
50
50
  "adm-zip": "^0.5.17",
@@ -44,6 +44,18 @@ import {
44
44
  requireCanonicalCompatibleBackendVersion,
45
45
  requireExactSemverVersion,
46
46
  } from "@tokamak-private-dapps/common-library/proof-backend-versioning";
47
+ import {
48
+ bigintToHex32,
49
+ buildStateManager,
50
+ buildTokamakTxSnapshot,
51
+ currentStorageBigInt,
52
+ deriveChannelTokenVaultLeafIndex,
53
+ deriveLiquidBalanceStorageKey,
54
+ fetchContractCodes,
55
+ normalizeBytesHex,
56
+ normalizeBytes32Hex,
57
+ serializeBigInts,
58
+ } from "@tokamak-private-dapps/common-library/tokamak-l2-helpers";
47
59
  import {
48
60
  resolveTokamakBlockInputConfig,
49
61
  } from "@tokamak-private-dapps/common-library/tokamak-runtime-paths";
@@ -107,20 +119,6 @@ import {
107
119
  normalizeEncryptedNoteValueWords,
108
120
  unpackEncryptedNoteValue,
109
121
  } from "./lib/private-state-note-delivery.mjs";
110
- import {
111
- bigintToHex32,
112
- buildStateManager,
113
- buildTokamakTxSnapshot,
114
- bytes32FromHex,
115
- currentStorageBigInt,
116
- deriveChannelTokenVaultLeafIndex,
117
- deriveLiquidBalanceStorageKey,
118
- fetchContractCodes,
119
- normalizeBytesHex,
120
- normalizeBytes32Hex,
121
- serializeBigInts,
122
- } from "./lib/private-state-tokamak-helpers.mjs";
123
-
124
122
  const require = createRequire(import.meta.url);
125
123
  const defaultCommandCwd = process.cwd();
126
124
  const privateStateCliPackageJson = require("./package.json");
@@ -920,7 +918,12 @@ async function handleWorkspaceInit({ args, network, provider }) {
920
918
  const bridgeResources = loadBridgeResources({ chainId: network.chainId });
921
919
  const recoverySource = resolveWorkspaceRecoverySource(args);
922
920
 
923
- const { workspaceDir, workspace, currentSnapshot } = await syncChannelWorkspace({
921
+ const {
922
+ workspaceDir,
923
+ workspace,
924
+ currentSnapshot,
925
+ cleanRebuildBackup,
926
+ } = await syncChannelWorkspace({
924
927
  workspaceName,
925
928
  channelName,
926
929
  network,
@@ -939,6 +942,7 @@ async function handleWorkspaceInit({ args, network, provider }) {
939
942
  source: workspace.recoverySource ?? recoverySource,
940
943
  workspace: workspaceName,
941
944
  workspaceDir,
945
+ cleanRebuildBackup: cleanRebuildBackup ?? null,
942
946
  channelName,
943
947
  channelId: workspace.channelId,
944
948
  channelManager: workspace.channelManager,
@@ -1649,10 +1653,18 @@ async function fetchChannelWorkspaceMirror({
1649
1653
 
1650
1654
  const mirrorAheadOfLocal = localRecoveryIndex
1651
1655
  && Number(manifestPrecheck.recoveryLastScannedBlock) > Number(localRecoveryIndex.nextBlock);
1652
- const bundleResult = mirrorAheadOfLocal
1656
+ const deltaBundleDescriptor = mirrorAheadOfLocal
1657
+ ? selectWorkspaceMirrorDeltaBundle({
1658
+ manifest,
1659
+ fromBlock: Number(localRecoveryIndex.nextBlock),
1660
+ toBlock: Number(manifest.checkpoint.recoveryLastScannedBlock) - 1,
1661
+ })
1662
+ : null;
1663
+ const bundleResult = mirrorAheadOfLocal && deltaBundleDescriptor
1653
1664
  ? await fetchAndApplyWorkspaceMirrorDelta({
1654
1665
  manifest,
1655
1666
  manifestUrl,
1667
+ bundleDescriptor: deltaBundleDescriptor,
1656
1668
  localRecoveryIndex,
1657
1669
  chainId,
1658
1670
  channelId,
@@ -2054,6 +2066,7 @@ function validateWorkspaceMirrorCheckpointArchive({
2054
2066
  async function fetchAndApplyWorkspaceMirrorDelta({
2055
2067
  manifest,
2056
2068
  manifestUrl,
2069
+ bundleDescriptor,
2057
2070
  localRecoveryIndex,
2058
2071
  chainId,
2059
2072
  channelId,
@@ -2067,7 +2080,6 @@ async function fetchAndApplyWorkspaceMirrorDelta({
2067
2080
  }) {
2068
2081
  const fromBlock = Number(localRecoveryIndex.nextBlock);
2069
2082
  const toBlock = Number(manifest.checkpoint.recoveryLastScannedBlock) - 1;
2070
- const bundleDescriptor = selectWorkspaceMirrorDeltaBundle({ manifest, fromBlock, toBlock });
2071
2083
  expect(
2072
2084
  bundleDescriptor,
2073
2085
  `Workspace mirror does not provide a delta bundle for local recovery index ${fromBlock} to checkpoint block ${toBlock}.`,
@@ -2165,6 +2177,7 @@ async function syncChannelWorkspace({
2165
2177
  }) {
2166
2178
  const workspaceDir = channelWorkspacePath(networkNameFromChainId(network.chainId), workspaceName);
2167
2179
  const channelDir = channelDataPath(workspaceDir);
2180
+ let cleanRebuildBackup = null;
2168
2181
  const hasPersistedChannelData = fs.existsSync(channelWorkspaceConfigPath(workspaceDir))
2169
2182
  || fs.existsSync(channelWorkspaceCurrentPath(workspaceDir))
2170
2183
  || fs.existsSync(channelWorkspaceOperationsPath(workspaceDir));
@@ -2176,6 +2189,13 @@ async function syncChannelWorkspace({
2176
2189
  const existingArtifacts = persist && hasPersistedChannelData
2177
2190
  ? loadExistingWorkspaceArtifacts(workspaceDir)
2178
2191
  : null;
2192
+ if (persist && useWorkspaceRecoveryIndex && fromGenesis) {
2193
+ cleanRebuildBackup = backupWorkspaceForCleanRebuild({
2194
+ workspaceDir,
2195
+ networkName: networkNameFromChainId(network.chainId),
2196
+ channelName,
2197
+ });
2198
+ }
2179
2199
 
2180
2200
  const { bridgeDeployment, bridgeAbiManifest } = bridgeResources;
2181
2201
  const bridgeCore = new Contract(bridgeDeployment.bridgeCore, bridgeAbiManifest.contracts.bridgeCore.abi, provider);
@@ -2287,9 +2307,57 @@ async function syncChannelWorkspace({
2287
2307
  throw new Error([
2288
2308
  `Workspace recovery index is missing or unusable for channel ${channelName} on ${networkNameFromChainId(network.chainId)}.`,
2289
2309
  "The CLI will not fall back to replaying channel logs from genesis unless explicitly requested.",
2290
- "Run channel recover-workspace --source rpc --from-genesis or wallet recover-workspace --from-genesis to rebuild from channel genesis.",
2310
+ "Run channel recover-workspace first to refresh the local channel workspace.",
2291
2311
  ].join(" "));
2292
2312
  }
2313
+ const workspaceBase = {
2314
+ name: workspaceName,
2315
+ network: networkNameFromChainId(network.chainId),
2316
+ chainId: network.chainId,
2317
+ appDeploymentPath: deploymentManifestPath,
2318
+ storageLayoutPath: storageLayoutManifestPath,
2319
+ channelId: channelId.toString(),
2320
+ channelName,
2321
+ dappId: Number(channelInfo.dappId),
2322
+ genesisBlockNumber,
2323
+ bridgeCore: getAddress(bridgeDeployment.bridgeCore),
2324
+ channelManager: getAddress(channelInfo.manager),
2325
+ bridgeTokenVault: getAddress(channelInfo.bridgeTokenVault),
2326
+ canonicalAsset,
2327
+ canonicalAssetDecimals,
2328
+ controller: controllerAddress,
2329
+ l2AccountingVault: l2AccountingVaultAddress,
2330
+ aPubBlockHash: normalizeBytes32Hex(channelInfo.aPubBlockHash),
2331
+ dappMetadataDigestSchema: policySnapshot.dappMetadataDigestSchema,
2332
+ dappMetadataDigest: policySnapshot.dappMetadataDigest,
2333
+ functionRoot: policySnapshot.functionRoot,
2334
+ policySnapshot,
2335
+ managedStorageAddresses,
2336
+ liquidBalancesSlot: liquidBalancesSlot.toString(),
2337
+ recoverySource,
2338
+ workspaceMirror: mirrorRecovery.workspaceMirror,
2339
+ };
2340
+ const buildWorkspaceForSnapshot = ({ currentSnapshot, scanRange }) => {
2341
+ const recoveryRootVectorHash = normalizeBytes32Hex(hashRootVector(currentSnapshot.stateRoots));
2342
+ return {
2343
+ ...workspaceBase,
2344
+ recoveryLastScannedBlock: Number(scanRange.toBlock) + 1,
2345
+ recoveryRootVectorHash,
2346
+ recoveryScanRange: scanRange,
2347
+ };
2348
+ };
2349
+ const persistWorkspaceCheckpoint = persist
2350
+ ? ({ currentSnapshot, scanRange }) => {
2351
+ persistChannelWorkspaceFiles({
2352
+ workspaceDir,
2353
+ channelDir,
2354
+ workspace: buildWorkspaceForSnapshot({ currentSnapshot, scanRange }),
2355
+ currentSnapshot,
2356
+ blockInfo,
2357
+ contractCodes,
2358
+ });
2359
+ }
2360
+ : null;
2293
2361
  const reconstruction = localSnapshotReusable
2294
2362
  ? {
2295
2363
  currentSnapshot: existingArtifacts.stateSnapshot,
@@ -2325,56 +2393,23 @@ async function syncChannelWorkspace({
2325
2393
  fromBlock: selectedRecoveryIndex?.nextBlock ?? genesisBlockNumber,
2326
2394
  toBlock: latestBlock,
2327
2395
  progressAction,
2396
+ onCheckpoint: persistWorkspaceCheckpoint,
2328
2397
  });
2329
2398
  const currentSnapshot = reconstruction.currentSnapshot;
2330
- const recoveryRootVectorHash = normalizeBytes32Hex(hashRootVector(currentSnapshot.stateRoots));
2331
- const recoveryLastScannedBlock = Number(reconstruction.scanRange.toBlock) + 1;
2332
-
2333
- const workspace = {
2334
- name: workspaceName,
2335
- network: networkNameFromChainId(network.chainId),
2336
- chainId: network.chainId,
2337
- appDeploymentPath: deploymentManifestPath,
2338
- storageLayoutPath: storageLayoutManifestPath,
2339
- channelId: channelId.toString(),
2340
- channelName,
2341
- dappId: Number(channelInfo.dappId),
2342
- genesisBlockNumber,
2343
- bridgeCore: getAddress(bridgeDeployment.bridgeCore),
2344
- channelManager: getAddress(channelInfo.manager),
2345
- bridgeTokenVault: getAddress(channelInfo.bridgeTokenVault),
2346
- canonicalAsset,
2347
- canonicalAssetDecimals,
2348
- controller: controllerAddress,
2349
- l2AccountingVault: l2AccountingVaultAddress,
2350
- aPubBlockHash: normalizeBytes32Hex(channelInfo.aPubBlockHash),
2351
- dappMetadataDigestSchema: policySnapshot.dappMetadataDigestSchema,
2352
- dappMetadataDigest: policySnapshot.dappMetadataDigest,
2353
- functionRoot: policySnapshot.functionRoot,
2354
- policySnapshot,
2355
- managedStorageAddresses,
2356
- liquidBalancesSlot: liquidBalancesSlot.toString(),
2357
- recoverySource,
2358
- workspaceMirror: mirrorRecovery.workspaceMirror,
2359
- recoveryLastScannedBlock,
2360
- recoveryRootVectorHash,
2361
- recoveryScanRange: reconstruction.scanRange,
2362
- };
2399
+ const workspace = buildWorkspaceForSnapshot({
2400
+ currentSnapshot,
2401
+ scanRange: reconstruction.scanRange,
2402
+ });
2363
2403
 
2364
2404
  if (persist) {
2365
- ensureDir(channelDir);
2366
- ensureDir(channelWorkspaceCurrentPath(workspaceDir));
2367
- ensureDir(channelWorkspaceOperationsPath(workspaceDir));
2368
- ensureDir(workspaceWalletsDir(workspaceDir));
2369
-
2370
- writeJsonIfChanged(channelWorkspaceConfigPath(workspaceDir), workspace);
2371
- writeJsonIfChanged(path.join(channelWorkspaceCurrentPath(workspaceDir), "state_snapshot.json"), currentSnapshot);
2372
- writeJsonIfChanged(
2373
- path.join(channelWorkspaceCurrentPath(workspaceDir), "state_snapshot.normalized.json"),
2405
+ persistChannelWorkspaceFiles({
2406
+ workspaceDir,
2407
+ channelDir,
2408
+ workspace,
2374
2409
  currentSnapshot,
2375
- );
2376
- writeJsonIfChanged(path.join(channelWorkspaceCurrentPath(workspaceDir), "block_info.json"), blockInfo);
2377
- writeJsonIfChanged(path.join(channelWorkspaceCurrentPath(workspaceDir), "contract_codes.json"), contractCodes);
2410
+ blockInfo,
2411
+ contractCodes,
2412
+ });
2378
2413
  }
2379
2414
 
2380
2415
  return {
@@ -2383,6 +2418,7 @@ async function syncChannelWorkspace({
2383
2418
  currentSnapshot,
2384
2419
  blockInfo,
2385
2420
  contractCodes,
2421
+ cleanRebuildBackup,
2386
2422
  };
2387
2423
  }
2388
2424
 
@@ -2463,39 +2499,13 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2463
2499
  const channelName = requireArg(args.channelName, "--channel-name");
2464
2500
  const signer = requireL1Signer(args, provider);
2465
2501
  const walletName = walletNameForChannelAndAddress(channelName, signer.address);
2466
- const bridgeResources = loadBridgeResources({ chainId: network.chainId });
2467
- const initialized = await syncChannelWorkspace({
2468
- workspaceName: channelName,
2502
+ const channelContextResult = await loadFreshChannelWorkspaceContextResult({
2469
2503
  channelName,
2470
- network,
2504
+ networkName: requireNetworkName(args),
2471
2505
  provider,
2472
- bridgeResources,
2473
- persist: true,
2474
- allowExistingWorkspaceSync: true,
2475
- useWorkspaceRecoveryIndex: true,
2476
- fromGenesis: args.fromGenesis === true,
2477
2506
  progressAction: "wallet recover-workspace",
2478
2507
  });
2479
- const context = {
2480
- workspaceName: channelName,
2481
- workspaceDir: initialized.workspaceDir,
2482
- persistChannelWorkspace: true,
2483
- workspace: initialized.workspace,
2484
- bridgeAbiManifest: bridgeResources.bridgeAbiManifest,
2485
- currentSnapshot: initialized.currentSnapshot,
2486
- blockInfo: initialized.blockInfo,
2487
- contractCodes: initialized.contractCodes,
2488
- channelManager: new Contract(
2489
- initialized.workspace.channelManager,
2490
- bridgeResources.bridgeAbiManifest.contracts.channelManager.abi,
2491
- provider,
2492
- ),
2493
- bridgeTokenVault: new Contract(
2494
- initialized.workspace.bridgeTokenVault,
2495
- bridgeResources.bridgeAbiManifest.contracts.bridgeTokenVault.abi,
2496
- provider,
2497
- ),
2498
- };
2508
+ const context = channelContextResult.context;
2499
2509
  const noteReceiveKeyMaterial = await deriveNoteReceiveKeyMaterial({
2500
2510
  signer,
2501
2511
  chainId: network.chainId,
@@ -2563,6 +2573,8 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2563
2573
  status: "already-recovered",
2564
2574
  wallet: walletName,
2565
2575
  walletDir: existingWallet.walletDir,
2576
+ recoveredChannelWorkspace: channelContextResult.recoveredWorkspace,
2577
+ channelAutoRecoveryBlockDelta: channelContextResult.autoRecoveryBlockDelta,
2566
2578
  workspace: context.workspaceName,
2567
2579
  channelName: context.workspace.channelName,
2568
2580
  channelId: context.workspace.channelId,
@@ -2612,6 +2624,8 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2612
2624
  status: "recovered",
2613
2625
  wallet: walletName,
2614
2626
  walletDir: walletContext.walletDir,
2627
+ recoveredChannelWorkspace: channelContextResult.recoveredWorkspace,
2628
+ channelAutoRecoveryBlockDelta: channelContextResult.autoRecoveryBlockDelta,
2615
2629
  workspace: context.workspaceName,
2616
2630
  channelName: context.workspace.channelName,
2617
2631
  channelId: context.workspace.channelId,
@@ -2730,6 +2744,73 @@ function uniquePaths(paths) {
2730
2744
  return [...new Set(paths.map((entry) => path.resolve(entry)))];
2731
2745
  }
2732
2746
 
2747
+ function backupWorkspaceForCleanRebuild({ workspaceDir, networkName, channelName }) {
2748
+ const resolvedWorkspaceDir = path.resolve(workspaceDir);
2749
+ if (!fs.existsSync(resolvedWorkspaceDir)) {
2750
+ return null;
2751
+ }
2752
+ expectPathWithinRoot(
2753
+ resolvedWorkspaceDir,
2754
+ workspaceRoot,
2755
+ `Clean rebuild refuses to move a path outside the private-state workspace root: ${resolvedWorkspaceDir}.`,
2756
+ );
2757
+
2758
+ const backupRoot = path.join(
2759
+ privateStateCliDataRoot(),
2760
+ "workspace-rebuild-backups",
2761
+ slugifyPathComponent(networkName),
2762
+ );
2763
+ ensureDir(backupRoot);
2764
+ const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
2765
+ const backupBasePath = path.join(
2766
+ backupRoot,
2767
+ `${slugifyPathComponent(channelName)}-${timestamp}`,
2768
+ );
2769
+ const backupPath = nextAvailablePath(backupBasePath);
2770
+ fs.renameSync(resolvedWorkspaceDir, backupPath);
2771
+ return {
2772
+ workspaceDir: resolvedWorkspaceDir,
2773
+ backupPath,
2774
+ secretsPreserved: true,
2775
+ };
2776
+ }
2777
+
2778
+ function persistChannelWorkspaceFiles({
2779
+ workspaceDir,
2780
+ channelDir,
2781
+ workspace,
2782
+ currentSnapshot,
2783
+ blockInfo,
2784
+ contractCodes,
2785
+ }) {
2786
+ ensureDir(channelDir);
2787
+ ensureDir(channelWorkspaceCurrentPath(workspaceDir));
2788
+ ensureDir(channelWorkspaceOperationsPath(workspaceDir));
2789
+ ensureDir(workspaceWalletsDir(workspaceDir));
2790
+
2791
+ writeJsonIfChanged(path.join(channelWorkspaceCurrentPath(workspaceDir), "state_snapshot.json"), currentSnapshot);
2792
+ writeJsonIfChanged(
2793
+ path.join(channelWorkspaceCurrentPath(workspaceDir), "state_snapshot.normalized.json"),
2794
+ currentSnapshot,
2795
+ );
2796
+ writeJsonIfChanged(path.join(channelWorkspaceCurrentPath(workspaceDir), "block_info.json"), blockInfo);
2797
+ writeJsonIfChanged(path.join(channelWorkspaceCurrentPath(workspaceDir), "contract_codes.json"), contractCodes);
2798
+ writeJsonIfChanged(channelWorkspaceConfigPath(workspaceDir), workspace);
2799
+ }
2800
+
2801
+ function nextAvailablePath(basePath) {
2802
+ if (!fs.existsSync(basePath)) {
2803
+ return basePath;
2804
+ }
2805
+ for (let index = 1; index <= 1000; index += 1) {
2806
+ const candidate = `${basePath}-${index}`;
2807
+ if (!fs.existsSync(candidate)) {
2808
+ return candidate;
2809
+ }
2810
+ }
2811
+ throw new Error(`Unable to allocate a clean rebuild backup path for ${basePath}.`);
2812
+ }
2813
+
2733
2814
  function removeManagedRoot({ label, rootPath }) {
2734
2815
  const resolvedPath = path.resolve(rootPath);
2735
2816
  try {
@@ -2950,7 +3031,6 @@ function handleInvestigator() {
2950
3031
  function resolveInvestigatorIndexPath() {
2951
3032
  const candidates = [
2952
3033
  path.join(privateStateCliPackageRoot, "investigator", "index.html"),
2953
- path.resolve(privateStateCliPackageRoot, "..", "investigator", "index.html"),
2954
3034
  ];
2955
3035
  const htmlPath = candidates.find((candidate) => fs.existsSync(candidate));
2956
3036
  if (!htmlPath) {
@@ -6110,10 +6190,16 @@ async function recoverDeliveredNotesFromEventLogs({
6110
6190
  };
6111
6191
 
6112
6192
  if (scanStartBlock > latestBlock) {
6193
+ const reconciledState = await reconcileWalletNotesWithBridgeState({
6194
+ walletContext,
6195
+ currentSnapshot: context.currentSnapshot,
6196
+ controllerAddress: context.workspace.controller,
6197
+ });
6113
6198
  walletContext.wallet.noteReceiveLastScannedBlock = latestBlock + 1;
6114
6199
  persistWallet(walletContext);
6115
6200
  return {
6116
6201
  importedNotes: [],
6202
+ reconciledState,
6117
6203
  scannedLogs: 0,
6118
6204
  scanRange,
6119
6205
  };
@@ -6124,18 +6210,67 @@ async function recoverDeliveredNotesFromEventLogs({
6124
6210
  );
6125
6211
  const commitmentExistsSlot = ethers.toBigInt(findStorageSlot(storageLayoutManifest, "PrivateStateController", "commitmentExists"));
6126
6212
  const nullifierUsedSlot = ethers.toBigInt(findStorageSlot(storageLayoutManifest, "PrivateStateController", "nullifierUsed"));
6127
- const observedLogs = await fetchLogsChunked(provider, {
6213
+ const importedNotes = [];
6214
+ let reconciledState = null;
6215
+ let scannedLogs = 0;
6216
+
6217
+ await fetchLogsChunked(provider, {
6128
6218
  address: context.workspace.channelManager,
6129
6219
  topics: [NOTE_VALUE_ENCRYPTED_TOPIC],
6130
6220
  fromBlock: scanStartBlock,
6131
6221
  toBlock: latestBlock,
6222
+ collectLogs: false,
6132
6223
  onProgress: progressAction
6133
6224
  ? createRpcLogScanProgress({ action: progressAction, label: "note-delivery events" })
6134
6225
  : null,
6226
+ onChunk: async ({ logs, chunkToBlock }) => {
6227
+ scannedLogs += logs.length;
6228
+ const importedCandidates = await recoverDeliveredNoteCandidatesFromLogs({
6229
+ logs,
6230
+ walletContext,
6231
+ context,
6232
+ noteReceivePrivateKey,
6233
+ commitmentExistsSlot,
6234
+ nullifierUsedSlot,
6235
+ });
6236
+ if (importedCandidates.length > 0) {
6237
+ importedNotes.push(...mergeTrackedNotesIntoWallet(walletContext, importedCandidates));
6238
+ reconciledState = await reconcileWalletNotesWithBridgeState({
6239
+ walletContext,
6240
+ currentSnapshot: context.currentSnapshot,
6241
+ controllerAddress: context.workspace.controller,
6242
+ });
6243
+ }
6244
+ walletContext.wallet.noteReceiveLastScannedBlock = Number(chunkToBlock) + 1;
6245
+ persistWallet(walletContext);
6246
+ },
6135
6247
  });
6136
6248
 
6249
+ reconciledState = await reconcileWalletNotesWithBridgeState({
6250
+ walletContext,
6251
+ currentSnapshot: context.currentSnapshot,
6252
+ controllerAddress: context.workspace.controller,
6253
+ });
6254
+ walletContext.wallet.noteReceiveLastScannedBlock = latestBlock + 1;
6255
+ persistWallet(walletContext);
6256
+ return {
6257
+ importedNotes,
6258
+ reconciledState,
6259
+ scannedLogs,
6260
+ scanRange,
6261
+ };
6262
+ }
6263
+
6264
+ async function recoverDeliveredNoteCandidatesFromLogs({
6265
+ logs,
6266
+ walletContext,
6267
+ context,
6268
+ noteReceivePrivateKey,
6269
+ commitmentExistsSlot,
6270
+ nullifierUsedSlot,
6271
+ }) {
6137
6272
  const importedCandidates = [];
6138
- for (const log of observedLogs) {
6273
+ for (const log of logs) {
6139
6274
  const encryptedNoteValue = extractEncryptedNoteValueFromBridgeLog(log);
6140
6275
  if (!encryptedNoteValue) {
6141
6276
  continue;
@@ -6200,21 +6335,7 @@ async function recoverDeliveredNotesFromEventLogs({
6200
6335
  }
6201
6336
  importedCandidates.push(trackedNote);
6202
6337
  }
6203
-
6204
- const importedNotes = mergeTrackedNotesIntoWallet(walletContext, importedCandidates);
6205
- const reconciledState = await reconcileWalletNotesWithBridgeState({
6206
- walletContext,
6207
- currentSnapshot: context.currentSnapshot,
6208
- controllerAddress: context.workspace.controller,
6209
- });
6210
- walletContext.wallet.noteReceiveLastScannedBlock = latestBlock + 1;
6211
- persistWallet(walletContext);
6212
- return {
6213
- importedNotes,
6214
- reconciledState,
6215
- scannedLogs: observedLogs.length,
6216
- scanRange,
6217
- };
6338
+ return importedCandidates;
6218
6339
  }
6219
6340
 
6220
6341
  function requireUsableWalletNoteReceiveRecoveryIndex({ walletContext, context, latestBlock }) {
@@ -6228,7 +6349,7 @@ function requireUsableWalletNoteReceiveRecoveryIndex({ walletContext, context, l
6228
6349
  throw new Error([
6229
6350
  `Wallet note recovery index is missing or unusable for wallet ${walletContext.walletName}.`,
6230
6351
  `Expected noteReceiveLastScannedBlock to be an integer between ${genesisBlockNumber} and ${Number(latestBlock) + 1}.`,
6231
- "Run wallet recover-workspace --from-genesis to rebuild wallet note state from channel genesis.",
6352
+ "Run wallet recover-workspace --from-genesis to restart received-note scanning from channel genesis.",
6232
6353
  ].join(" "));
6233
6354
  }
6234
6355
  return nextBlock;
@@ -6274,7 +6395,7 @@ async function ensureWalletNoteReceiveStateCurrent({
6274
6395
  throw new Error([
6275
6396
  `Wallet note recovery index is missing or unusable for wallet ${walletContext.walletName}.`,
6276
6397
  "Automatic wallet recovery uses only the saved note recovery index and will not replay from genesis.",
6277
- `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if a genesis rebuild is required.`,
6398
+ `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if received-note scanning must restart from channel genesis.`,
6278
6399
  `Details: ${indexError.message}`,
6279
6400
  ].join(" "));
6280
6401
  }
@@ -6312,7 +6433,7 @@ async function ensureWalletNoteReceiveStateCurrent({
6312
6433
  throw new Error([
6313
6434
  `Wallet workspace is not current for wallet ${walletContext.walletName}.`,
6314
6435
  "Automatic wallet recovery uses only the saved note recovery index and will not replay from genesis.",
6315
- `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if a genesis rebuild is required.`,
6436
+ `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if received-note scanning must restart from channel genesis.`,
6316
6437
  `Details: ${recoveryError.message}`,
6317
6438
  ].join(" "));
6318
6439
  }
@@ -6328,7 +6449,7 @@ async function ensureWalletNoteReceiveStateCurrent({
6328
6449
  throw new Error([
6329
6450
  `Wallet workspace is still stale after recovery-index sync for wallet ${walletContext.walletName}.`,
6330
6451
  "Automatic wallet recovery will not replay from genesis.",
6331
- `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if a genesis rebuild is required.`,
6452
+ `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if received-note scanning must restart from channel genesis.`,
6332
6453
  `Details: ${postRecoveryError.message}`,
6333
6454
  ].join(" "));
6334
6455
  }
@@ -6948,7 +7069,7 @@ async function recoverChannelWorkspaceFromIndexOnly({
6948
7069
  throw new Error([
6949
7070
  `Channel workspace is not current for ${channelName} on ${networkName}.`,
6950
7071
  "Automatic channel workspace recovery uses only the saved recovery index and will not replay from genesis.",
6951
- `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} --source rpc --from-genesis if a genesis rebuild is required.`,
7072
+ `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} first.`,
6952
7073
  `Details: ${recoveryError.message}`,
6953
7074
  cause ? `Initial freshness failure: ${cause.message}` : null,
6954
7075
  ].filter(Boolean).join(" "));
@@ -6961,7 +7082,7 @@ async function recoverChannelWorkspaceFromIndexOnly({
6961
7082
  throw new Error([
6962
7083
  `Channel workspace is still stale after recovery-index sync for ${channelName} on ${networkName}.`,
6963
7084
  "Automatic channel workspace recovery will not replay from genesis.",
6964
- `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} --source rpc --from-genesis if a genesis rebuild is required.`,
7085
+ `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} first.`,
6965
7086
  `Details: ${postRecoveryError.message}`,
6966
7087
  cause ? `Initial freshness failure: ${cause.message}` : null,
6967
7088
  ].filter(Boolean).join(" "));
@@ -6985,7 +7106,7 @@ async function requireChannelWorkspaceRecoveryIndexForAutoRefresh({
6985
7106
  throw new Error([
6986
7107
  message,
6987
7108
  "Automatic channel workspace recovery uses only the saved recovery index and will not replay from genesis.",
6988
- `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} --source rpc --from-genesis if a genesis rebuild is required.`,
7109
+ `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} first.`,
6989
7110
  cause ? `Initial freshness failure: ${cause.message}` : null,
6990
7111
  ].filter(Boolean).join(" "));
6991
7112
  };
@@ -7995,9 +8116,9 @@ async function buildGrothTransition({ operationDir, workspace, stateManager, vau
7995
8116
  update: {
7996
8117
  currentRootVector: normalizedRootVector(currentSnapshot.stateRoots),
7997
8118
  updatedRoot: bigintToHex32(updatedRoot),
7998
- currentUserKey: bytes32FromHex(keyHex),
8119
+ currentUserKey: normalizeBytes32Hex(keyHex),
7999
8120
  currentUserValue: currentValue,
8000
- updatedUserKey: bytes32FromHex(keyHex),
8121
+ updatedUserKey: normalizeBytes32Hex(keyHex),
8001
8122
  updatedUserValue: nextValue,
8002
8123
  },
8003
8124
  nextSnapshot,
@@ -8188,10 +8309,6 @@ function normalizeBytes12Hex(value) {
8188
8309
  return normalizeBytesHex(value, 12);
8189
8310
  }
8190
8311
 
8191
- function normalizeBytes16Hex(value) {
8192
- return normalizeBytesHex(value, 16);
8193
- }
8194
-
8195
8312
  function hashTokamakPublicInputs(values) {
8196
8313
  return keccak256(abiCoder.encode(["uint256[]"], [values]));
8197
8314
  }
@@ -8319,6 +8436,67 @@ async function fetchChannelRecoveryLogs({
8319
8436
  };
8320
8437
  }
8321
8438
 
8439
+ async function fetchChannelRecoveryEventGroupsChunked({
8440
+ provider,
8441
+ bridgeAbiManifest,
8442
+ channelInfo,
8443
+ channelManager = null,
8444
+ fromBlock,
8445
+ toBlock,
8446
+ progressAction = null,
8447
+ onChunk,
8448
+ }) {
8449
+ const resolvedChannelManager = channelManager ?? new Contract(
8450
+ channelInfo.manager,
8451
+ bridgeAbiManifest.contracts.channelManager.abi,
8452
+ provider,
8453
+ );
8454
+ const currentRootVectorObservedTopic =
8455
+ normalizeBytes32Hex(resolvedChannelManager.interface.getEvent("CurrentRootVectorObserved").topicHash);
8456
+ const bridgeTokenVault = new Contract(
8457
+ channelInfo.bridgeTokenVault,
8458
+ bridgeAbiManifest.contracts.bridgeTokenVault.abi,
8459
+ provider,
8460
+ );
8461
+ const bridgeVaultTopic = bridgeTokenVault.interface.getEvent("StorageWriteObserved").topicHash;
8462
+
8463
+ await fetchLogsChunked(provider, {
8464
+ address: channelInfo.manager,
8465
+ topics: [[
8466
+ currentRootVectorObservedTopic,
8467
+ CONTROLLER_STORAGE_KEY_OBSERVED_TOPIC,
8468
+ VAULT_STORAGE_WRITE_OBSERVED_TOPIC,
8469
+ ]],
8470
+ fromBlock,
8471
+ toBlock,
8472
+ collectLogs: false,
8473
+ onProgress: progressAction
8474
+ ? createRpcLogScanProgress({ action: progressAction, label: "channel-recovery chunks" })
8475
+ : null,
8476
+ onChunk: async ({ logs: channelManagerLogs, chunkFromBlock, chunkToBlock }) => {
8477
+ await throttleLogRequest();
8478
+ const bridgeVaultLogs = await provider.getLogs({
8479
+ address: channelInfo.bridgeTokenVault,
8480
+ topics: [bridgeVaultTopic],
8481
+ fromBlock: chunkFromBlock,
8482
+ toBlock: chunkToBlock,
8483
+ });
8484
+ const groupedValues = normalizeWorkspaceMirrorDeltaEventGroups({
8485
+ logs: [...channelManagerLogs, ...bridgeVaultLogs],
8486
+ channelInfo,
8487
+ bridgeAbiManifest,
8488
+ fromBlock: chunkFromBlock,
8489
+ toBlock: chunkToBlock,
8490
+ });
8491
+ await onChunk?.({
8492
+ groupedValues,
8493
+ chunkFromBlock,
8494
+ chunkToBlock,
8495
+ });
8496
+ },
8497
+ });
8498
+ }
8499
+
8322
8500
  async function reconstructChannelSnapshot({
8323
8501
  provider,
8324
8502
  bridgeAbiManifest,
@@ -8336,6 +8514,7 @@ async function reconstructChannelSnapshot({
8336
8514
  fromBlock = genesisBlockNumber,
8337
8515
  toBlock = null,
8338
8516
  progressAction = null,
8517
+ onCheckpoint = null,
8339
8518
  }) {
8340
8519
  let startingSnapshot = baseSnapshot;
8341
8520
  if (!startingSnapshot) {
@@ -8351,11 +8530,20 @@ async function reconstructChannelSnapshot({
8351
8530
 
8352
8531
  const latestBlock = toBlock === null ? await provider.getBlockNumber() : Number(toBlock);
8353
8532
  const scanFromBlock = Math.max(Number(genesisBlockNumber), Number(fromBlock));
8354
- const {
8355
- currentRootVectorObservedTopic,
8356
- channelManagerLogs,
8357
- bridgeVaultLogs,
8358
- } = await fetchChannelRecoveryLogs({
8533
+ let currentSnapshot = startingSnapshot;
8534
+ if (onCheckpoint && scanFromBlock <= latestBlock) {
8535
+ await onCheckpoint({
8536
+ currentSnapshot,
8537
+ scanRange: {
8538
+ fromBlock: scanFromBlock,
8539
+ toBlock: scanFromBlock - 1,
8540
+ mode: baseSnapshot ? "recovery-index-initial" : "genesis-initial",
8541
+ },
8542
+ });
8543
+ }
8544
+ const stateManager = await buildStateManager(currentSnapshot, contractCodes);
8545
+
8546
+ await fetchChannelRecoveryEventGroupsChunked({
8359
8547
  provider,
8360
8548
  bridgeAbiManifest,
8361
8549
  channelInfo,
@@ -8363,37 +8551,27 @@ async function reconstructChannelSnapshot({
8363
8551
  fromBlock: scanFromBlock,
8364
8552
  toBlock: latestBlock,
8365
8553
  progressAction,
8366
- });
8367
- const channelManagerEvents = channelManagerLogs.map((log) => {
8368
- const topic0 = log.topics[0] ? normalizeBytes32Hex(log.topics[0]) : null;
8369
- if (topic0 !== null && ethers.toBigInt(topic0) === ethers.toBigInt(currentRootVectorObservedTopic)) {
8370
- const parsed = channelManager.interface.parseLog(log);
8371
- return {
8372
- ...log,
8373
- args: parsed.args,
8374
- fragment: parsed.fragment,
8375
- };
8376
- }
8377
- return log;
8378
- });
8379
-
8380
- const groupedEvents = new Map();
8381
- for (const event of [...channelManagerEvents, ...bridgeVaultLogs]) {
8382
- const key = event.transactionHash;
8383
- const group = groupedEvents.get(key) ?? [];
8384
- group.push(event);
8385
- groupedEvents.set(key, group);
8386
- }
8387
-
8388
- const groupedValues = [...groupedEvents.values()].sort((left, right) => compareLogsByPosition(left[0], right[0]));
8389
- const currentSnapshot = await applyChannelRecoveryEventGroups({
8390
- startingSnapshot,
8391
- groupedValues,
8392
- contractCodes,
8393
- channelInfo,
8394
- controllerAddress,
8395
- l2AccountingVaultAddress,
8396
- liquidBalancesSlot,
8554
+ onChunk: async ({ groupedValues, chunkFromBlock, chunkToBlock }) => {
8555
+ currentSnapshot = await applyChannelRecoveryEventGroupsToStateManager({
8556
+ stateManager,
8557
+ fallbackSnapshot: currentSnapshot,
8558
+ groupedValues,
8559
+ channelInfo,
8560
+ controllerAddress,
8561
+ l2AccountingVaultAddress,
8562
+ liquidBalancesSlot,
8563
+ });
8564
+ await onCheckpoint?.({
8565
+ currentSnapshot,
8566
+ scanRange: {
8567
+ fromBlock: scanFromBlock,
8568
+ toBlock: chunkToBlock,
8569
+ chunkFromBlock,
8570
+ chunkToBlock,
8571
+ mode: baseSnapshot ? "recovery-index" : "genesis",
8572
+ },
8573
+ });
8574
+ },
8397
8575
  });
8398
8576
 
8399
8577
  expect(
@@ -8421,9 +8599,28 @@ async function applyChannelRecoveryEventGroups({
8421
8599
  l2AccountingVaultAddress,
8422
8600
  liquidBalancesSlot,
8423
8601
  }) {
8424
- let currentSnapshot = startingSnapshot;
8425
- const stateManager = await buildStateManager(currentSnapshot, contractCodes);
8602
+ const stateManager = await buildStateManager(startingSnapshot, contractCodes);
8603
+ return applyChannelRecoveryEventGroupsToStateManager({
8604
+ stateManager,
8605
+ fallbackSnapshot: startingSnapshot,
8606
+ groupedValues,
8607
+ channelInfo,
8608
+ controllerAddress,
8609
+ l2AccountingVaultAddress,
8610
+ liquidBalancesSlot,
8611
+ });
8612
+ }
8426
8613
 
8614
+ async function applyChannelRecoveryEventGroupsToStateManager({
8615
+ stateManager,
8616
+ fallbackSnapshot,
8617
+ groupedValues,
8618
+ channelInfo,
8619
+ controllerAddress,
8620
+ l2AccountingVaultAddress,
8621
+ liquidBalancesSlot,
8622
+ }) {
8623
+ let currentSnapshot = fallbackSnapshot;
8427
8624
  for (const group of groupedValues) {
8428
8625
  const orderedGroup = [...group].sort(compareLogsByPosition);
8429
8626
  const rootEvent = orderedGroup.find(
@@ -8670,11 +8867,14 @@ async function fetchLogsChunked(provider, {
8670
8867
  fromBlock,
8671
8868
  toBlock,
8672
8869
  initialChunkSize = DEFAULT_LOG_CHUNK_SIZE,
8870
+ collectLogs = true,
8673
8871
  onProgress = null,
8872
+ onChunk = null,
8674
8873
  }) {
8675
8874
  const normalizedFromBlock = Number(fromBlock);
8676
8875
  const resolvedToBlock = toBlock === "latest" ? await provider.getBlockNumber() : Number(toBlock);
8677
8876
  const aggregatedLogs = [];
8877
+ let logsFound = 0;
8678
8878
 
8679
8879
  if (normalizedFromBlock > resolvedToBlock) {
8680
8880
  onProgress?.({
@@ -8702,27 +8902,15 @@ async function fetchLogsChunked(provider, {
8702
8902
  let cursor = normalizedFromBlock;
8703
8903
  while (cursor <= resolvedToBlock) {
8704
8904
  const chunkToBlock = Math.min(resolvedToBlock, cursor + chunkSize - 1);
8905
+ let logs;
8705
8906
  try {
8706
8907
  await throttleLogRequest();
8707
- const logs = await provider.getLogs({
8908
+ logs = await provider.getLogs({
8708
8909
  address,
8709
8910
  topics,
8710
8911
  fromBlock: cursor,
8711
8912
  toBlock: chunkToBlock,
8712
8913
  });
8713
- aggregatedLogs.push(...logs);
8714
- onProgress?.({
8715
- status: "progress",
8716
- fromBlock: normalizedFromBlock,
8717
- toBlock: resolvedToBlock,
8718
- chunkFromBlock: cursor,
8719
- chunkToBlock,
8720
- scannedBlocks: chunkToBlock - normalizedFromBlock + 1,
8721
- totalBlocks,
8722
- logsFound: aggregatedLogs.length,
8723
- chunkLogs: logs.length,
8724
- });
8725
- cursor = chunkToBlock + 1;
8726
8914
  } catch (error) {
8727
8915
  if (isRateLimitError(error)) {
8728
8916
  throw new Error(
@@ -8735,7 +8923,36 @@ async function fetchLogsChunked(provider, {
8735
8923
  throw error;
8736
8924
  }
8737
8925
  chunkSize = suggestedChunkSize;
8926
+ continue;
8927
+ }
8928
+ logsFound += logs.length;
8929
+ if (collectLogs) {
8930
+ aggregatedLogs.push(...logs);
8738
8931
  }
8932
+ onProgress?.({
8933
+ status: "progress",
8934
+ fromBlock: normalizedFromBlock,
8935
+ toBlock: resolvedToBlock,
8936
+ chunkFromBlock: cursor,
8937
+ chunkToBlock,
8938
+ scannedBlocks: chunkToBlock - normalizedFromBlock + 1,
8939
+ totalBlocks,
8940
+ logsFound,
8941
+ chunkLogs: logs.length,
8942
+ });
8943
+ await onChunk?.({
8944
+ status: "progress",
8945
+ fromBlock: normalizedFromBlock,
8946
+ toBlock: resolvedToBlock,
8947
+ chunkFromBlock: cursor,
8948
+ chunkToBlock,
8949
+ scannedBlocks: chunkToBlock - normalizedFromBlock + 1,
8950
+ totalBlocks,
8951
+ logsFound,
8952
+ chunkLogs: logs.length,
8953
+ logs,
8954
+ });
8955
+ cursor = chunkToBlock + 1;
8739
8956
  }
8740
8957
 
8741
8958
  onProgress?.({
@@ -8744,7 +8961,7 @@ async function fetchLogsChunked(provider, {
8744
8961
  toBlock: resolvedToBlock,
8745
8962
  scannedBlocks: totalBlocks,
8746
8963
  totalBlocks,
8747
- logsFound: aggregatedLogs.length,
8964
+ logsFound,
8748
8965
  });
8749
8966
 
8750
8967
  return aggregatedLogs;
@@ -8780,7 +8997,7 @@ function assertAutoRecoveryBlockBudget({
8780
8997
  `Automatic recovery for ${label} would exceed the ${AUTO_RECOVERY_BLOCK_BUDGET}-block pre-command budget.`,
8781
8998
  `Recovery delta is ${blockDelta} blocks from ${normalizedFromBlock} to ${normalizedToBlock}.`,
8782
8999
  `Remaining automatic recovery budget is ${normalizedBudget} blocks.`,
8783
- `Run ${recoveryCommand} first; add --from-genesis only if the saved recovery index is unusable.`,
9000
+ `Run ${recoveryCommand} first.`,
8784
9001
  ].join(" "));
8785
9002
  }
8786
9003
 
@@ -10512,9 +10729,9 @@ function writeEncryptedWalletFile(filePath, plaintextBytes, walletSecret) {
10512
10729
  version: WALLET_ENCRYPTION_VERSION,
10513
10730
  algorithm: WALLET_ENCRYPTION_ALGORITHM,
10514
10731
  kdf: "scrypt",
10515
- salt: normalizeBytes16Hex(salt),
10732
+ salt: normalizeBytesHex(salt, 16),
10516
10733
  iv: normalizeBytes12Hex(iv),
10517
- tag: normalizeBytes16Hex(tag),
10734
+ tag: normalizeBytesHex(tag, 16),
10518
10735
  ciphertext: ethers.hexlify(ciphertext),
10519
10736
  };
10520
10737
  fs.writeFileSync(filePath, `${JSON.stringify(envelope, null, 2)}\n`);
@@ -11012,8 +11229,7 @@ function buildRecoveryHints(error, args = {}) {
11012
11229
  }
11013
11230
 
11014
11231
  if (message.includes("Workspace recovery index is missing or unusable")) {
11015
- hints.push(`private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName} --source rpc --from-genesis`);
11016
- hints.push(`private-state-cli wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account ${accountName} --from-genesis`);
11232
+ hints.push(`private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName}`);
11017
11233
  }
11018
11234
 
11019
11235
  if (message.includes("Wallet note recovery index is missing or unusable")) {
@@ -1,184 +0,0 @@
1
- import { ethers, getAddress } from "ethers";
2
- import {
3
- MAX_MT_LEAVES,
4
- TokamakL2StateManager,
5
- createTokamakL2Common,
6
- createTokamakL2StateManagerFromStateSnapshot,
7
- createTokamakL2Tx,
8
- getUserStorageKey,
9
- poseidon,
10
- } from "tokamak-l2js";
11
- import {
12
- addHexPrefix,
13
- bytesToBigInt,
14
- bytesToHex,
15
- createAddressFromString,
16
- hexToBigInt,
17
- hexToBytes,
18
- } from "@ethereumjs/util";
19
- import {
20
- resolveTokamakBlockInputConfig,
21
- } from "@tokamak-private-dapps/common-library/tokamak-runtime-paths";
22
-
23
- const { previousBlockHashCount: tokamakPrevBlockHashCount } = resolveTokamakBlockInputConfig();
24
-
25
- export function normalizeBytesHex(value, byteLength) {
26
- if (!Number.isInteger(byteLength) || byteLength <= 0) {
27
- throw new Error("normalizeBytesHex requires a positive byte length.");
28
- }
29
- const targetHexLength = byteLength * 2;
30
- let hex;
31
- if (typeof value === "string") {
32
- const trimmed = value.trim();
33
- if (!/^0x[0-9a-fA-F]*$/.test(trimmed)) {
34
- throw new Error(`Expected a hex string, received ${value}.`);
35
- }
36
- hex = trimmed.replace(/^0x/i, "");
37
- if (hex.length % 2 !== 0) {
38
- hex = `0${hex}`;
39
- }
40
- } else {
41
- hex = ethers.hexlify(value).replace(/^0x/i, "");
42
- }
43
- if (hex.length > targetHexLength) {
44
- throw new Error(`Expected at most ${byteLength} bytes, received ${Math.ceil(hex.length / 2)} bytes.`);
45
- }
46
- return `0x${hex.padStart(targetHexLength, "0").toLowerCase()}`;
47
- }
48
-
49
- export function normalizeBytes32Hex(hexValue) {
50
- return normalizeBytesHex(hexValue, 32);
51
- }
52
-
53
- export function bytes32FromHex(hexValue) {
54
- return normalizeBytes32Hex(hexValue);
55
- }
56
-
57
- export function bigintToHex32(value) {
58
- return normalizeBytes32Hex(ethers.toBeHex(value));
59
- }
60
-
61
- export function poseidonHexFromBytes(bytesLike) {
62
- return ethers.hexlify(poseidon(ethers.getBytes(bytesLike))).toLowerCase();
63
- }
64
-
65
- export function serializeBigInts(value) {
66
- return JSON.parse(JSON.stringify(value, (_key, current) => (
67
- typeof current === "bigint" ? current.toString() : current
68
- )));
69
- }
70
-
71
- export function buildTokamakTxSnapshot({ signerPrivateKey, senderPubKey, to, data, nonce }) {
72
- return serializeBigInts(
73
- createTokamakL2Tx(
74
- {
75
- nonce: ethers.toBigInt(nonce),
76
- to: createAddressFromString(to),
77
- data: hexToBytes(addHexPrefix(String(data ?? "").replace(/^0x/i, ""))),
78
- senderPubKey,
79
- },
80
- { common: createTokamakL2Common() },
81
- )
82
- .sign(signerPrivateKey)
83
- .captureTxSnapshot(),
84
- );
85
- }
86
-
87
- export async function fetchContractCodes(provider, addresses, { requireBytecode = false } = {}) {
88
- const codes = [];
89
- for (const address of addresses) {
90
- const normalizedAddress = getAddress(address);
91
- const code = await provider.getCode(normalizedAddress);
92
- if (requireBytecode && code === "0x") {
93
- throw new Error(`No deployed bytecode found at ${normalizedAddress}.`);
94
- }
95
- codes.push({ address: normalizedAddress, code });
96
- }
97
- return codes;
98
- }
99
-
100
- export async function buildStateManager(snapshot, contractCodes) {
101
- return createTokamakL2StateManagerFromStateSnapshot(snapshot, {
102
- contractCodes: contractCodes.map((entry) => ({
103
- address: createAddressFromString(entry.address),
104
- code: addHexPrefix(entry.code),
105
- })),
106
- });
107
- }
108
-
109
- export async function currentStorageBigInt(stateManager, address, keyHex) {
110
- const valueBytes = await stateManager.getStorage(
111
- createAddressFromString(address),
112
- hexToBytes(addHexPrefix(String(keyHex ?? "").replace(/^0x/i, ""))),
113
- );
114
- if (valueBytes.length === 0) {
115
- return 0n;
116
- }
117
- return bytesToBigInt(valueBytes);
118
- }
119
-
120
- export async function putStorageValue(stateManager, address, keyHex, nextValue) {
121
- await stateManager.putStorage(
122
- createAddressFromString(address),
123
- hexToBytes(addHexPrefix(String(keyHex ?? "").replace(/^0x/i, ""))),
124
- hexToBytes(addHexPrefix(String(bigintToHex32(nextValue) ?? "").replace(/^0x/i, ""))),
125
- );
126
- }
127
-
128
- export async function putStorageAndCapture(stateManager, address, keyHex, nextValue) {
129
- await currentStorageBigInt(stateManager, address, keyHex);
130
- await putStorageValue(stateManager, address, keyHex, nextValue);
131
- return stateManager.captureStateSnapshot();
132
- }
133
-
134
- export async function initializePrivateStateSnapshot({ controllerAddress, vaultAddress, channelId }) {
135
- const stateManager = new TokamakL2StateManager({ common: createTokamakL2Common() });
136
- const addresses = [controllerAddress, vaultAddress].map((address) => createAddressFromString(address));
137
- await stateManager._initializeForAddresses(addresses);
138
- stateManager._channelId = channelId;
139
- for (const address of addresses) {
140
- stateManager._commitResolvedStorageEntries(address, []);
141
- }
142
- return stateManager.captureStateSnapshot();
143
- }
144
-
145
- export async function getBlockInfoAt(provider, blockNumber, { send = (method, params) => provider.send(method, params) } = {}) {
146
- const blockTag = ethers.toQuantity(blockNumber);
147
- const block = await send("eth_getBlockByNumber", [blockTag, false]);
148
- const prevBlockHashes = [];
149
- for (let offset = 1; offset <= tokamakPrevBlockHashCount; offset += 1) {
150
- if (blockNumber <= offset) {
151
- prevBlockHashes.push("0x0");
152
- continue;
153
- }
154
- const previousBlock = await send("eth_getBlockByNumber", [ethers.toQuantity(blockNumber - offset), false]);
155
- prevBlockHashes.push(previousBlock.hash);
156
- }
157
- const chainId = await send("eth_chainId", []);
158
- return {
159
- coinBase: block.miner,
160
- timeStamp: block.timestamp,
161
- blockNumber: block.number,
162
- prevRanDao: block.prevRandao ?? block.mixHash ?? block.difficulty ?? "0x0",
163
- gasLimit: block.gasLimit,
164
- chainId,
165
- selfBalance: "0x0",
166
- baseFee: block.baseFeePerGas ?? "0x0",
167
- prevBlockHashes,
168
- };
169
- }
170
-
171
- export async function getFixedBlockInfo(provider, options = {}) {
172
- const send = options.send ?? ((method, params) => provider.send(method, params));
173
- const latestNumberHex = await send("eth_blockNumber", []);
174
- const latestNumber = Number(hexToBigInt(addHexPrefix(String(latestNumberHex ?? "").replace(/^0x/i, ""))));
175
- return getBlockInfoAt(provider, latestNumber, { send });
176
- }
177
-
178
- export function deriveLiquidBalanceStorageKey(l2Address, slot) {
179
- return normalizeBytes32Hex(bytesToHex(getUserStorageKey([l2Address, ethers.toBigInt(slot)], "TokamakL2")));
180
- }
181
-
182
- export function deriveChannelTokenVaultLeafIndex(storageKey) {
183
- return hexToBigInt(addHexPrefix(String(storageKey ?? "").replace(/^0x/i, ""))) % ethers.toBigInt(MAX_MT_LEAVES);
184
- }