@tokamak-private-dapps/private-state-cli 1.0.1 → 1.0.2

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,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.0.2 - 2026-05-06
6
+
7
+ - Added `update`, which checks npm registry for the latest private-state CLI package and updates
8
+ global npm installs when a newer version exists.
9
+ - Kept repository checkouts and non-global installs read-only during `update`; those modes print
10
+ the exact `npm install -g @tokamak-private-dapps/private-state-cli@latest` command instead.
11
+ - Reused runtime-management output parsing helpers for `update` and `uninstall` instead of
12
+ duplicating npm JSON and ANSI-output handling in the CLI entrypoint.
5
13
  - Added `transaction-fees`, which reads packaged measured gas data from `assets/tx-fees.json`,
6
14
  combines it with live RPC fee data and live ETH/USD pricing, and prints a per-command ETH/USD
7
15
  fee table.
@@ -15,6 +23,12 @@
15
23
  guiding new users through `join-channel`.
16
24
  - Added RPC log scan progress output to `recover-workspace` and `recover-wallet`, with progress
17
25
  routed to stderr in `--json` mode so machine-readable command results stay valid.
26
+ - Added `recover-wallet --from-genesis` and removed implicit genesis replay fallback from
27
+ `recover-workspace` and `recover-wallet`; both commands now require a usable recovery index
28
+ unless the user explicitly requests `--from-genesis`.
29
+ - Changed `get-my-wallet-meta`, `get-my-channel-fund`, and `get-my-notes` to use indexed
30
+ recovery only before reading channel state, with `get-my-notes` also validating the wallet
31
+ note-receive recovery index before scanning delivery logs.
18
32
  - Unified wallet command workspace refresh through the same recovery-indexed path used by
19
33
  `recover-workspace`, and shared received-note recovery through the wallet's
20
34
  `noteReceiveLastScannedBlock` index.
package/README.md CHANGED
@@ -57,6 +57,16 @@ Print only the installed CLI package version with:
57
57
  private-state-cli --version
58
58
  ```
59
59
 
60
+ Check npm registry for a newer CLI package and update a global npm install when possible:
61
+
62
+ ```bash
63
+ private-state-cli update
64
+ ```
65
+
66
+ `update` keeps `--version` suitable for scripts by using a separate command for registry checks. If the CLI is running
67
+ from a repository checkout or npm does not report a global install, it does not edit local source files; it prints the
68
+ recommended `npm install -g @tokamak-private-dapps/private-state-cli@latest` command instead.
69
+
60
70
  Remove all local private-state CLI data with:
61
71
 
62
72
  ```bash
@@ -86,6 +96,24 @@ A common private-state flow is:
86
96
 
87
97
  Use `private-state-cli --help` for the full command list and required options.
88
98
 
99
+ Workspace recovery commands use the saved recovery index by default. If the local workspace is missing, corrupted, or
100
+ does not contain a usable index, `recover-workspace` and `recover-wallet` stop with an explicit error instead of
101
+ silently replaying logs from channel genesis. Use `--from-genesis` only when you intentionally want to rebuild from the
102
+ channel creation block:
103
+
104
+ ```bash
105
+ private-state-cli recover-workspace --channel-name <CHANNEL> --network mainnet --from-genesis
106
+ private-state-cli recover-wallet --channel-name <CHANNEL> --network mainnet --account <ACCOUNT> --from-genesis
107
+ ```
108
+
109
+ `create-channel` is the exception: after the channel is created on-chain, the CLI initializes that new local workspace
110
+ by replaying from the channel's genesis block because no prior recovery index can exist for a new channel.
111
+
112
+ Wallet getter commands that need channel state, including `get-my-wallet-meta`, `get-my-channel-fund`, and
113
+ `get-my-notes`, follow the same indexed recovery rule before reading local or on-chain state. `get-my-notes` also uses
114
+ the wallet's saved note-receive scan index for encrypted note delivery logs. If either index is unusable, the command
115
+ stops and asks the user to run the appropriate recovery command with `--from-genesis`.
116
+
89
117
  Estimate live transaction costs before sending commands with:
90
118
 
91
119
  ```bash
@@ -205,6 +233,9 @@ Operating rules:
205
233
  `<channelName>-<l1Address>`.
206
234
  - The network RPC URL is the endpoint used to read and write chain state. It can be supplied once with `--rpc-url`
207
235
  on a bridge-facing command, after which the CLI saves it under the selected network.
236
+ - A workspace recovery index is the saved block pointer and state-root hash that lets the CLI resume log scanning
237
+ without replaying the channel from its creation block. If it is missing, explain `--from-genesis` before using it
238
+ because genesis replay can take much longer.
208
239
  - When the user does not have a network RPC URL yet, explain that they need an Ethereum JSON-RPC endpoint for the
209
240
  selected network. They can obtain one from an infrastructure provider such as Alchemy, Infura, QuickNode, or from
210
241
  their own node. Ask the user to create or select the endpoint in that provider's UI, then paste only the endpoint URL
@@ -167,6 +167,16 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
167
167
  fields: [],
168
168
  usage: "no options",
169
169
  },
170
+ {
171
+ id: "update",
172
+ description: "Check npm registry for the latest private-state CLI package and update global installs when possible.",
173
+ fields: ["json"],
174
+ usage: "optional --json",
175
+ help: [
176
+ "Global npm installs are updated with npm install -g when a newer registry version exists",
177
+ "Repository checkouts and non-global installs print the required update command instead of modifying source files",
178
+ ],
179
+ },
170
180
  {
171
181
  id: "doctor",
172
182
  description: "Check private-state CLI package versions, runtime install state, Docker mode, CUDA mode, and deployment artifacts.",
@@ -207,7 +217,10 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
207
217
  description: "Create a bridge channel and initialize its workspace.",
208
218
  fields: ["channelName", "joinToll", "network", "account", "rpcUrl"],
209
219
  usage: "--channel-name, --join-toll, --network, --account, and optional --rpc-url",
210
- help: ["Prints the immutable policy snapshot before sending the transaction"],
220
+ help: [
221
+ "Prints the immutable policy snapshot before sending the transaction",
222
+ "Initializes the local channel workspace by replaying channel logs from channel genesis",
223
+ ],
211
224
  },
212
225
  {
213
226
  id: "recover-workspace",
@@ -216,6 +229,7 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
216
229
  usage: "--channel-name, --network, optional --from-genesis, and optional --rpc-url",
217
230
  help: [
218
231
  "By default, resumes RPC log scanning from the workspace recovery index when available",
232
+ "Fails instead of falling back to genesis when no usable recovery index exists",
219
233
  "Use --from-genesis to ignore the recovery index and replay logs from channel genesis",
220
234
  "Prints RPC log scan progress while rebuilding the workspace",
221
235
  ],
@@ -247,11 +261,14 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
247
261
  {
248
262
  id: "recover-wallet",
249
263
  description: "Rebuild a recoverable local wallet from on-chain channel state.",
250
- fields: ["channelName", "network", "account", "rpcUrl"],
251
- usage: "--channel-name, --network, --account, and optional --rpc-url",
264
+ fields: ["channelName", "network", "account", "fromGenesis", "rpcUrl"],
265
+ usage: "--channel-name, --network, --account, optional --from-genesis, and optional --rpc-url",
252
266
  help: [
253
267
  "Requires the protected wallet-local secret imported during join-channel to exist at the canonical secret path",
254
268
  "Does not create or recover the wallet secret itself",
269
+ "By default, resumes RPC log scanning from the workspace recovery index when available",
270
+ "Fails instead of falling back to genesis when no usable recovery index exists",
271
+ "Use --from-genesis to ignore the recovery index and replay channel logs from channel genesis",
255
272
  "Prints RPC log scan progress while rebuilding channel state and received-note state",
256
273
  ],
257
274
  },
@@ -270,6 +287,7 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
270
287
  description: "Check whether a wallet matches the on-chain channel registration.",
271
288
  fields: ["wallet", "network"],
272
289
  usage: "--wallet and --network",
290
+ help: ["Refreshes channel state through the workspace recovery index before reading registration metadata"],
273
291
  },
274
292
  {
275
293
  id: "get-my-l1-address",
@@ -301,6 +319,7 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
301
319
  description: "Read the current channel L2 accounting balance.",
302
320
  fields: ["wallet", "network"],
303
321
  usage: "--wallet and --network",
322
+ help: ["Refreshes channel state through the workspace recovery index before reading the L2 accounting balance"],
304
323
  },
305
324
  {
306
325
  id: "exit-channel",
@@ -334,6 +353,10 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
334
353
  description: "Show the wallet's tracked note state and refresh received notes.",
335
354
  fields: ["wallet", "network"],
336
355
  usage: "--wallet and --network",
356
+ help: [
357
+ "Refreshes channel state through the workspace recovery index before reading notes",
358
+ "Refreshes received-note logs through the wallet note recovery index",
359
+ ],
337
360
  },
338
361
  ]);
339
362
 
@@ -1300,9 +1300,11 @@ export {
1300
1300
  installGroth16RuntimeForPrivateState,
1301
1301
  installPrivateStateCliArtifacts,
1302
1302
  writePrivateStateCliInstallManifest,
1303
+ parseJsonReport,
1303
1304
  resolveArtifactCacheBaseRoot,
1304
1305
  privateStateCliArtifactPaths,
1305
1306
  inspectGroth16Runtime,
1307
+ stripAnsi,
1306
1308
  resolveActiveGroth16ProverRuntime,
1307
1309
  resolveActiveTokamakCliInvocation,
1308
1310
  readTokamakCliPackageReport,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamak-private-dapps/private-state-cli",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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",
@@ -68,6 +68,7 @@ import {
68
68
  installPrivateStateCliArtifacts,
69
69
  installTokamakCliRuntimeForPrivateState,
70
70
  inspectGroth16Runtime,
71
+ parseJsonReport,
71
72
  printDoctorHumanReport,
72
73
  privateStateCliArtifactPaths,
73
74
  readTokamakCliPackageReport,
@@ -77,6 +78,7 @@ import {
77
78
  resolveArtifactCacheBaseRoot,
78
79
  resolvePrivateStateInstallRuntimeVersions,
79
80
  resolveTokamakCliResourceDirForRuntimeRoot,
81
+ stripAnsi,
80
82
  writePrivateStateCliInstallManifest,
81
83
  } from "./lib/private-state-runtime-management.mjs";
82
84
  import {
@@ -127,6 +129,7 @@ const secretRoot = path.resolve(os.homedir(), "tokamak-private-channels", "secre
127
129
  const flatDeploymentArtifactPathsByChainId = new Map();
128
130
  const PRIVATE_STATE_UNINSTALL_CONFIRMATION =
129
131
  "I understand that the wallet secrets deleted due to this decision cannot be recovered";
132
+ const PRIVATE_STATE_CLI_PACKAGE_NAME = privateStateCliPackageJson.name;
130
133
  const GROTH16_PACKAGE_NAME = "@tokamak-private-dapps/groth16";
131
134
  const TOKAMAK_ZKEVM_CLI_PACKAGE_NAME = "@tokamak-zk-evm/cli";
132
135
  let jsonOutputRequested = false;
@@ -338,6 +341,12 @@ async function main() {
338
341
  return;
339
342
  }
340
343
 
344
+ if (args.command === "update") {
345
+ assertUpdateArgs(args);
346
+ await handleUpdate();
347
+ return;
348
+ }
349
+
341
350
  if (args.command === "doctor") {
342
351
  assertDoctorArgs(args);
343
352
  await handleDoctor({ args });
@@ -525,6 +534,8 @@ async function handleChannelCreate({ args, network, provider }) {
525
534
  provider,
526
535
  bridgeResources,
527
536
  persist: true,
537
+ fromGenesis: true,
538
+ progressAction: "create-channel",
528
539
  });
529
540
 
530
541
  printJson({
@@ -822,6 +833,13 @@ async function initializeChannelWorkspace({
822
833
  managedStorageAddresses,
823
834
  })
824
835
  : null;
836
+ if (useWorkspaceRecoveryIndex && !fromGenesis && !localSnapshotReusable && !recoveryIndex) {
837
+ throw new Error([
838
+ `Workspace recovery index is missing or unusable for channel ${channelName} on ${networkNameFromChainId(network.chainId)}.`,
839
+ "The CLI will not fall back to replaying channel logs from genesis unless explicitly requested.",
840
+ "Run recover-workspace --from-genesis or recover-wallet --from-genesis to rebuild from channel genesis.",
841
+ ].join(" "));
842
+ }
825
843
  const reconstruction = localSnapshotReusable
826
844
  ? {
827
845
  currentSnapshot: existingArtifacts.stateSnapshot,
@@ -993,6 +1011,7 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
993
1011
  persist: true,
994
1012
  allowExistingWorkspaceSync: true,
995
1013
  useWorkspaceRecoveryIndex: true,
1014
+ fromGenesis: args.fromGenesis === true,
996
1015
  progressAction: "recover-wallet",
997
1016
  });
998
1017
  const context = {
@@ -1423,31 +1442,44 @@ function assertSafeManagedRoot(rootPath, label) {
1423
1442
  }
1424
1443
  }
1425
1444
 
1426
- function uninstallGlobalPrivateStateCliPackage() {
1427
- const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
1428
- const list = runCaptured(npmCommand, ["ls", "-g", "@tokamak-private-dapps/private-state-cli", "--depth=0", "--json"]);
1429
- if (list.status !== 0) {
1430
- const listReport = parseJsonReport(list.stdout);
1445
+ function npmCommandName() {
1446
+ return process.platform === "win32" ? "npm.cmd" : "npm";
1447
+ }
1448
+
1449
+ function inspectGlobalPrivateStateCliPackage() {
1450
+ const list = runCaptured(npmCommandName(), ["ls", "-g", PRIVATE_STATE_CLI_PACKAGE_NAME, "--depth=0", "--json"]);
1451
+ const report = parseJsonReport(list.stdout);
1452
+ const packageReport = report?.dependencies?.[PRIVATE_STATE_CLI_PACKAGE_NAME] ?? null;
1453
+ const installed = Boolean(packageReport);
1454
+ if (!installed) {
1431
1455
  const missing = /empty|missing|not found|not installed/iu.test(`${list.stdout}\n${list.stderr}`);
1432
1456
  return {
1433
- attempted: false,
1434
1457
  installed: false,
1435
- reason: missing || listReport ? "global package is not installed" : "unable to inspect global npm package",
1458
+ version: null,
1436
1459
  status: list.status,
1460
+ reason: missing || report ? "global package is not installed" : "unable to inspect global npm package",
1437
1461
  stderr: stripAnsi(list.stderr).trim(),
1438
1462
  };
1439
1463
  }
1440
- const report = parseJsonReport(list.stdout);
1441
- const installed = Boolean(report?.dependencies?.["@tokamak-private-dapps/private-state-cli"]);
1442
- if (!installed) {
1464
+ return {
1465
+ installed: true,
1466
+ version: packageReport.version ?? null,
1467
+ status: list.status,
1468
+ };
1469
+ }
1470
+
1471
+ function uninstallGlobalPrivateStateCliPackage() {
1472
+ const inspection = inspectGlobalPrivateStateCliPackage();
1473
+ if (!inspection.installed) {
1443
1474
  return {
1444
1475
  attempted: false,
1445
1476
  installed: false,
1446
- reason: "global package is not installed",
1447
- status: list.status,
1477
+ reason: inspection.reason,
1478
+ status: inspection.status,
1479
+ stderr: inspection.stderr,
1448
1480
  };
1449
1481
  }
1450
- const uninstall = runCaptured(npmCommand, ["uninstall", "-g", "@tokamak-private-dapps/private-state-cli"]);
1482
+ const uninstall = runCaptured(npmCommandName(), ["uninstall", "-g", PRIVATE_STATE_CLI_PACKAGE_NAME]);
1451
1483
  return {
1452
1484
  attempted: true,
1453
1485
  installed: true,
@@ -1458,6 +1490,106 @@ function uninstallGlobalPrivateStateCliPackage() {
1458
1490
  };
1459
1491
  }
1460
1492
 
1493
+ async function handleUpdate() {
1494
+ const currentVersion = privateStateCliPackageJson.version;
1495
+ const latestVersion = await fetchLatestPrivateStateCliVersion();
1496
+ const registryComparison = compareSemver(currentVersion, latestVersion);
1497
+ const globalPackage = inspectGlobalPrivateStateCliPackage();
1498
+ const runningFromRepositoryCheckout = isRepositoryCliPackageRoot(privateStateCliPackageRoot);
1499
+ const updateAvailable = registryComparison < 0;
1500
+
1501
+ const result = {
1502
+ action: "update",
1503
+ packageName: PRIVATE_STATE_CLI_PACKAGE_NAME,
1504
+ currentVersion,
1505
+ latestVersion,
1506
+ updateAvailable,
1507
+ registryState: registryComparison > 0 ? "local-version-ahead-of-registry" : "normal",
1508
+ runningFromRepositoryCheckout,
1509
+ globalPackage,
1510
+ attempted: false,
1511
+ updated: false,
1512
+ command: `npm install -g ${PRIVATE_STATE_CLI_PACKAGE_NAME}@latest`,
1513
+ };
1514
+
1515
+ if (!updateAvailable) {
1516
+ printJson(result);
1517
+ return;
1518
+ }
1519
+
1520
+ if (runningFromRepositoryCheckout) {
1521
+ printJson({
1522
+ ...result,
1523
+ reason: "running from a repository checkout; update the checkout with git/npm instead of mutating source files",
1524
+ });
1525
+ return;
1526
+ }
1527
+
1528
+ if (!globalPackage.installed) {
1529
+ printJson({
1530
+ ...result,
1531
+ reason: "global npm package is not installed; install or update the CLI with the printed command",
1532
+ });
1533
+ return;
1534
+ }
1535
+
1536
+ const install = runCaptured(npmCommandName(), ["install", "-g", `${PRIVATE_STATE_CLI_PACKAGE_NAME}@latest`]);
1537
+ if (install.status !== 0) {
1538
+ throw new Error([
1539
+ `Unable to update ${PRIVATE_STATE_CLI_PACKAGE_NAME} to ${latestVersion}.`,
1540
+ stripAnsi(install.stderr || install.stdout).trim(),
1541
+ ].filter(Boolean).join(" "));
1542
+ }
1543
+ printJson({
1544
+ ...result,
1545
+ attempted: true,
1546
+ updated: true,
1547
+ installedVersion: latestVersion,
1548
+ stdout: stripAnsi(install.stdout).trim(),
1549
+ stderr: stripAnsi(install.stderr).trim(),
1550
+ });
1551
+ }
1552
+
1553
+ async function fetchLatestPrivateStateCliVersion() {
1554
+ const url = `https://registry.npmjs.org/${encodeURIComponent(PRIVATE_STATE_CLI_PACKAGE_NAME)}/latest`;
1555
+ const response = await fetch(url, {
1556
+ redirect: "follow",
1557
+ signal: typeof globalThis.AbortSignal?.timeout === "function" ? globalThis.AbortSignal.timeout(10_000) : undefined,
1558
+ });
1559
+ if (!response.ok) {
1560
+ throw new Error(`Unable to fetch ${PRIVATE_STATE_CLI_PACKAGE_NAME} latest version from npm registry: HTTP ${response.status}.`);
1561
+ }
1562
+ const metadata = await response.json();
1563
+ const version = metadata?.version;
1564
+ if (typeof version !== "string" || version.trim() === "") {
1565
+ throw new Error(`npm registry response for ${PRIVATE_STATE_CLI_PACKAGE_NAME} did not include a version.`);
1566
+ }
1567
+ return version;
1568
+ }
1569
+
1570
+ function compareSemver(left, right) {
1571
+ const parse = (value) => String(value).split("-", 1)[0].split(".").map((part) => {
1572
+ const parsed = Number.parseInt(part, 10);
1573
+ return Number.isFinite(parsed) ? parsed : 0;
1574
+ });
1575
+ const leftParts = parse(left);
1576
+ const rightParts = parse(right);
1577
+ const length = Math.max(leftParts.length, rightParts.length);
1578
+ for (let index = 0; index < length; index += 1) {
1579
+ const delta = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
1580
+ if (delta !== 0) {
1581
+ return delta < 0 ? -1 : 1;
1582
+ }
1583
+ }
1584
+ return 0;
1585
+ }
1586
+
1587
+ function isRepositoryCliPackageRoot(packageRoot) {
1588
+ const segments = path.resolve(packageRoot).split(path.sep);
1589
+ const suffix = ["packages", "apps", "private-state", "cli"];
1590
+ return suffix.every((segment, index) => segments[segments.length - suffix.length + index] === segment);
1591
+ }
1592
+
1461
1593
  async function handleDoctor({ args }) {
1462
1594
  const report = buildDoctorReport({ probeGpu: args.gpu === true });
1463
1595
  if (isJsonOutputRequested()) {
@@ -2129,8 +2261,8 @@ function applyGuideNextAction(guide) {
2129
2261
  }
2130
2262
  if (guide.selectors.channelName && guide.state.channel?.onchain?.exists && !guide.state.channel?.local?.workspaceExists) {
2131
2263
  setGuideNextAction(guide, {
2132
- command: `recover-workspace --channel-name ${guide.selectors.channelName} --network ${guide.selectors.network}`,
2133
- why: "The channel exists on-chain, but the local channel workspace has not been recovered yet.",
2264
+ command: `recover-workspace --channel-name ${guide.selectors.channelName} --network ${guide.selectors.network} --from-genesis`,
2265
+ why: "The channel exists on-chain, but the local channel workspace has not been recovered yet, so there is no local recovery index to resume from.",
2134
2266
  });
2135
2267
  return;
2136
2268
  }
@@ -2269,21 +2401,24 @@ function redactRpcUrl(rpcUrl) {
2269
2401
 
2270
2402
  async function handleGetMyWalletMeta({ args, provider }) {
2271
2403
  const { wallet, walletMetadata } = loadUnlockedWalletWithMetadata(args);
2272
- const { signer, l2Identity } = restoreWalletParticipant(wallet, provider);
2273
- const context = await loadChannelContext({
2274
- args,
2275
- networkName: walletMetadata.network,
2404
+ const contextResult = await loadPreferredWalletChannelContext({
2405
+ walletContext: wallet,
2276
2406
  provider,
2407
+ progressAction: "get-my-wallet-meta",
2408
+ });
2409
+ const context = contextResult.context;
2410
+ const {
2411
+ signer,
2412
+ l2Identity,
2413
+ registration,
2414
+ expectedStorageKey,
2415
+ matchesWallet,
2416
+ } = await loadWalletChannelRegistrationState({
2277
2417
  walletContext: wallet,
2418
+ context,
2419
+ provider,
2278
2420
  });
2279
2421
 
2280
- const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
2281
- const expectedStorageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
2282
- const matchesWallet = registration.exists
2283
- && ethers.toBigInt(getAddress(registration.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address))
2284
- && ethers.toBigInt(normalizeBytes32Hex(registration.channelTokenVaultKey))
2285
- === ethers.toBigInt(normalizeBytes32Hex(expectedStorageKey));
2286
-
2287
2422
  printJson({
2288
2423
  action: "get-my-wallet-meta",
2289
2424
  wallet: wallet.walletName,
@@ -2301,31 +2436,23 @@ async function handleGetMyWalletMeta({ args, provider }) {
2301
2436
  }
2302
2437
 
2303
2438
  async function loadWalletChannelFundState({ walletContext, provider, progressAction = null }) {
2304
- const { signer, l2Identity } = restoreWalletParticipant(walletContext, provider);
2305
2439
  const contextResult = await loadPreferredWalletChannelContext({
2306
2440
  walletContext,
2307
2441
  provider,
2308
2442
  progressAction,
2309
2443
  });
2310
2444
  const context = contextResult.context;
2311
- const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
2312
- expect(
2313
- registration.exists,
2314
- cliError(
2315
- CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION,
2316
- `No channelTokenVault registration exists for ${signer.address}. Run join-channel first.`,
2317
- ),
2318
- );
2319
- const expectedStorageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
2320
- expect(
2321
- ethers.toBigInt(getAddress(registration.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address)),
2322
- "The local wallet L2 address does not match the registered channel L2 address.",
2323
- );
2324
- expect(
2325
- ethers.toBigInt(normalizeBytes32Hex(registration.channelTokenVaultKey))
2326
- === ethers.toBigInt(normalizeBytes32Hex(expectedStorageKey)),
2327
- "The local wallet L2 storage key does not match the registered channelTokenVault key.",
2328
- );
2445
+ const {
2446
+ signer,
2447
+ l2Identity,
2448
+ registration,
2449
+ expectedStorageKey,
2450
+ } = await loadWalletChannelRegistrationState({
2451
+ walletContext,
2452
+ context,
2453
+ provider,
2454
+ requireRegistration: true,
2455
+ });
2329
2456
 
2330
2457
  const stateManager = await buildStateManager(context.currentSnapshot, context.contractCodes);
2331
2458
  const channelDeposit = await currentStorageBigInt(
@@ -2344,6 +2471,43 @@ async function loadWalletChannelFundState({ walletContext, provider, progressAct
2344
2471
  };
2345
2472
  }
2346
2473
 
2474
+ async function loadWalletChannelRegistrationState({
2475
+ walletContext,
2476
+ context,
2477
+ provider,
2478
+ requireRegistration = false,
2479
+ }) {
2480
+ const { signer, l2Identity } = restoreWalletParticipant(walletContext, provider);
2481
+ const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
2482
+ const expectedStorageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
2483
+ const matchesWallet = registration.exists
2484
+ && ethers.toBigInt(getAddress(registration.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address))
2485
+ && ethers.toBigInt(normalizeBytes32Hex(registration.channelTokenVaultKey))
2486
+ === ethers.toBigInt(normalizeBytes32Hex(expectedStorageKey));
2487
+
2488
+ if (requireRegistration) {
2489
+ expect(
2490
+ registration.exists,
2491
+ cliError(
2492
+ CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION,
2493
+ `No channelTokenVault registration exists for ${signer.address}. Run join-channel first.`,
2494
+ ),
2495
+ );
2496
+ expect(
2497
+ matchesWallet,
2498
+ "The local wallet L2 address or storage key does not match the registered channelTokenVault state.",
2499
+ );
2500
+ }
2501
+
2502
+ return {
2503
+ signer,
2504
+ l2Identity,
2505
+ registration,
2506
+ expectedStorageKey,
2507
+ matchesWallet,
2508
+ };
2509
+ }
2510
+
2347
2511
  async function handleGetMyChannelFund({ args, provider }) {
2348
2512
  const { wallet, walletMetadata } = loadUnlockedWalletWithMetadata(args);
2349
2513
  const {
@@ -2353,7 +2517,11 @@ async function handleGetMyChannelFund({ args, provider }) {
2353
2517
  registration,
2354
2518
  expectedStorageKey,
2355
2519
  channelFund,
2356
- } = await loadWalletChannelFundState({ walletContext: wallet, provider });
2520
+ } = await loadWalletChannelFundState({
2521
+ walletContext: wallet,
2522
+ provider,
2523
+ progressAction: "get-my-channel-fund",
2524
+ });
2357
2525
 
2358
2526
  printJson({
2359
2527
  action: "get-my-channel-fund",
@@ -2376,9 +2544,9 @@ async function handleGetMyChannelFund({ args, provider }) {
2376
2544
  }
2377
2545
 
2378
2546
  async function handleJoinChannel({ args, network, provider, rpcUrl }) {
2379
- const context = await loadChannelContext({
2547
+ const context = await loadJoinChannelContext({
2380
2548
  args,
2381
- networkName: network.name,
2549
+ network,
2382
2550
  provider,
2383
2551
  });
2384
2552
  const signer = requireL1Signer(args, provider);
@@ -3323,11 +3491,12 @@ async function recoverDeliveredNotesFromEventLogs({
3323
3491
  noteReceivePrivateKey,
3324
3492
  progressAction = null,
3325
3493
  }) {
3326
- const scanStartBlock = Math.max(
3327
- Number(walletContext.wallet.noteReceiveLastScannedBlock),
3328
- Number(context.workspace.genesisBlockNumber),
3329
- );
3330
3494
  const latestBlock = await provider.getBlockNumber();
3495
+ const scanStartBlock = requireUsableWalletNoteReceiveRecoveryIndex({
3496
+ walletContext,
3497
+ context,
3498
+ latestBlock,
3499
+ });
3331
3500
  const scanRange = {
3332
3501
  fromBlock: scanStartBlock,
3333
3502
  toBlock: latestBlock,
@@ -3431,6 +3600,23 @@ async function recoverDeliveredNotesFromEventLogs({
3431
3600
  };
3432
3601
  }
3433
3602
 
3603
+ function requireUsableWalletNoteReceiveRecoveryIndex({ walletContext, context, latestBlock }) {
3604
+ const nextBlock = Number(walletContext.wallet.noteReceiveLastScannedBlock);
3605
+ const genesisBlockNumber = Number(context.workspace.genesisBlockNumber);
3606
+ if (
3607
+ !Number.isInteger(nextBlock)
3608
+ || nextBlock < genesisBlockNumber
3609
+ || nextBlock > Number(latestBlock) + 1
3610
+ ) {
3611
+ throw new Error([
3612
+ `Wallet note recovery index is missing or unusable for wallet ${walletContext.walletName}.`,
3613
+ `Expected noteReceiveLastScannedBlock to be an integer between ${genesisBlockNumber} and ${Number(latestBlock) + 1}.`,
3614
+ "Run recover-wallet --from-genesis to rebuild wallet note state from channel genesis.",
3615
+ ].join(" "));
3616
+ }
3617
+ return nextBlock;
3618
+ }
3619
+
3434
3620
  function extractEncryptedNoteValueFromBridgeLog(log) {
3435
3621
  if (!Array.isArray(log?.topics) || log.topics.length !== 1) {
3436
3622
  return null;
@@ -4191,24 +4377,10 @@ async function loadWorkspaceContext(workspaceName, networkName, provider) {
4191
4377
  };
4192
4378
  }
4193
4379
 
4194
- async function loadChannelContext({ args, networkName, provider, walletContext = null }) {
4380
+ async function loadJoinChannelContext({ args, network, provider }) {
4195
4381
  const chainId = Number((await provider.getNetwork()).chainId);
4196
- const resolvedNetworkName = networkName ?? networkNameFromChainId(chainId);
4197
- const channelName = args.channelName ?? walletContext?.wallet.channelName;
4198
- if (args.channelName && walletContext) {
4199
- expect(
4200
- args.channelName === walletContext.wallet.channelName,
4201
- [
4202
- `The provided --channel-name (${args.channelName}) does not match the wallet channel`,
4203
- `(${walletContext.wallet.channelName}).`,
4204
- ].join(" "),
4205
- );
4206
- }
4207
- if (!channelName) {
4208
- throw new Error(
4209
- "Missing channel selector. Provide either --channel-name or --wallet bound to a channel.",
4210
- );
4211
- }
4382
+ const resolvedNetworkName = network?.name ?? networkNameFromChainId(chainId);
4383
+ const channelName = requireArg(args.channelName, "--channel-name");
4212
4384
 
4213
4385
  const bridgeResources = loadBridgeResources({ chainId });
4214
4386
  const initialized = await initializeChannelWorkspace({
@@ -5953,6 +6125,10 @@ function assertUninstallArgs(args) {
5953
6125
  assertAllowedCommandSchema(args, "uninstall");
5954
6126
  }
5955
6127
 
6128
+ function assertUpdateArgs(args) {
6129
+ assertAllowedCommandSchema(args, "update");
6130
+ }
6131
+
5956
6132
  function assertDoctorArgs(args) {
5957
6133
  assertAllowedCommandSchema(args, "doctor");
5958
6134
  if (args.gpu !== undefined && args.gpu !== true) {
@@ -6051,6 +6227,9 @@ function assertExplicitSignerCommandArgs(args, commandName) {
6051
6227
 
6052
6228
  function assertRecoverWalletArgs(args) {
6053
6229
  assertExplicitSignerCommandArgs(args, "recover-wallet");
6230
+ if (args.fromGenesis !== undefined && args.fromGenesis !== true) {
6231
+ throw new Error("recover-wallet option --from-genesis does not accept a value.");
6232
+ }
6054
6233
  }
6055
6234
 
6056
6235
  function assertJoinChannelArgs(args) {
@@ -6577,6 +6756,7 @@ function loadWalletCommandRuntime(args) {
6577
6756
  const HUMAN_RESULT_RENDERERS = Object.freeze({
6578
6757
  guide: printGuideHumanResult,
6579
6758
  "transaction-fees": printTransactionFeesHumanResult,
6759
+ update: printUpdateHumanResult,
6580
6760
  });
6581
6761
 
6582
6762
  function normalizePrivateKey(value) {
@@ -6668,6 +6848,35 @@ function printTransactionFeesHumanResult(report) {
6668
6848
  console.log(lines.join("\n"));
6669
6849
  }
6670
6850
 
6851
+ function printUpdateHumanResult(report) {
6852
+ const lines = [
6853
+ "Private-State CLI Update",
6854
+ `Package: ${formatHumanValue(report.packageName)}`,
6855
+ `Current version: ${formatHumanValue(report.currentVersion)}`,
6856
+ `Latest registry version: ${formatHumanValue(report.latestVersion)}`,
6857
+ ];
6858
+ if (report.registryState === "local-version-ahead-of-registry") {
6859
+ lines.push("Status: local version is newer than the npm registry latest tag.");
6860
+ } else if (!report.updateAvailable) {
6861
+ lines.push("Status: up to date.");
6862
+ } else if (report.updated) {
6863
+ lines.push("Status: updated global npm install.");
6864
+ } else {
6865
+ lines.push(
6866
+ "Status: update available.",
6867
+ `Reason: ${formatHumanValue(report.reason)}`,
6868
+ `Command: ${formatHumanValue(report.command)}`,
6869
+ );
6870
+ }
6871
+ lines.push(
6872
+ `Global install: ${report.globalPackage?.installed ? `yes (${formatHumanValue(report.globalPackage.version)})` : "no"}`,
6873
+ `Repository checkout: ${report.runningFromRepositoryCheckout ? "yes" : "no"}`,
6874
+ "",
6875
+ "Run with --json to inspect the full update report.",
6876
+ );
6877
+ console.log(lines.join("\n"));
6878
+ }
6879
+
6671
6880
  function formatHumanTable(headers, rows) {
6672
6881
  const values = [headers, ...rows].map((row) => row.map((value) => String(value ?? "")));
6673
6882
  const widths = headers.map((_header, columnIndex) =>
@@ -6888,6 +7097,15 @@ function buildRecoveryHints(error, args = {}) {
6888
7097
  hints.push(`private-state-cli guide --network ${networkName} --channel-name ${channelName}`);
6889
7098
  }
6890
7099
 
7100
+ if (message.includes("Workspace recovery index is missing or unusable")) {
7101
+ hints.push(`private-state-cli recover-workspace --channel-name ${channelName} --network ${networkName} --from-genesis`);
7102
+ hints.push(`private-state-cli recover-wallet --channel-name ${channelName} --network ${networkName} --account ${accountName} --from-genesis`);
7103
+ }
7104
+
7105
+ if (message.includes("Wallet note recovery index is missing or unusable")) {
7106
+ hints.push(`private-state-cli recover-wallet --channel-name ${channelName} --network ${networkName} --account ${accountName} --from-genesis`);
7107
+ }
7108
+
6891
7109
  if (message.includes("Missing channel selector")) {
6892
7110
  hints.push(`private-state-cli list-local-wallets --network ${networkName}`);
6893
7111
  hints.push(`private-state-cli guide --network ${networkName} --channel-name <CHANNEL> --wallet <WALLET>`);