@tokamak-private-dapps/private-state-cli 2.2.0 → 2.2.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,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.2.1 - 2026-05-18
6
+
7
+ - Added `channel recover-workspace --source rpc --output-raw` to append raw JSON-RPC request and response history
8
+ to method-specific JSON files under the channel workspace `rpcCallHistory/` directory, with `eth_getLogs` split by event.
9
+ Indexed recovery appends to existing history; `--from-genesis` overwrites it with one full genesis-to-latest scan.
10
+
5
11
  ## 2.2.0 - 2026-05-18
6
12
 
7
13
  - Added `install --read-only` for channel-state read commands and commands that do not depend on channel state. This
package/README.md CHANGED
@@ -192,6 +192,12 @@ run is interrupted, the next non-`--from-genesis` RPC recovery resumes from the
192
192
  can also start from that local checkpoint: it uses a matching delta bundle when one is available, otherwise a newer
193
193
  verified full mirror checkpoint replaces the local checkpoint before RPC catch-up.
194
194
 
195
+ Use `channel recover-workspace --source rpc --output-raw` when you need to preserve the raw JSON-RPC request and
196
+ response history for inspection. The CLI appends calls to method-specific JSON files, and splits `eth_getLogs` into
197
+ event-specific files such as `eth_getLogs.CurrentRootVectorObserved.json`, under
198
+ `~/tokamak-private-channels/workspace/<network>/<channel>/channel/rpcCallHistory/`. Indexed recovery appends to the
199
+ existing history, while `--from-genesis` overwrites it with one full genesis-to-latest scan.
200
+
195
201
  `channel create` is the exception: after the channel is created on-chain, the CLI initializes that new local workspace
196
202
  by replaying from the channel's genesis block because no prior recovery index can exist for a new channel.
197
203
 
@@ -202,6 +202,13 @@ export const PRIVATE_STATE_CLI_FIELD_CATALOG = Object.freeze({
202
202
  option: "--from-genesis",
203
203
  optional: true,
204
204
  },
205
+ outputRaw: {
206
+ label: "Output Raw RPC History",
207
+ type: "checkbox",
208
+ hint: "With channel recover-workspace --source rpc, preserve raw JSON-RPC request and response history under the channel workspace.",
209
+ option: "--output-raw",
210
+ optional: true,
211
+ },
205
212
  source: {
206
213
  label: "Recovery Source",
207
214
  type: "select",
@@ -383,8 +390,8 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
383
390
  display: "channel recover-workspace",
384
391
  description: "Rebuild the local channel workspace from bridge state.",
385
392
  installMode: "read-only",
386
- fields: ["channelName", "network", "source", "fromGenesis"],
387
- usage: "--channel-name, --network, optional --source, optional --from-genesis",
393
+ fields: ["channelName", "network", "source", "fromGenesis", "outputRaw"],
394
+ usage: "--channel-name, --network, optional --source, optional --from-genesis, optional --output-raw",
388
395
  help: [
389
396
  "By default, --source rpc resumes RPC log scanning from the workspace recovery index when available",
390
397
  "--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",
@@ -392,6 +399,7 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
392
399
  "Mirror recovery uses a matching delta bundle when available; otherwise a newer verified full checkpoint replaces the local checkpoint before RPC catch-up",
393
400
  "Fails instead of falling back to genesis when no usable recovery index exists",
394
401
  "Use --source rpc --from-genesis to ignore the recovery index and replay logs from channel genesis",
402
+ "--output-raw with --source rpc appends raw JSON-RPC request and response history to method-specific JSON files under the channel workspace rpcCallHistory directory; eth_getLogs is split by event",
395
403
  "--from-genesis moves the existing local channel workspace to workspace-rebuild-backups before writing the current-format workspace; local secrets are preserved",
396
404
  "Prints RPC log scan progress while rebuilding the workspace",
397
405
  ],
@@ -731,7 +739,7 @@ export function privateStateCliCommandSynopsis(command) {
731
739
  return null;
732
740
  }
733
741
  const valueLabel = field.valueLabel ?? field.placeholderLabel ?? `<${field.label?.toUpperCase().replace(/\s+/g, "_") ?? "VALUE"}>`;
734
- const option = field.type === "checkbox" || fieldKey === "fromGenesis"
742
+ const option = field.type === "checkbox"
735
743
  ? field.option
736
744
  : `${field.option} ${valueLabel}`;
737
745
  return field.optional || optionalFields.has(fieldKey) ? `[${option}]` : option;
@@ -108,10 +108,6 @@ function runCaptured(command, args, { cwd = defaultCommandCwd, env = process.env
108
108
  };
109
109
  }
110
110
 
111
- function requireSemverVersion(value, label) {
112
- return requireExactSemverVersion(value, label);
113
- }
114
-
115
111
  function readTokamakCliPackageReport(packageRoot = null) {
116
112
  try {
117
113
  const resolvedPackageRoot = packageRoot ?? resolveActiveTokamakCliPackageRoot();
@@ -234,19 +230,17 @@ function buildDoctorHumanRows(report) {
234
230
  check: `${report.installManifest.mode} deployment artifacts`,
235
231
  status: doctorStatus(report.checks.find((check) => check.name === `${report.installManifest.mode} deployment artifacts`)?.ok),
236
232
  detail: [
237
- `readOnlyChains=${report.deploymentArtifacts.chains
238
- .filter((entry) => entry.modes[PRIVATE_STATE_INSTALL_MODES.READ_ONLY].ok)
239
- .map((entry) => entry.chainId)
240
- .join(",") || "none"}`,
241
- `fullChains=${report.deploymentArtifacts.chains
242
- .filter((entry) => entry.modes[PRIVATE_STATE_INSTALL_MODES.FULL].ok)
243
- .map((entry) => entry.chainId)
244
- .join(",") || "none"}`,
233
+ `readOnlyChains=${formatInstalledArtifactChains(report.deploymentArtifacts, PRIVATE_STATE_INSTALL_MODES.READ_ONLY)}`,
234
+ `fullChains=${formatInstalledArtifactChains(report.deploymentArtifacts, PRIVATE_STATE_INSTALL_MODES.FULL)}`,
245
235
  ].join(" "),
246
236
  },
247
237
  ];
248
238
  }
249
239
 
240
+ function formatInstalledArtifactChains(artifactReadiness, mode) {
241
+ return installedArtifactChainIds(artifactReadiness, mode).join(",") || "none";
242
+ }
243
+
250
244
  function formatDoctorTable(rows) {
251
245
  const headers = ["Check", "Status", "Detail"];
252
246
  const checkWidth = Math.max(headers[0].length, ...rows.map((row) => row.check.length));
@@ -660,19 +654,20 @@ function buildCommandAvailability({ artifactReadiness, installMode, tokamakCli,
660
654
  requiredInstallMode,
661
655
  available,
662
656
  chains: requiredInstallMode === PRIVATE_STATE_INSTALL_MODES.FULL
663
- ? artifactReadiness.chains
664
- .filter((entry) => entry.modes[PRIVATE_STATE_INSTALL_MODES.FULL].ok)
665
- .map((entry) => entry.chainId)
666
- : requiredInstallMode === PRIVATE_STATE_INSTALL_MODES.READ_ONLY
667
- ? artifactReadiness.chains
668
- .filter((entry) => entry.modes[PRIVATE_STATE_INSTALL_MODES.READ_ONLY].ok)
669
- .map((entry) => entry.chainId)
670
- : [],
657
+ || requiredInstallMode === PRIVATE_STATE_INSTALL_MODES.READ_ONLY
658
+ ? installedArtifactChainIds(artifactReadiness, requiredInstallMode)
659
+ : [],
671
660
  reasons,
672
661
  };
673
662
  });
674
663
  }
675
664
 
665
+ function installedArtifactChainIds(artifactReadiness, mode) {
666
+ return artifactReadiness.chains
667
+ .filter((entry) => entry.modes[mode]?.ok)
668
+ .map((entry) => entry.chainId);
669
+ }
670
+
676
671
  function buildSelectedRuntimeVersionCheck({ installManifest, installMode, tokamakCli, groth16Runtime }) {
677
672
  if (installMode === PRIVATE_STATE_INSTALL_MODES.READ_ONLY) {
678
673
  return {
@@ -752,16 +747,16 @@ async function resolveRequestedGroth16PackageVersion(requestedVersion) {
752
747
  }
753
748
 
754
749
  const bundledPackageJson = readJson(path.join(resolveGroth16PackageRoot(), "package.json"));
755
- return requireSemverVersion(bundledPackageJson.version, `${GROTH16_PACKAGE_NAME} bundled package version`);
750
+ return requireExactSemverVersion(bundledPackageJson.version, `${GROTH16_PACKAGE_NAME} bundled package version`);
756
751
  }
757
752
 
758
753
  async function resolveRequestedNpmPackageVersion({ packageName, requestedVersion, optionName }) {
759
754
  const metadata = await fetchNpmPackageMetadata(packageName);
760
755
  if (requestedVersion === undefined || requestedVersion === null) {
761
- return requireSemverVersion(metadata?.["dist-tags"]?.latest, `${packageName} npm latest version`);
756
+ return requireExactSemverVersion(metadata?.["dist-tags"]?.latest, `${packageName} npm latest version`);
762
757
  }
763
758
 
764
- const normalizedVersion = requireSemverVersion(requestedVersion, optionName);
759
+ const normalizedVersion = requireExactSemverVersion(requestedVersion, optionName);
765
760
  if (!metadata.versions?.[normalizedVersion]) {
766
761
  throw new Error(`npm package ${packageName} does not contain version ${normalizedVersion}.`);
767
762
  }
@@ -832,7 +827,7 @@ async function installGroth16RuntimeForPrivateState({ version, docker }) {
832
827
  }
833
828
 
834
829
  function resolveGroth16RuntimePackageInstall(version) {
835
- const normalizedVersion = requireSemverVersion(version, `${GROTH16_PACKAGE_NAME} version`);
830
+ const normalizedVersion = requireExactSemverVersion(version, `${GROTH16_PACKAGE_NAME} version`);
836
831
  const bundledPackageRoot = resolveGroth16PackageRoot();
837
832
  const bundledPackageJson = readJson(path.join(bundledPackageRoot, "package.json"));
838
833
  if (bundledPackageJson.name === GROTH16_PACKAGE_NAME && bundledPackageJson.version === normalizedVersion) {
@@ -930,7 +925,7 @@ function parseDriveFileIdFromDownloadUrl(value) {
930
925
 
931
926
  function installManagedNpmPackage({ packageName, version, cacheBaseRoot = resolveArtifactCacheBaseRoot() }) {
932
927
  const normalizedPackageName = requireNonEmptyString(packageName, "packageName");
933
- const normalizedVersion = requireSemverVersion(version, `${normalizedPackageName} version`);
928
+ const normalizedVersion = requireExactSemverVersion(version, `${normalizedPackageName} version`);
934
929
  const installPrefix = managedNpmPackageInstallPrefix({
935
930
  packageName: normalizedPackageName,
936
931
  version: normalizedVersion,
@@ -965,7 +960,7 @@ function managedNpmPackageInstallPrefix({ packageName, version, cacheBaseRoot =
965
960
  const safePackageName = requireNonEmptyString(packageName, "packageName")
966
961
  .replace(/^@/, "")
967
962
  .replace(/[^A-Za-z0-9._-]+/g, "__");
968
- return path.join(privateStateCliRuntimeRoot(cacheBaseRoot), "npm", safePackageName, requireSemverVersion(version, "version"));
963
+ return path.join(privateStateCliRuntimeRoot(cacheBaseRoot), "npm", safePackageName, requireExactSemverVersion(version, "version"));
969
964
  }
970
965
 
971
966
  async function downloadGroth16CrsArtifactsForPrivateState({
package/lib/runtime.mjs CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  deriveChannelTokenVaultLeafIndex,
53
53
  deriveLiquidBalanceStorageKey,
54
54
  fetchContractCodes,
55
+ getBlockInfoAt,
55
56
  normalizeBytesHex,
56
57
  normalizeBytes32Hex,
57
58
  serializeBigInts,
@@ -554,25 +555,25 @@ function requireFlatDeploymentArtifactPathsForChainId(chainId) {
554
555
  }
555
556
 
556
557
  function requireInstalledDeploymentArtifacts(artifactPaths, chainId, mode) {
557
- const requiredFiles = privateStateCliArtifactRequiredFiles(artifactPaths, mode);
558
- try {
559
- for (const entry of requiredFiles) {
560
- if (!fs.existsSync(entry.path)) {
561
- throw new Error(`Missing ${entry.label}: ${entry.path}.`);
562
- }
563
- }
564
- } catch (error) {
565
- throw cliError(
566
- CLI_ERROR_CODES.MISSING_DEPLOYMENT_ARTIFACTS,
567
- [
568
- `Missing ${mode} installed deployment artifacts for chain ${chainId} under ${artifactPaths.rootDir}.`,
569
- mode === PRIVATE_STATE_INSTALL_MODES.FULL
570
- ? "Run install before running private-state CLI commands that write channel state."
571
- : "Run install --read-only before running private-state CLI commands that read channel state.",
572
- `Original error: ${error.message}`,
573
- ].join(" "),
574
- );
558
+ const missingFiles = missingInstalledDeploymentArtifactFiles(artifactPaths, mode);
559
+ if (missingFiles.length === 0) {
560
+ return;
575
561
  }
562
+ throw cliError(
563
+ CLI_ERROR_CODES.MISSING_DEPLOYMENT_ARTIFACTS,
564
+ [
565
+ `Missing ${mode} installed deployment artifacts for chain ${chainId} under ${artifactPaths.rootDir}.`,
566
+ mode === PRIVATE_STATE_INSTALL_MODES.FULL
567
+ ? "Run install before running private-state CLI commands that write channel state."
568
+ : "Run install --read-only before running private-state CLI commands that read channel state.",
569
+ `Original error: ${missingFiles.map((entry) => `Missing ${entry.label}: ${entry.path}.`).join(" ")}`,
570
+ ].join(" "),
571
+ );
572
+ }
573
+
574
+ function missingInstalledDeploymentArtifactFiles(artifactPaths, mode) {
575
+ return privateStateCliArtifactRequiredFiles(artifactPaths, mode)
576
+ .filter((entry) => !fs.existsSync(entry.path));
576
577
  }
577
578
 
578
579
  async function handleChannelCreate({ args, network, provider }) {
@@ -721,12 +722,14 @@ async function handleWorkspaceInit({ args, network, provider }) {
721
722
  const workspaceName = channelName;
722
723
  const bridgeResources = loadBridgeResources({ chainId: network.chainId });
723
724
  const recoverySource = resolveWorkspaceRecoverySource(args);
725
+ const outputRawRpcCallHistory = args.outputRaw === true;
724
726
 
725
727
  const {
726
728
  workspaceDir,
727
729
  workspace,
728
730
  currentSnapshot,
729
731
  cleanRebuildBackup,
732
+ rpcCallHistory,
730
733
  } = await syncChannelWorkspace({
731
734
  workspaceName,
732
735
  channelName,
@@ -738,6 +741,7 @@ async function handleWorkspaceInit({ args, network, provider }) {
738
741
  useWorkspaceRecoveryIndex: true,
739
742
  fromGenesis: args.fromGenesis === true,
740
743
  recoverySource,
744
+ outputRawRpcCallHistory,
741
745
  progressAction: "channel recover-workspace",
742
746
  });
743
747
 
@@ -757,6 +761,7 @@ async function handleWorkspaceInit({ args, network, provider }) {
757
761
  recoveryLastScannedBlock: workspace.recoveryLastScannedBlock,
758
762
  recoveryRootVectorHash: workspace.recoveryRootVectorHash,
759
763
  recoveryScanRange: workspace.recoveryScanRange,
764
+ rpcCallHistory,
760
765
  workspaceMirror: workspace.workspaceMirror ?? null,
761
766
  });
762
767
  }
@@ -1968,6 +1973,7 @@ async function syncChannelWorkspace({
1968
1973
  useWorkspaceRecoveryIndex = false,
1969
1974
  fromGenesis = false,
1970
1975
  recoverySource = "rpc",
1976
+ outputRawRpcCallHistory = false,
1971
1977
  minimumToBlock = null,
1972
1978
  progressAction = null,
1973
1979
  }) {
@@ -1993,8 +1999,15 @@ async function syncChannelWorkspace({
1993
1999
  });
1994
2000
  }
1995
2001
 
2002
+ const rpcCallHistoryRecorder = outputRawRpcCallHistory
2003
+ ? createRpcCallHistoryRecorder({ workspaceDir })
2004
+ : null;
2005
+ const activeProvider = rpcCallHistoryRecorder
2006
+ ? attachRpcCallHistoryRecorderToProvider(provider, rpcCallHistoryRecorder)
2007
+ : provider;
2008
+
1996
2009
  const { bridgeDeployment, bridgeAbiManifest } = bridgeResources;
1997
- const bridgeCore = new Contract(bridgeDeployment.bridgeCore, bridgeAbiManifest.contracts.bridgeCore.abi, provider);
2010
+ const bridgeCore = new Contract(bridgeDeployment.bridgeCore, bridgeAbiManifest.contracts.bridgeCore.abi, activeProvider);
1998
2011
  const channelId = deriveChannelIdFromName(channelName);
1999
2012
  const channelInfo = await bridgeCore.getChannel(channelId);
2000
2013
  if (!channelInfo.exists) {
@@ -2004,13 +2017,13 @@ async function syncChannelWorkspace({
2004
2017
  const channelManager = new Contract(
2005
2018
  channelInfo.manager,
2006
2019
  bridgeAbiManifest.contracts.channelManager.abi,
2007
- provider,
2020
+ activeProvider,
2008
2021
  );
2009
2022
  const canonicalAsset = getAddress(channelInfo.asset);
2010
- const canonicalAssetDecimals = await fetchTokenDecimals(provider, canonicalAsset);
2023
+ const canonicalAssetDecimals = await fetchTokenDecimals(activeProvider, canonicalAsset);
2011
2024
  const currentRootVectorHash = normalizeBytes32Hex(await channelManager.currentRootVectorHash());
2012
2025
  const genesisBlockNumber = Number(await channelManager.genesisBlockNumber());
2013
- const observedLatestBlock = await provider.getBlockNumber();
2026
+ const observedLatestBlock = await activeProvider.getBlockNumber();
2014
2027
  const latestBlock = minimumToBlock === null
2015
2028
  ? observedLatestBlock
2016
2029
  : Math.max(observedLatestBlock, Number(minimumToBlock));
@@ -2038,8 +2051,8 @@ async function syncChannelWorkspace({
2038
2051
  `Managed storage vector does not include L2 accounting vault ${l2AccountingVaultAddress}.`,
2039
2052
  );
2040
2053
 
2041
- const contractCodes = await fetchContractCodes(provider, managedStorageAddresses);
2042
- const blockInfo = await fetchChannelBlockInfo(provider, genesisBlockNumber);
2054
+ const contractCodes = await fetchContractCodes(activeProvider, managedStorageAddresses);
2055
+ const blockInfo = await getBlockInfoAt(activeProvider, genesisBlockNumber);
2043
2056
  const derivedAPubBlockHash = normalizeBytes32Hex(hashTokamakPublicInputs(encodeTokamakBlockInfo(blockInfo)));
2044
2057
  expect(
2045
2058
  ethers.toBigInt(derivedAPubBlockHash) === ethers.toBigInt(normalizeBytes32Hex(channelInfo.aPubBlockHash)),
@@ -2154,6 +2167,10 @@ async function syncChannelWorkspace({
2154
2167
  });
2155
2168
  }
2156
2169
  : null;
2170
+ rpcCallHistoryRecorder?.setScanRange({
2171
+ fromBlock: selectedRecoveryIndex?.nextBlock ?? genesisBlockNumber,
2172
+ toBlock: latestBlock,
2173
+ });
2157
2174
  const reconstruction = localSnapshotReusable
2158
2175
  ? {
2159
2176
  currentSnapshot: existingArtifacts.stateSnapshot,
@@ -2173,7 +2190,7 @@ async function syncChannelWorkspace({
2173
2190
  },
2174
2191
  }
2175
2192
  : await reconstructChannelSnapshot({
2176
- provider,
2193
+ provider: activeProvider,
2177
2194
  bridgeAbiManifest,
2178
2195
  channelInfo,
2179
2196
  channelManager,
@@ -2190,7 +2207,9 @@ async function syncChannelWorkspace({
2190
2207
  toBlock: latestBlock,
2191
2208
  progressAction,
2192
2209
  onCheckpoint: persistWorkspaceCheckpoint,
2210
+ rpcCallHistoryRecorder,
2193
2211
  });
2212
+ rpcCallHistoryRecorder?.setScanRange(reconstruction.scanRange);
2194
2213
  const currentSnapshot = reconstruction.currentSnapshot;
2195
2214
  const workspace = buildWorkspaceForSnapshot({
2196
2215
  currentSnapshot,
@@ -2215,6 +2234,7 @@ async function syncChannelWorkspace({
2215
2234
  blockInfo,
2216
2235
  contractCodes,
2217
2236
  cleanRebuildBackup,
2237
+ rpcCallHistory: rpcCallHistoryRecorder?.finish() ?? null,
2218
2238
  };
2219
2239
  }
2220
2240
 
@@ -2656,6 +2676,198 @@ function persistChannelWorkspaceFiles({
2656
2676
  writeJsonIfChanged(channelWorkspaceConfigPath(workspaceDir), workspace);
2657
2677
  }
2658
2678
 
2679
+ function channelWorkspaceRpcCallHistoryPath(workspaceDir) {
2680
+ return path.join(channelDataPath(workspaceDir), "rpcCallHistory");
2681
+ }
2682
+
2683
+ function createRpcCallHistoryRecorder({ workspaceDir }) {
2684
+ const historyDir = channelWorkspaceRpcCallHistoryPath(workspaceDir);
2685
+ const entriesByFile = new Map();
2686
+ let scanRange = null;
2687
+ let callCount = 0;
2688
+ ensureDir(historyDir);
2689
+
2690
+ const pushEntry = ({ method, eventName = null, entry }) => {
2691
+ const file = rpcCallHistoryFileName(method, eventName);
2692
+ const entries = entriesByFile.get(file) ?? [];
2693
+ entries.push({
2694
+ method,
2695
+ ...(eventName ? { event: eventName } : {}),
2696
+ ...entry,
2697
+ });
2698
+ entriesByFile.set(file, entries);
2699
+ };
2700
+
2701
+ return {
2702
+ historyDir,
2703
+ setScanRange(nextScanRange) {
2704
+ scanRange = {
2705
+ fromBlock: Number(nextScanRange.fromBlock),
2706
+ toBlock: Number(nextScanRange.toBlock),
2707
+ ...(nextScanRange.mode ? { mode: nextScanRange.mode } : {}),
2708
+ };
2709
+ },
2710
+ recordRpcCall({ method, params, response, error = null }) {
2711
+ if (method === "eth_getLogs") {
2712
+ return;
2713
+ }
2714
+ callCount += 1;
2715
+ pushEntry({
2716
+ method,
2717
+ entry: {
2718
+ recordedAt: new Date().toISOString(),
2719
+ request: buildRawJsonRpcRequest(method, params),
2720
+ ...(error ? { error } : { response }),
2721
+ },
2722
+ });
2723
+ },
2724
+ recordEthGetLogs({ request, logs, groupedValues, chunkFromBlock, chunkToBlock }) {
2725
+ callCount += 1;
2726
+ const eventBuckets = groupRawEthGetLogsByRecoveryEvent({ logs, groupedValues });
2727
+ for (const [eventName, response] of eventBuckets.entries()) {
2728
+ pushEntry({
2729
+ method: "eth_getLogs",
2730
+ eventName,
2731
+ entry: {
2732
+ recordedAt: new Date().toISOString(),
2733
+ chunkRange: { fromBlock: Number(chunkFromBlock), toBlock: Number(chunkToBlock) },
2734
+ request: buildRawEthGetLogsRequest(request),
2735
+ response,
2736
+ },
2737
+ });
2738
+ }
2739
+ },
2740
+ finish() {
2741
+ const files = [...entriesByFile.entries()].map(([file, entries]) =>
2742
+ appendRpcCallHistoryEntries({
2743
+ historyDir,
2744
+ file,
2745
+ entries: entries.map((entry) => ({ scanRange, ...entry })),
2746
+ }));
2747
+ return {
2748
+ historyDir,
2749
+ scanRange,
2750
+ callCount,
2751
+ files: files.sort((left, right) => left.file.localeCompare(right.file)),
2752
+ };
2753
+ },
2754
+ };
2755
+ }
2756
+
2757
+ function attachRpcCallHistoryRecorderToProvider(provider, recorder) {
2758
+ const send = provider.send.bind(provider);
2759
+ provider.send = async (method, params) => {
2760
+ try {
2761
+ const response = await send(method, params);
2762
+ recorder.recordRpcCall({ method, params, response });
2763
+ return response;
2764
+ } catch (error) {
2765
+ recorder.recordRpcCall({ method, params, error: normalizeRpcCallHistoryError(error) });
2766
+ throw error;
2767
+ }
2768
+ };
2769
+ return provider;
2770
+ }
2771
+
2772
+ function normalizeRpcCallHistoryError(error) {
2773
+ return {
2774
+ name: error?.name ?? "Error",
2775
+ code: error?.code ?? null,
2776
+ message: error?.message ?? String(error),
2777
+ };
2778
+ }
2779
+
2780
+ function appendRpcCallHistoryEntries({ historyDir, file, entries }) {
2781
+ const filePath = path.join(historyDir, file);
2782
+ const { method, event: eventName = null } = entries[0];
2783
+ const current = readJsonIfExists(filePath) ?? {
2784
+ method,
2785
+ ...(eventName ? { event: eventName } : {}),
2786
+ entries: [],
2787
+ };
2788
+ expect(current.method === method, `RPC call history file method mismatch: ${filePath}.`);
2789
+ expect(Array.isArray(current.entries), `RPC call history file entries must be an array: ${filePath}.`);
2790
+ if (eventName) {
2791
+ expect(current.event === eventName, `RPC call history file event mismatch: ${filePath}.`);
2792
+ }
2793
+ current.updatedAt = new Date().toISOString();
2794
+ current.entries.push(...entries);
2795
+ writeJson(filePath, current);
2796
+ return {
2797
+ method,
2798
+ ...(eventName ? { event: eventName } : {}),
2799
+ file,
2800
+ path: filePath,
2801
+ entriesAdded: entries.length,
2802
+ totalEntries: current.entries.length,
2803
+ };
2804
+ }
2805
+
2806
+ function buildRawJsonRpcRequest(method, params = []) {
2807
+ return {
2808
+ jsonrpc: "2.0",
2809
+ method,
2810
+ params: params ?? [],
2811
+ };
2812
+ }
2813
+
2814
+ function rpcCallHistoryFileName(method, eventName = null) {
2815
+ const suffix = eventName ? `.${safeRpcCallHistoryFileToken(eventName)}` : "";
2816
+ return `${safeRpcCallHistoryFileToken(method)}${suffix}.json`;
2817
+ }
2818
+
2819
+ function safeRpcCallHistoryFileToken(value) {
2820
+ return String(value).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
2821
+ }
2822
+
2823
+ function groupRawEthGetLogsByRecoveryEvent({ logs, groupedValues }) {
2824
+ if (logs.length === 0) {
2825
+ return new Map([["noLogs", []]]);
2826
+ }
2827
+ const eventNamesByLog = new Map();
2828
+ for (const group of groupedValues) {
2829
+ for (const event of group) {
2830
+ eventNamesByLog.set(recoveryLogHistoryKey(event), channelRecoveryEventName(event));
2831
+ }
2832
+ }
2833
+ const buckets = new Map();
2834
+ for (const log of logs) {
2835
+ const eventName = eventNamesByLog.get(recoveryLogHistoryKey(log)) ?? "unknown";
2836
+ const bucket = buckets.get(eventName) ?? [];
2837
+ bucket.push(log);
2838
+ buckets.set(eventName, bucket);
2839
+ }
2840
+ return buckets;
2841
+ }
2842
+
2843
+ function recoveryLogHistoryKey(log) {
2844
+ return `${normalizeBytes32Hex(log.transactionHash)}:${Number(log.index ?? log.logIndex)}`;
2845
+ }
2846
+
2847
+ function channelRecoveryEventName(event) {
2848
+ if (event.fragment?.name) {
2849
+ return event.fragment.name;
2850
+ }
2851
+ const topic0 = event.topics[0] ? normalizeBytes32Hex(event.topics[0]) : null;
2852
+ if (topic0 === normalizeBytes32Hex(CONTROLLER_STORAGE_KEY_OBSERVED_TOPIC)) {
2853
+ return "StorageKeyObserved";
2854
+ }
2855
+ if (topic0 === normalizeBytes32Hex(VAULT_STORAGE_WRITE_OBSERVED_TOPIC)) {
2856
+ return "LiquidBalanceStorageWriteObserved";
2857
+ }
2858
+ return "unknown";
2859
+ }
2860
+
2861
+ function buildRawEthGetLogsRequest(request) {
2862
+ const filter = {
2863
+ address: request.address,
2864
+ topics: request.topics,
2865
+ fromBlock: ethers.toQuantity(request.fromBlock),
2866
+ toBlock: ethers.toQuantity(request.toBlock),
2867
+ };
2868
+ return buildRawJsonRpcRequest("eth_getLogs", [filter]);
2869
+ }
2870
+
2659
2871
  function nextAvailablePath(basePath) {
2660
2872
  if (!fs.existsSync(basePath)) {
2661
2873
  return basePath;
@@ -3623,12 +3835,10 @@ function inspectGuideNetworkRuntime(networkName) {
3623
3835
 
3624
3836
  function inspectGuideDeploymentArtifacts(chainId) {
3625
3837
  const paths = privateStateCliArtifactPaths(resolveArtifactCacheBaseRoot(), chainId);
3626
- const readOnlyMissingFiles = privateStateCliArtifactRequiredFiles(paths, PRIVATE_STATE_INSTALL_MODES.READ_ONLY)
3627
- .map((entry) => entry.path)
3628
- .filter((filePath) => !fs.existsSync(filePath));
3629
- const fullMissingFiles = privateStateCliArtifactRequiredFiles(paths, PRIVATE_STATE_INSTALL_MODES.FULL)
3630
- .map((entry) => entry.path)
3631
- .filter((filePath) => !fs.existsSync(filePath));
3838
+ const readOnlyMissingFiles = missingInstalledDeploymentArtifactFiles(paths, PRIVATE_STATE_INSTALL_MODES.READ_ONLY)
3839
+ .map((entry) => entry.path);
3840
+ const fullMissingFiles = missingInstalledDeploymentArtifactFiles(paths, PRIVATE_STATE_INSTALL_MODES.FULL)
3841
+ .map((entry) => entry.path);
3632
3842
  return {
3633
3843
  installed: readOnlyMissingFiles.length === 0,
3634
3844
  readOnlyInstalled: readOnlyMissingFiles.length === 0,
@@ -8437,39 +8647,6 @@ function appendSplitWord(target, startIndex, value) {
8437
8647
  target[startIndex + 1] = normalized >> 128n;
8438
8648
  }
8439
8649
 
8440
- async function fetchChannelBlockInfo(provider, blockNumber) {
8441
- const blockTag = ethers.toQuantity(blockNumber);
8442
- const block = await provider.send("eth_getBlockByNumber", [blockTag, false]);
8443
- if (!block) {
8444
- throw new Error(`Unable to fetch channel genesis block ${blockNumber}.`);
8445
- }
8446
-
8447
- const prevBlockHashes = [];
8448
- for (let offset = 1; offset <= TOKAMAK_PREVIOUS_BLOCK_HASH_COUNT; offset += 1) {
8449
- if (blockNumber <= offset) {
8450
- prevBlockHashes.push("0x0");
8451
- continue;
8452
- }
8453
- const previousBlock = await provider.send("eth_getBlockByNumber", [ethers.toQuantity(blockNumber - offset), false]);
8454
- if (!previousBlock) {
8455
- throw new Error(`Unable to fetch previous block hash for block ${blockNumber - offset}.`);
8456
- }
8457
- prevBlockHashes.push(previousBlock.hash);
8458
- }
8459
-
8460
- return {
8461
- coinBase: block.miner,
8462
- timeStamp: block.timestamp,
8463
- blockNumber: block.number,
8464
- prevRanDao: block.prevRandao ?? block.mixHash ?? block.difficulty ?? "0x0",
8465
- gasLimit: block.gasLimit,
8466
- chainId: await provider.send("eth_chainId", []),
8467
- selfBalance: "0x0",
8468
- baseFee: block.baseFeePerGas ?? "0x0",
8469
- prevBlockHashes,
8470
- };
8471
- }
8472
-
8473
8650
  async function fetchChannelRecoveryLogs({
8474
8651
  provider,
8475
8652
  bridgeAbiManifest,
@@ -8510,6 +8687,7 @@ async function fetchChannelRecoveryEventGroupsChunked({
8510
8687
  fromBlock,
8511
8688
  toBlock,
8512
8689
  progressAction = null,
8690
+ rpcCallHistoryRecorder = null,
8513
8691
  onChunk,
8514
8692
  }) {
8515
8693
  const recoveryFilter = buildChannelRecoveryLogFilter({
@@ -8527,7 +8705,7 @@ async function fetchChannelRecoveryEventGroupsChunked({
8527
8705
  onProgress: progressAction
8528
8706
  ? createRpcLogScanProgress({ action: progressAction, label: "channel-recovery chunks" })
8529
8707
  : null,
8530
- onChunk: async ({ logs, chunkFromBlock, chunkToBlock }) => {
8708
+ onChunk: async ({ request, logs, chunkFromBlock, chunkToBlock }) => {
8531
8709
  const groupedValues = normalizeWorkspaceMirrorDeltaEventGroups({
8532
8710
  logs,
8533
8711
  channelInfo,
@@ -8535,6 +8713,13 @@ async function fetchChannelRecoveryEventGroupsChunked({
8535
8713
  fromBlock: chunkFromBlock,
8536
8714
  toBlock: chunkToBlock,
8537
8715
  });
8716
+ rpcCallHistoryRecorder?.recordEthGetLogs({
8717
+ request,
8718
+ logs,
8719
+ groupedValues,
8720
+ chunkFromBlock,
8721
+ chunkToBlock,
8722
+ });
8538
8723
  await onChunk?.({
8539
8724
  groupedValues,
8540
8725
  chunkFromBlock,
@@ -8591,6 +8776,7 @@ async function reconstructChannelSnapshot({
8591
8776
  toBlock = null,
8592
8777
  progressAction = null,
8593
8778
  onCheckpoint = null,
8779
+ rpcCallHistoryRecorder = null,
8594
8780
  }) {
8595
8781
  let startingSnapshot = baseSnapshot;
8596
8782
  if (!startingSnapshot) {
@@ -8627,6 +8813,7 @@ async function reconstructChannelSnapshot({
8627
8813
  fromBlock: scanFromBlock,
8628
8814
  toBlock: latestBlock,
8629
8815
  progressAction,
8816
+ rpcCallHistoryRecorder,
8630
8817
  onChunk: async ({ groupedValues, chunkFromBlock, chunkToBlock }) => {
8631
8818
  currentSnapshot = await applyChannelRecoveryEventGroupsToStateManager({
8632
8819
  stateManager,
@@ -8979,13 +9166,14 @@ async function fetchLogsChunked(provider, {
8979
9166
  while (cursor <= resolvedToBlock) {
8980
9167
  const chunkToBlock = Math.min(resolvedToBlock, cursor + chunkSize - 1);
8981
9168
  let logs;
9169
+ const request = {
9170
+ address,
9171
+ topics,
9172
+ fromBlock: cursor,
9173
+ toBlock: chunkToBlock,
9174
+ };
8982
9175
  try {
8983
- logs = await fetchLogsRateLimited(provider, {
8984
- address,
8985
- topics,
8986
- fromBlock: cursor,
8987
- toBlock: chunkToBlock,
8988
- });
9176
+ logs = await fetchLogsRateLimited(provider, request);
8989
9177
  } catch (error) {
8990
9178
  throw buildRpcLogQueryConfigError({
8991
9179
  error,
@@ -9019,6 +9207,7 @@ async function fetchLogsChunked(provider, {
9019
9207
  totalBlocks,
9020
9208
  logsFound,
9021
9209
  chunkLogs: logs.length,
9210
+ request,
9022
9211
  logs,
9023
9212
  });
9024
9213
  cursor = chunkToBlock + 1;
@@ -10154,10 +10343,16 @@ function assertCreateChannelArgs(args) {
10154
10343
 
10155
10344
  function assertRecoverWorkspaceArgs(args) {
10156
10345
  assertAllowedCommandSchema(args, "channel-recover-workspace");
10157
- resolveWorkspaceRecoverySource(args);
10346
+ const source = resolveWorkspaceRecoverySource(args);
10158
10347
  if (args.fromGenesis !== undefined && args.fromGenesis !== true) {
10159
10348
  throw new Error("channel recover-workspace option --from-genesis does not accept a value.");
10160
10349
  }
10350
+ if (args.outputRaw !== undefined && args.outputRaw !== true) {
10351
+ throw new Error("channel recover-workspace option --output-raw does not accept a value.");
10352
+ }
10353
+ if (args.outputRaw === true && source !== "rpc") {
10354
+ throw new Error("channel recover-workspace option --output-raw requires --source rpc.");
10355
+ }
10161
10356
  }
10162
10357
 
10163
10358
  function assertGetChannelArgs(args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamak-private-dapps/private-state-cli",
3
- "version": "2.2.0",
3
+ "version": "2.2.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",