@tokamak-private-dapps/private-state-cli 2.2.0 → 2.3.0

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,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.3.0 - 2026-05-20
6
+
7
+ - Added `help observer` to print the deployed public observer URL for the private-state monitoring
8
+ surface.
9
+ - Documented the deployed public observer in the monitoring audit packet and observability matrix.
10
+
11
+ ## 2.2.1 - 2026-05-18
12
+
13
+ - Added `channel recover-workspace --source rpc --output-raw` to append raw JSON-RPC request and response history
14
+ to method-specific JSON files under the channel workspace `rpcCallHistory/` directory, with `eth_getLogs` split by event.
15
+ Indexed recovery appends to existing history; `--from-genesis` overwrites it with one full genesis-to-latest scan.
16
+
5
17
  ## 2.2.0 - 2026-05-18
6
18
 
7
19
  - 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
 
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  assertDoctorArgs,
3
3
  assertGuideArgs,
4
+ assertObserverArgs,
4
5
  assertInstallZkEvmArgs,
5
6
  assertSetRpcArgs,
6
7
  assertTransactionFeesArgs,
@@ -8,6 +9,7 @@ import {
8
9
  assertUpdateArgs,
9
10
  handleDoctor,
10
11
  handleGuide,
12
+ handleObserver,
11
13
  handleInstallZkEvm,
12
14
  handleSetRpc,
13
15
  handleTransactionFees,
@@ -41,6 +43,10 @@ export const systemCommands = Object.freeze({
41
43
  assertGuideArgs(args);
42
44
  await handleGuide({ args });
43
45
  },
46
+ "help-observer": async (args) => {
47
+ assertObserverArgs(args);
48
+ handleObserver();
49
+ },
44
50
  "help-transaction-fees": async (args) => {
45
51
  assertTransactionFeesArgs(args);
46
52
  const { network, provider, rpcUrl } = loadExplicitCommandRuntime(args);
@@ -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",
@@ -322,6 +329,17 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
322
329
  usage: "optional --network, --channel-name, --account, and --wallet",
323
330
  help: ["Does not accept --rpc-url and never writes RPC configuration"],
324
331
  },
332
+ {
333
+ id: "help-observer",
334
+ display: "help observer",
335
+ description: "Show the deployed public observer URL.",
336
+ fields: [],
337
+ usage: "no options",
338
+ help: [
339
+ "Prints the deployed observer URL so terminals can present it as a clickable link",
340
+ "The observer is a public monitoring surface; it is not a wallet, key manager, or disclosure authority",
341
+ ],
342
+ },
325
343
  {
326
344
  id: "help-transaction-fees",
327
345
  display: "help transaction-fees",
@@ -383,8 +401,8 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
383
401
  display: "channel recover-workspace",
384
402
  description: "Rebuild the local channel workspace from bridge state.",
385
403
  installMode: "read-only",
386
- fields: ["channelName", "network", "source", "fromGenesis"],
387
- usage: "--channel-name, --network, optional --source, optional --from-genesis",
404
+ fields: ["channelName", "network", "source", "fromGenesis", "outputRaw"],
405
+ usage: "--channel-name, --network, optional --source, optional --from-genesis, optional --output-raw",
388
406
  help: [
389
407
  "By default, --source rpc resumes RPC log scanning from the workspace recovery index when available",
390
408
  "--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 +410,7 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
392
410
  "Mirror recovery uses a matching delta bundle when available; otherwise a newer verified full checkpoint replaces the local checkpoint before RPC catch-up",
393
411
  "Fails instead of falling back to genesis when no usable recovery index exists",
394
412
  "Use --source rpc --from-genesis to ignore the recovery index and replay logs from channel genesis",
413
+ "--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
414
  "--from-genesis moves the existing local channel workspace to workspace-rebuild-backups before writing the current-format workspace; local secrets are preserved",
396
415
  "Prints RPC log scan progress while rebuilding the workspace",
397
416
  ],
@@ -731,7 +750,7 @@ export function privateStateCliCommandSynopsis(command) {
731
750
  return null;
732
751
  }
733
752
  const valueLabel = field.valueLabel ?? field.placeholderLabel ?? `<${field.label?.toUpperCase().replace(/\s+/g, "_") ?? "VALUE"}>`;
734
- const option = field.type === "checkbox" || fieldKey === "fromGenesis"
753
+ const option = field.type === "checkbox"
735
754
  ? field.option
736
755
  : `${field.option} ${valueLabel}`;
737
756
  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,
@@ -136,6 +137,7 @@ const PRIVATE_STATE_UNINSTALL_CONFIRMATION =
136
137
  const ACTION_IMPACT_CONFIRMATION =
137
138
  "I understand the public and private impact of this action";
138
139
  const PRIVATE_STATE_CLI_PACKAGE_NAME = privateStateCliPackageJson.name;
140
+ const PRIVATE_STATE_OBSERVER_URL = "https://project-scw1r.vercel.app";
139
141
  const GROTH16_PACKAGE_NAME = "@tokamak-private-dapps/groth16";
140
142
  const TOKAMAK_ZKEVM_CLI_PACKAGE_NAME = "@tokamak-zk-evm/cli";
141
143
  const WALLET_BACKUP_EXPORT_FORMAT = "tokamak-private-state-wallet-backup-export";
@@ -554,25 +556,25 @@ function requireFlatDeploymentArtifactPathsForChainId(chainId) {
554
556
  }
555
557
 
556
558
  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
- );
559
+ const missingFiles = missingInstalledDeploymentArtifactFiles(artifactPaths, mode);
560
+ if (missingFiles.length === 0) {
561
+ return;
575
562
  }
563
+ throw cliError(
564
+ CLI_ERROR_CODES.MISSING_DEPLOYMENT_ARTIFACTS,
565
+ [
566
+ `Missing ${mode} installed deployment artifacts for chain ${chainId} under ${artifactPaths.rootDir}.`,
567
+ mode === PRIVATE_STATE_INSTALL_MODES.FULL
568
+ ? "Run install before running private-state CLI commands that write channel state."
569
+ : "Run install --read-only before running private-state CLI commands that read channel state.",
570
+ `Original error: ${missingFiles.map((entry) => `Missing ${entry.label}: ${entry.path}.`).join(" ")}`,
571
+ ].join(" "),
572
+ );
573
+ }
574
+
575
+ function missingInstalledDeploymentArtifactFiles(artifactPaths, mode) {
576
+ return privateStateCliArtifactRequiredFiles(artifactPaths, mode)
577
+ .filter((entry) => !fs.existsSync(entry.path));
576
578
  }
577
579
 
578
580
  async function handleChannelCreate({ args, network, provider }) {
@@ -721,12 +723,14 @@ async function handleWorkspaceInit({ args, network, provider }) {
721
723
  const workspaceName = channelName;
722
724
  const bridgeResources = loadBridgeResources({ chainId: network.chainId });
723
725
  const recoverySource = resolveWorkspaceRecoverySource(args);
726
+ const outputRawRpcCallHistory = args.outputRaw === true;
724
727
 
725
728
  const {
726
729
  workspaceDir,
727
730
  workspace,
728
731
  currentSnapshot,
729
732
  cleanRebuildBackup,
733
+ rpcCallHistory,
730
734
  } = await syncChannelWorkspace({
731
735
  workspaceName,
732
736
  channelName,
@@ -738,6 +742,7 @@ async function handleWorkspaceInit({ args, network, provider }) {
738
742
  useWorkspaceRecoveryIndex: true,
739
743
  fromGenesis: args.fromGenesis === true,
740
744
  recoverySource,
745
+ outputRawRpcCallHistory,
741
746
  progressAction: "channel recover-workspace",
742
747
  });
743
748
 
@@ -757,6 +762,7 @@ async function handleWorkspaceInit({ args, network, provider }) {
757
762
  recoveryLastScannedBlock: workspace.recoveryLastScannedBlock,
758
763
  recoveryRootVectorHash: workspace.recoveryRootVectorHash,
759
764
  recoveryScanRange: workspace.recoveryScanRange,
765
+ rpcCallHistory,
760
766
  workspaceMirror: workspace.workspaceMirror ?? null,
761
767
  });
762
768
  }
@@ -1968,6 +1974,7 @@ async function syncChannelWorkspace({
1968
1974
  useWorkspaceRecoveryIndex = false,
1969
1975
  fromGenesis = false,
1970
1976
  recoverySource = "rpc",
1977
+ outputRawRpcCallHistory = false,
1971
1978
  minimumToBlock = null,
1972
1979
  progressAction = null,
1973
1980
  }) {
@@ -1993,8 +2000,15 @@ async function syncChannelWorkspace({
1993
2000
  });
1994
2001
  }
1995
2002
 
2003
+ const rpcCallHistoryRecorder = outputRawRpcCallHistory
2004
+ ? createRpcCallHistoryRecorder({ workspaceDir })
2005
+ : null;
2006
+ const activeProvider = rpcCallHistoryRecorder
2007
+ ? attachRpcCallHistoryRecorderToProvider(provider, rpcCallHistoryRecorder)
2008
+ : provider;
2009
+
1996
2010
  const { bridgeDeployment, bridgeAbiManifest } = bridgeResources;
1997
- const bridgeCore = new Contract(bridgeDeployment.bridgeCore, bridgeAbiManifest.contracts.bridgeCore.abi, provider);
2011
+ const bridgeCore = new Contract(bridgeDeployment.bridgeCore, bridgeAbiManifest.contracts.bridgeCore.abi, activeProvider);
1998
2012
  const channelId = deriveChannelIdFromName(channelName);
1999
2013
  const channelInfo = await bridgeCore.getChannel(channelId);
2000
2014
  if (!channelInfo.exists) {
@@ -2004,13 +2018,13 @@ async function syncChannelWorkspace({
2004
2018
  const channelManager = new Contract(
2005
2019
  channelInfo.manager,
2006
2020
  bridgeAbiManifest.contracts.channelManager.abi,
2007
- provider,
2021
+ activeProvider,
2008
2022
  );
2009
2023
  const canonicalAsset = getAddress(channelInfo.asset);
2010
- const canonicalAssetDecimals = await fetchTokenDecimals(provider, canonicalAsset);
2024
+ const canonicalAssetDecimals = await fetchTokenDecimals(activeProvider, canonicalAsset);
2011
2025
  const currentRootVectorHash = normalizeBytes32Hex(await channelManager.currentRootVectorHash());
2012
2026
  const genesisBlockNumber = Number(await channelManager.genesisBlockNumber());
2013
- const observedLatestBlock = await provider.getBlockNumber();
2027
+ const observedLatestBlock = await activeProvider.getBlockNumber();
2014
2028
  const latestBlock = minimumToBlock === null
2015
2029
  ? observedLatestBlock
2016
2030
  : Math.max(observedLatestBlock, Number(minimumToBlock));
@@ -2038,8 +2052,8 @@ async function syncChannelWorkspace({
2038
2052
  `Managed storage vector does not include L2 accounting vault ${l2AccountingVaultAddress}.`,
2039
2053
  );
2040
2054
 
2041
- const contractCodes = await fetchContractCodes(provider, managedStorageAddresses);
2042
- const blockInfo = await fetchChannelBlockInfo(provider, genesisBlockNumber);
2055
+ const contractCodes = await fetchContractCodes(activeProvider, managedStorageAddresses);
2056
+ const blockInfo = await getBlockInfoAt(activeProvider, genesisBlockNumber);
2043
2057
  const derivedAPubBlockHash = normalizeBytes32Hex(hashTokamakPublicInputs(encodeTokamakBlockInfo(blockInfo)));
2044
2058
  expect(
2045
2059
  ethers.toBigInt(derivedAPubBlockHash) === ethers.toBigInt(normalizeBytes32Hex(channelInfo.aPubBlockHash)),
@@ -2154,6 +2168,10 @@ async function syncChannelWorkspace({
2154
2168
  });
2155
2169
  }
2156
2170
  : null;
2171
+ rpcCallHistoryRecorder?.setScanRange({
2172
+ fromBlock: selectedRecoveryIndex?.nextBlock ?? genesisBlockNumber,
2173
+ toBlock: latestBlock,
2174
+ });
2157
2175
  const reconstruction = localSnapshotReusable
2158
2176
  ? {
2159
2177
  currentSnapshot: existingArtifacts.stateSnapshot,
@@ -2173,7 +2191,7 @@ async function syncChannelWorkspace({
2173
2191
  },
2174
2192
  }
2175
2193
  : await reconstructChannelSnapshot({
2176
- provider,
2194
+ provider: activeProvider,
2177
2195
  bridgeAbiManifest,
2178
2196
  channelInfo,
2179
2197
  channelManager,
@@ -2190,7 +2208,9 @@ async function syncChannelWorkspace({
2190
2208
  toBlock: latestBlock,
2191
2209
  progressAction,
2192
2210
  onCheckpoint: persistWorkspaceCheckpoint,
2211
+ rpcCallHistoryRecorder,
2193
2212
  });
2213
+ rpcCallHistoryRecorder?.setScanRange(reconstruction.scanRange);
2194
2214
  const currentSnapshot = reconstruction.currentSnapshot;
2195
2215
  const workspace = buildWorkspaceForSnapshot({
2196
2216
  currentSnapshot,
@@ -2215,6 +2235,7 @@ async function syncChannelWorkspace({
2215
2235
  blockInfo,
2216
2236
  contractCodes,
2217
2237
  cleanRebuildBackup,
2238
+ rpcCallHistory: rpcCallHistoryRecorder?.finish() ?? null,
2218
2239
  };
2219
2240
  }
2220
2241
 
@@ -2656,6 +2677,198 @@ function persistChannelWorkspaceFiles({
2656
2677
  writeJsonIfChanged(channelWorkspaceConfigPath(workspaceDir), workspace);
2657
2678
  }
2658
2679
 
2680
+ function channelWorkspaceRpcCallHistoryPath(workspaceDir) {
2681
+ return path.join(channelDataPath(workspaceDir), "rpcCallHistory");
2682
+ }
2683
+
2684
+ function createRpcCallHistoryRecorder({ workspaceDir }) {
2685
+ const historyDir = channelWorkspaceRpcCallHistoryPath(workspaceDir);
2686
+ const entriesByFile = new Map();
2687
+ let scanRange = null;
2688
+ let callCount = 0;
2689
+ ensureDir(historyDir);
2690
+
2691
+ const pushEntry = ({ method, eventName = null, entry }) => {
2692
+ const file = rpcCallHistoryFileName(method, eventName);
2693
+ const entries = entriesByFile.get(file) ?? [];
2694
+ entries.push({
2695
+ method,
2696
+ ...(eventName ? { event: eventName } : {}),
2697
+ ...entry,
2698
+ });
2699
+ entriesByFile.set(file, entries);
2700
+ };
2701
+
2702
+ return {
2703
+ historyDir,
2704
+ setScanRange(nextScanRange) {
2705
+ scanRange = {
2706
+ fromBlock: Number(nextScanRange.fromBlock),
2707
+ toBlock: Number(nextScanRange.toBlock),
2708
+ ...(nextScanRange.mode ? { mode: nextScanRange.mode } : {}),
2709
+ };
2710
+ },
2711
+ recordRpcCall({ method, params, response, error = null }) {
2712
+ if (method === "eth_getLogs") {
2713
+ return;
2714
+ }
2715
+ callCount += 1;
2716
+ pushEntry({
2717
+ method,
2718
+ entry: {
2719
+ recordedAt: new Date().toISOString(),
2720
+ request: buildRawJsonRpcRequest(method, params),
2721
+ ...(error ? { error } : { response }),
2722
+ },
2723
+ });
2724
+ },
2725
+ recordEthGetLogs({ request, logs, groupedValues, chunkFromBlock, chunkToBlock }) {
2726
+ callCount += 1;
2727
+ const eventBuckets = groupRawEthGetLogsByRecoveryEvent({ logs, groupedValues });
2728
+ for (const [eventName, response] of eventBuckets.entries()) {
2729
+ pushEntry({
2730
+ method: "eth_getLogs",
2731
+ eventName,
2732
+ entry: {
2733
+ recordedAt: new Date().toISOString(),
2734
+ chunkRange: { fromBlock: Number(chunkFromBlock), toBlock: Number(chunkToBlock) },
2735
+ request: buildRawEthGetLogsRequest(request),
2736
+ response,
2737
+ },
2738
+ });
2739
+ }
2740
+ },
2741
+ finish() {
2742
+ const files = [...entriesByFile.entries()].map(([file, entries]) =>
2743
+ appendRpcCallHistoryEntries({
2744
+ historyDir,
2745
+ file,
2746
+ entries: entries.map((entry) => ({ scanRange, ...entry })),
2747
+ }));
2748
+ return {
2749
+ historyDir,
2750
+ scanRange,
2751
+ callCount,
2752
+ files: files.sort((left, right) => left.file.localeCompare(right.file)),
2753
+ };
2754
+ },
2755
+ };
2756
+ }
2757
+
2758
+ function attachRpcCallHistoryRecorderToProvider(provider, recorder) {
2759
+ const send = provider.send.bind(provider);
2760
+ provider.send = async (method, params) => {
2761
+ try {
2762
+ const response = await send(method, params);
2763
+ recorder.recordRpcCall({ method, params, response });
2764
+ return response;
2765
+ } catch (error) {
2766
+ recorder.recordRpcCall({ method, params, error: normalizeRpcCallHistoryError(error) });
2767
+ throw error;
2768
+ }
2769
+ };
2770
+ return provider;
2771
+ }
2772
+
2773
+ function normalizeRpcCallHistoryError(error) {
2774
+ return {
2775
+ name: error?.name ?? "Error",
2776
+ code: error?.code ?? null,
2777
+ message: error?.message ?? String(error),
2778
+ };
2779
+ }
2780
+
2781
+ function appendRpcCallHistoryEntries({ historyDir, file, entries }) {
2782
+ const filePath = path.join(historyDir, file);
2783
+ const { method, event: eventName = null } = entries[0];
2784
+ const current = readJsonIfExists(filePath) ?? {
2785
+ method,
2786
+ ...(eventName ? { event: eventName } : {}),
2787
+ entries: [],
2788
+ };
2789
+ expect(current.method === method, `RPC call history file method mismatch: ${filePath}.`);
2790
+ expect(Array.isArray(current.entries), `RPC call history file entries must be an array: ${filePath}.`);
2791
+ if (eventName) {
2792
+ expect(current.event === eventName, `RPC call history file event mismatch: ${filePath}.`);
2793
+ }
2794
+ current.updatedAt = new Date().toISOString();
2795
+ current.entries.push(...entries);
2796
+ writeJson(filePath, current);
2797
+ return {
2798
+ method,
2799
+ ...(eventName ? { event: eventName } : {}),
2800
+ file,
2801
+ path: filePath,
2802
+ entriesAdded: entries.length,
2803
+ totalEntries: current.entries.length,
2804
+ };
2805
+ }
2806
+
2807
+ function buildRawJsonRpcRequest(method, params = []) {
2808
+ return {
2809
+ jsonrpc: "2.0",
2810
+ method,
2811
+ params: params ?? [],
2812
+ };
2813
+ }
2814
+
2815
+ function rpcCallHistoryFileName(method, eventName = null) {
2816
+ const suffix = eventName ? `.${safeRpcCallHistoryFileToken(eventName)}` : "";
2817
+ return `${safeRpcCallHistoryFileToken(method)}${suffix}.json`;
2818
+ }
2819
+
2820
+ function safeRpcCallHistoryFileToken(value) {
2821
+ return String(value).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
2822
+ }
2823
+
2824
+ function groupRawEthGetLogsByRecoveryEvent({ logs, groupedValues }) {
2825
+ if (logs.length === 0) {
2826
+ return new Map([["noLogs", []]]);
2827
+ }
2828
+ const eventNamesByLog = new Map();
2829
+ for (const group of groupedValues) {
2830
+ for (const event of group) {
2831
+ eventNamesByLog.set(recoveryLogHistoryKey(event), channelRecoveryEventName(event));
2832
+ }
2833
+ }
2834
+ const buckets = new Map();
2835
+ for (const log of logs) {
2836
+ const eventName = eventNamesByLog.get(recoveryLogHistoryKey(log)) ?? "unknown";
2837
+ const bucket = buckets.get(eventName) ?? [];
2838
+ bucket.push(log);
2839
+ buckets.set(eventName, bucket);
2840
+ }
2841
+ return buckets;
2842
+ }
2843
+
2844
+ function recoveryLogHistoryKey(log) {
2845
+ return `${normalizeBytes32Hex(log.transactionHash)}:${Number(log.index ?? log.logIndex)}`;
2846
+ }
2847
+
2848
+ function channelRecoveryEventName(event) {
2849
+ if (event.fragment?.name) {
2850
+ return event.fragment.name;
2851
+ }
2852
+ const topic0 = event.topics[0] ? normalizeBytes32Hex(event.topics[0]) : null;
2853
+ if (topic0 === normalizeBytes32Hex(CONTROLLER_STORAGE_KEY_OBSERVED_TOPIC)) {
2854
+ return "StorageKeyObserved";
2855
+ }
2856
+ if (topic0 === normalizeBytes32Hex(VAULT_STORAGE_WRITE_OBSERVED_TOPIC)) {
2857
+ return "LiquidBalanceStorageWriteObserved";
2858
+ }
2859
+ return "unknown";
2860
+ }
2861
+
2862
+ function buildRawEthGetLogsRequest(request) {
2863
+ const filter = {
2864
+ address: request.address,
2865
+ topics: request.topics,
2866
+ fromBlock: ethers.toQuantity(request.fromBlock),
2867
+ toBlock: ethers.toQuantity(request.toBlock),
2868
+ };
2869
+ return buildRawJsonRpcRequest("eth_getLogs", [filter]);
2870
+ }
2871
+
2659
2872
  function nextAvailablePath(basePath) {
2660
2873
  if (!fs.existsSync(basePath)) {
2661
2874
  return basePath;
@@ -2866,6 +3079,18 @@ async function handleDoctor({ args }) {
2866
3079
  }
2867
3080
  }
2868
3081
 
3082
+ function handleObserver() {
3083
+ printJson({
3084
+ action: "observer",
3085
+ url: PRIVATE_STATE_OBSERVER_URL,
3086
+ scope: "Public monitoring observer for Tokamak Private App Channels and the private-state DApp.",
3087
+ notes: [
3088
+ "The observer helps users and reviewers inspect public monitoring surfaces.",
3089
+ "The observer does not receive wallet secrets, spending keys, viewing keys, or private note plaintext.",
3090
+ ],
3091
+ });
3092
+ }
3093
+
2869
3094
  function handleInvestigator() {
2870
3095
  const htmlPath = resolveInvestigatorIndexPath();
2871
3096
  const fileUrl = pathToFileURL(htmlPath).href;
@@ -3623,12 +3848,10 @@ function inspectGuideNetworkRuntime(networkName) {
3623
3848
 
3624
3849
  function inspectGuideDeploymentArtifacts(chainId) {
3625
3850
  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));
3851
+ const readOnlyMissingFiles = missingInstalledDeploymentArtifactFiles(paths, PRIVATE_STATE_INSTALL_MODES.READ_ONLY)
3852
+ .map((entry) => entry.path);
3853
+ const fullMissingFiles = missingInstalledDeploymentArtifactFiles(paths, PRIVATE_STATE_INSTALL_MODES.FULL)
3854
+ .map((entry) => entry.path);
3632
3855
  return {
3633
3856
  installed: readOnlyMissingFiles.length === 0,
3634
3857
  readOnlyInstalled: readOnlyMissingFiles.length === 0,
@@ -8437,39 +8660,6 @@ function appendSplitWord(target, startIndex, value) {
8437
8660
  target[startIndex + 1] = normalized >> 128n;
8438
8661
  }
8439
8662
 
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
8663
  async function fetchChannelRecoveryLogs({
8474
8664
  provider,
8475
8665
  bridgeAbiManifest,
@@ -8510,6 +8700,7 @@ async function fetchChannelRecoveryEventGroupsChunked({
8510
8700
  fromBlock,
8511
8701
  toBlock,
8512
8702
  progressAction = null,
8703
+ rpcCallHistoryRecorder = null,
8513
8704
  onChunk,
8514
8705
  }) {
8515
8706
  const recoveryFilter = buildChannelRecoveryLogFilter({
@@ -8527,7 +8718,7 @@ async function fetchChannelRecoveryEventGroupsChunked({
8527
8718
  onProgress: progressAction
8528
8719
  ? createRpcLogScanProgress({ action: progressAction, label: "channel-recovery chunks" })
8529
8720
  : null,
8530
- onChunk: async ({ logs, chunkFromBlock, chunkToBlock }) => {
8721
+ onChunk: async ({ request, logs, chunkFromBlock, chunkToBlock }) => {
8531
8722
  const groupedValues = normalizeWorkspaceMirrorDeltaEventGroups({
8532
8723
  logs,
8533
8724
  channelInfo,
@@ -8535,6 +8726,13 @@ async function fetchChannelRecoveryEventGroupsChunked({
8535
8726
  fromBlock: chunkFromBlock,
8536
8727
  toBlock: chunkToBlock,
8537
8728
  });
8729
+ rpcCallHistoryRecorder?.recordEthGetLogs({
8730
+ request,
8731
+ logs,
8732
+ groupedValues,
8733
+ chunkFromBlock,
8734
+ chunkToBlock,
8735
+ });
8538
8736
  await onChunk?.({
8539
8737
  groupedValues,
8540
8738
  chunkFromBlock,
@@ -8591,6 +8789,7 @@ async function reconstructChannelSnapshot({
8591
8789
  toBlock = null,
8592
8790
  progressAction = null,
8593
8791
  onCheckpoint = null,
8792
+ rpcCallHistoryRecorder = null,
8594
8793
  }) {
8595
8794
  let startingSnapshot = baseSnapshot;
8596
8795
  if (!startingSnapshot) {
@@ -8627,6 +8826,7 @@ async function reconstructChannelSnapshot({
8627
8826
  fromBlock: scanFromBlock,
8628
8827
  toBlock: latestBlock,
8629
8828
  progressAction,
8829
+ rpcCallHistoryRecorder,
8630
8830
  onChunk: async ({ groupedValues, chunkFromBlock, chunkToBlock }) => {
8631
8831
  currentSnapshot = await applyChannelRecoveryEventGroupsToStateManager({
8632
8832
  stateManager,
@@ -8979,13 +9179,14 @@ async function fetchLogsChunked(provider, {
8979
9179
  while (cursor <= resolvedToBlock) {
8980
9180
  const chunkToBlock = Math.min(resolvedToBlock, cursor + chunkSize - 1);
8981
9181
  let logs;
9182
+ const request = {
9183
+ address,
9184
+ topics,
9185
+ fromBlock: cursor,
9186
+ toBlock: chunkToBlock,
9187
+ };
8982
9188
  try {
8983
- logs = await fetchLogsRateLimited(provider, {
8984
- address,
8985
- topics,
8986
- fromBlock: cursor,
8987
- toBlock: chunkToBlock,
8988
- });
9189
+ logs = await fetchLogsRateLimited(provider, request);
8989
9190
  } catch (error) {
8990
9191
  throw buildRpcLogQueryConfigError({
8991
9192
  error,
@@ -9019,6 +9220,7 @@ async function fetchLogsChunked(provider, {
9019
9220
  totalBlocks,
9020
9221
  logsFound,
9021
9222
  chunkLogs: logs.length,
9223
+ request,
9022
9224
  logs,
9023
9225
  });
9024
9226
  cursor = chunkToBlock + 1;
@@ -10066,6 +10268,10 @@ function assertGuideArgs(args) {
10066
10268
  assertAllowedCommandSchema(args, "help-guide");
10067
10269
  }
10068
10270
 
10271
+ function assertObserverArgs(args) {
10272
+ assertAllowedCommandSchema(args, "help-observer");
10273
+ }
10274
+
10069
10275
  function assertTransactionFeesArgs(args) {
10070
10276
  assertAllowedCommandSchema(args, "help-transaction-fees");
10071
10277
  }
@@ -10154,10 +10360,16 @@ function assertCreateChannelArgs(args) {
10154
10360
 
10155
10361
  function assertRecoverWorkspaceArgs(args) {
10156
10362
  assertAllowedCommandSchema(args, "channel-recover-workspace");
10157
- resolveWorkspaceRecoverySource(args);
10363
+ const source = resolveWorkspaceRecoverySource(args);
10158
10364
  if (args.fromGenesis !== undefined && args.fromGenesis !== true) {
10159
10365
  throw new Error("channel recover-workspace option --from-genesis does not accept a value.");
10160
10366
  }
10367
+ if (args.outputRaw !== undefined && args.outputRaw !== true) {
10368
+ throw new Error("channel recover-workspace option --output-raw does not accept a value.");
10369
+ }
10370
+ if (args.outputRaw === true && source !== "rpc") {
10371
+ throw new Error("channel recover-workspace option --output-raw requires --source rpc.");
10372
+ }
10161
10373
  }
10162
10374
 
10163
10375
  function assertGetChannelArgs(args) {
@@ -11011,6 +11223,7 @@ function loadWalletCommandRuntime(args, { prepareArtifacts = false } = {}) {
11011
11223
  const HUMAN_RESULT_RENDERERS = Object.freeze({
11012
11224
  guide: printGuideHumanResult,
11013
11225
  investigator: printInvestigatorHumanResult,
11226
+ observer: printObserverHumanResult,
11014
11227
  "transaction-fees": printTransactionFeesHumanResult,
11015
11228
  update: printUpdateHumanResult,
11016
11229
  });
@@ -11093,6 +11306,24 @@ function printInvestigatorHumanResult(result) {
11093
11306
  console.log(lines.join("\n"));
11094
11307
  }
11095
11308
 
11309
+ function printObserverHumanResult(result) {
11310
+ const lines = [
11311
+ "Private-State Public Observer",
11312
+ `URL: ${formatHumanValue(result.url)}`,
11313
+ ];
11314
+ if (result.scope) {
11315
+ lines.push(`Scope: ${formatHumanValue(result.scope)}`);
11316
+ }
11317
+ if (Array.isArray(result.notes) && result.notes.length > 0) {
11318
+ lines.push(
11319
+ "",
11320
+ "Notes",
11321
+ ...result.notes.map((note) => `- ${note}`),
11322
+ );
11323
+ }
11324
+ console.log(lines.join("\n"));
11325
+ }
11326
+
11096
11327
  function printTransactionFeesHumanResult(report) {
11097
11328
  const lines = [
11098
11329
  "Transaction Fees",
@@ -11505,6 +11736,7 @@ export {
11505
11736
  assertUpdateArgs,
11506
11737
  assertDoctorArgs,
11507
11738
  assertGuideArgs,
11739
+ assertObserverArgs,
11508
11740
  assertTransactionFeesArgs,
11509
11741
  assertInvestigatorArgs,
11510
11742
  assertAccountGetL1AddressArgs,
@@ -11538,6 +11770,7 @@ export {
11538
11770
  handleUpdate,
11539
11771
  handleDoctor,
11540
11772
  handleGuide,
11773
+ handleObserver,
11541
11774
  handleTransactionFees,
11542
11775
  handleInvestigator,
11543
11776
  handleAccountGetL1Address,
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.3.0",
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",