@tokamak-private-dapps/private-state-cli 1.0.0 → 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.
@@ -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 {
@@ -120,12 +122,14 @@ import {
120
122
 
121
123
  const require = createRequire(import.meta.url);
122
124
  const defaultCommandCwd = process.cwd();
125
+ const privateStateCliPackageJson = require("./package.json");
123
126
  const privateStateCliPackageRoot = path.dirname(require.resolve("./package.json"));
124
127
  const workspaceRoot = path.resolve(os.homedir(), "tokamak-private-channels", "workspace");
125
128
  const secretRoot = path.resolve(os.homedir(), "tokamak-private-channels", "secrets");
126
129
  const flatDeploymentArtifactPathsByChainId = new Map();
127
130
  const PRIVATE_STATE_UNINSTALL_CONFIRMATION =
128
131
  "I understand that the wallet secrets deleted due to this decision cannot be recovered";
132
+ const PRIVATE_STATE_CLI_PACKAGE_NAME = privateStateCliPackageJson.name;
129
133
  const GROTH16_PACKAGE_NAME = "@tokamak-private-dapps/groth16";
130
134
  const TOKAMAK_ZKEVM_CLI_PACKAGE_NAME = "@tokamak-zk-evm/cli";
131
135
  let jsonOutputRequested = false;
@@ -314,6 +318,12 @@ async function main() {
314
318
  activeCliArgs = args;
315
319
  configureOutput(args);
316
320
 
321
+ if (args.version !== undefined) {
322
+ assertVersionArgs(args);
323
+ printVersion();
324
+ return;
325
+ }
326
+
317
327
  if (args.help || !args.command) {
318
328
  printHelp();
319
329
  return;
@@ -331,6 +341,12 @@ async function main() {
331
341
  return;
332
342
  }
333
343
 
344
+ if (args.command === "update") {
345
+ assertUpdateArgs(args);
346
+ await handleUpdate();
347
+ return;
348
+ }
349
+
334
350
  if (args.command === "doctor") {
335
351
  assertDoctorArgs(args);
336
352
  await handleDoctor({ args });
@@ -343,6 +359,13 @@ async function main() {
343
359
  return;
344
360
  }
345
361
 
362
+ if (args.command === "transaction-fees") {
363
+ assertTransactionFeesArgs(args);
364
+ const { network, provider, rpcUrl } = loadExplicitCommandRuntime(args);
365
+ await handleTransactionFees({ network, provider, rpcUrl });
366
+ return;
367
+ }
368
+
346
369
  if (args.command === "get-my-l1-address") {
347
370
  assertGetMyL1AddressArgs(args);
348
371
  handleGetMyL1Address({ args });
@@ -511,6 +534,8 @@ async function handleChannelCreate({ args, network, provider }) {
511
534
  provider,
512
535
  bridgeResources,
513
536
  persist: true,
537
+ fromGenesis: true,
538
+ progressAction: "create-channel",
514
539
  });
515
540
 
516
541
  printJson({
@@ -622,6 +647,7 @@ async function handleWorkspaceInit({ args, network, provider }) {
622
647
  allowExistingWorkspaceSync: true,
623
648
  useWorkspaceRecoveryIndex: true,
624
649
  fromGenesis: args.fromGenesis === true,
650
+ progressAction: "recover-workspace",
625
651
  });
626
652
 
627
653
  printJson({
@@ -729,6 +755,7 @@ async function initializeChannelWorkspace({
729
755
  allowExistingWorkspaceSync = false,
730
756
  useWorkspaceRecoveryIndex = false,
731
757
  fromGenesis = false,
758
+ progressAction = null,
732
759
  }) {
733
760
  const workspaceDir = channelWorkspacePath(networkNameFromChainId(network.chainId), workspaceName);
734
761
  const channelDir = channelDataPath(workspaceDir);
@@ -806,6 +833,13 @@ async function initializeChannelWorkspace({
806
833
  managedStorageAddresses,
807
834
  })
808
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
+ }
809
843
  const reconstruction = localSnapshotReusable
810
844
  ? {
811
845
  currentSnapshot: existingArtifacts.stateSnapshot,
@@ -831,6 +865,7 @@ async function initializeChannelWorkspace({
831
865
  baseSnapshot: recoveryIndex?.stateSnapshot ?? null,
832
866
  fromBlock: recoveryIndex?.nextBlock ?? genesisBlockNumber,
833
867
  toBlock: latestBlock,
868
+ progressAction,
834
869
  });
835
870
  const currentSnapshot = reconstruction.currentSnapshot;
836
871
  const recoveryRootVectorHash = normalizeBytes32Hex(hashRootVector(currentSnapshot.stateRoots));
@@ -975,6 +1010,9 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
975
1010
  bridgeResources,
976
1011
  persist: true,
977
1012
  allowExistingWorkspaceSync: true,
1013
+ useWorkspaceRecoveryIndex: true,
1014
+ fromGenesis: args.fromGenesis === true,
1015
+ progressAction: "recover-wallet",
978
1016
  });
979
1017
  const context = {
980
1018
  workspaceName: channelName,
@@ -1056,6 +1094,14 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
1056
1094
  });
1057
1095
 
1058
1096
  if (existingWallet) {
1097
+ const { recoveredDeliveryState } = await recoverWalletReceivedNotes({
1098
+ walletContext: existingWallet,
1099
+ context,
1100
+ provider,
1101
+ signer,
1102
+ noteReceiveKeyMaterial,
1103
+ progressAction: "recover-wallet",
1104
+ });
1059
1105
  printJson({
1060
1106
  action: "recover-wallet",
1061
1107
  status: "already-recovered",
@@ -1071,6 +1117,10 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
1071
1117
  l2StorageKey: storageKey,
1072
1118
  leafIndex: registration.leafIndex.toString(),
1073
1119
  noteReceivePubKey: noteReceiveKeyMaterial.noteReceivePubKey,
1120
+ l2Nonce: existingWallet.wallet.l2Nonce,
1121
+ recoveredFromLogs: recoveredDeliveryState.importedNotes,
1122
+ scannedDeliveryLogs: recoveredDeliveryState.scannedLogs,
1123
+ noteReceiveScanRange: recoveredDeliveryState.scanRange,
1074
1124
  });
1075
1125
  return;
1076
1126
  }
@@ -1091,11 +1141,13 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
1091
1141
  walletContext.wallet.l2Nonce = 0;
1092
1142
  persistWallet(walletContext);
1093
1143
 
1094
- const recoveredDeliveryState = await recoverDeliveredNotesFromEventLogs({
1144
+ const { recoveredDeliveryState } = await recoverWalletReceivedNotes({
1095
1145
  walletContext,
1096
1146
  context,
1097
1147
  provider,
1098
- noteReceivePrivateKey: noteReceiveKeyMaterial.privateKey,
1148
+ signer,
1149
+ noteReceiveKeyMaterial,
1150
+ progressAction: "recover-wallet",
1099
1151
  });
1100
1152
 
1101
1153
  printJson({
@@ -1390,31 +1442,44 @@ function assertSafeManagedRoot(rootPath, label) {
1390
1442
  }
1391
1443
  }
1392
1444
 
1393
- function uninstallGlobalPrivateStateCliPackage() {
1394
- const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
1395
- const list = runCaptured(npmCommand, ["ls", "-g", "@tokamak-private-dapps/private-state-cli", "--depth=0", "--json"]);
1396
- if (list.status !== 0) {
1397
- 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) {
1398
1455
  const missing = /empty|missing|not found|not installed/iu.test(`${list.stdout}\n${list.stderr}`);
1399
1456
  return {
1400
- attempted: false,
1401
1457
  installed: false,
1402
- reason: missing || listReport ? "global package is not installed" : "unable to inspect global npm package",
1458
+ version: null,
1403
1459
  status: list.status,
1460
+ reason: missing || report ? "global package is not installed" : "unable to inspect global npm package",
1404
1461
  stderr: stripAnsi(list.stderr).trim(),
1405
1462
  };
1406
1463
  }
1407
- const report = parseJsonReport(list.stdout);
1408
- const installed = Boolean(report?.dependencies?.["@tokamak-private-dapps/private-state-cli"]);
1409
- 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) {
1410
1474
  return {
1411
1475
  attempted: false,
1412
1476
  installed: false,
1413
- reason: "global package is not installed",
1414
- status: list.status,
1477
+ reason: inspection.reason,
1478
+ status: inspection.status,
1479
+ stderr: inspection.stderr,
1415
1480
  };
1416
1481
  }
1417
- const uninstall = runCaptured(npmCommand, ["uninstall", "-g", "@tokamak-private-dapps/private-state-cli"]);
1482
+ const uninstall = runCaptured(npmCommandName(), ["uninstall", "-g", PRIVATE_STATE_CLI_PACKAGE_NAME]);
1418
1483
  return {
1419
1484
  attempted: true,
1420
1485
  installed: true,
@@ -1425,6 +1490,106 @@ function uninstallGlobalPrivateStateCliPackage() {
1425
1490
  };
1426
1491
  }
1427
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
+
1428
1593
  async function handleDoctor({ args }) {
1429
1594
  const report = buildDoctorReport({ probeGpu: args.gpu === true });
1430
1595
  if (isJsonOutputRequested()) {
@@ -1437,6 +1602,144 @@ async function handleDoctor({ args }) {
1437
1602
  }
1438
1603
  }
1439
1604
 
1605
+ async function handleTransactionFees({ network, provider, rpcUrl }) {
1606
+ const feeAsset = loadTransactionFeeAsset();
1607
+ const feeData = await provider.getFeeData();
1608
+ const gasPrices = requireTransactionFeeGasPrices(feeData);
1609
+ const ethUsd = await fetchEthUsdPrice();
1610
+ const rows = buildTransactionFeeRows({
1611
+ commands: feeAsset.commands,
1612
+ typicalGasPriceWei: gasPrices.typical,
1613
+ worstCaseGasPriceWei: gasPrices.worstCase,
1614
+ ethUsd,
1615
+ });
1616
+
1617
+ printJson({
1618
+ action: "transaction-fees",
1619
+ generatedAt: new Date().toISOString(),
1620
+ network: network.name,
1621
+ chainId: Number(network.chainId),
1622
+ rpcUrl,
1623
+ asset: {
1624
+ schema: feeAsset.schema,
1625
+ measuredAt: feeAsset.measuredAt,
1626
+ measurementBasis: feeAsset.measurementBasis,
1627
+ notes: feeAsset.notes,
1628
+ },
1629
+ livePricing: {
1630
+ typicalGasPriceWei: gasPrices.typical.toString(),
1631
+ typicalGasPriceGwei: formatGwei(gasPrices.typical),
1632
+ typicalGasPriceSource: gasPrices.typicalSource,
1633
+ worstCaseGasPriceWei: gasPrices.worstCase.toString(),
1634
+ worstCaseGasPriceGwei: formatGwei(gasPrices.worstCase),
1635
+ worstCaseGasPriceSource: gasPrices.worstCaseSource,
1636
+ maxFeePerGasWei: feeData.maxFeePerGas?.toString() ?? null,
1637
+ maxPriorityFeePerGasWei: feeData.maxPriorityFeePerGas?.toString() ?? null,
1638
+ gasPriceWei: feeData.gasPrice?.toString() ?? null,
1639
+ ethUsd,
1640
+ ethUsdSource: "CoinGecko simple price API",
1641
+ },
1642
+ rows,
1643
+ });
1644
+ }
1645
+
1646
+ function loadTransactionFeeAsset() {
1647
+ const assetPath = path.join(privateStateCliPackageRoot, "assets", "tx-fees.json");
1648
+ const asset = readJson(assetPath);
1649
+ expect(asset.schema === "tokamak-private-state-cli-tx-fees.v1", `Unsupported transaction fee asset schema: ${assetPath}`);
1650
+ expect(Array.isArray(asset.commands), `Transaction fee asset is missing commands: ${assetPath}`);
1651
+ return asset;
1652
+ }
1653
+
1654
+ function requireTransactionFeeGasPrices(feeData) {
1655
+ const typical = feeData.gasPrice ?? feeData.maxFeePerGas;
1656
+ const worstCase = feeData.maxFeePerGas ?? feeData.gasPrice;
1657
+ if (typical === null || typical === undefined || worstCase === null || worstCase === undefined) {
1658
+ throw new Error("RPC provider did not return gasPrice or maxFeePerGas.");
1659
+ }
1660
+ return {
1661
+ typical: ethers.toBigInt(typical),
1662
+ typicalSource: feeData.gasPrice ? "gasPrice" : "maxFeePerGas",
1663
+ worstCase: ethers.toBigInt(worstCase),
1664
+ worstCaseSource: feeData.maxFeePerGas ? "maxFeePerGas" : "gasPrice",
1665
+ };
1666
+ }
1667
+
1668
+ async function fetchEthUsdPrice() {
1669
+ const url = "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd";
1670
+ const response = await fetch(url, {
1671
+ headers: {
1672
+ accept: "application/json",
1673
+ "user-agent": `${privateStateCliPackageJson.name}/${privateStateCliPackageJson.version}`,
1674
+ },
1675
+ });
1676
+ if (!response.ok) {
1677
+ throw new Error(`Unable to fetch live ETH/USD price from CoinGecko: HTTP ${response.status}.`);
1678
+ }
1679
+ const payload = await response.json();
1680
+ const value = Number(payload?.ethereum?.usd);
1681
+ if (!Number.isFinite(value) || value <= 0) {
1682
+ throw new Error("CoinGecko response did not include a valid ethereum.usd price.");
1683
+ }
1684
+ return value;
1685
+ }
1686
+
1687
+ function buildTransactionFeeRows({ commands, typicalGasPriceWei, worstCaseGasPriceWei, ethUsd }) {
1688
+ return commands.map((entry) => {
1689
+ const transactions = expectTransactionFeeTransactions(entry);
1690
+ const gasUsed = transactions.reduce((sum, transaction) => sum + Number(transaction.gasUsed), 0);
1691
+ const typicalEth = ethers.formatEther(BigInt(gasUsed) * typicalGasPriceWei);
1692
+ const worstCaseEth = ethers.formatEther(BigInt(gasUsed) * worstCaseGasPriceWei);
1693
+ return {
1694
+ command: entry.command,
1695
+ description: entry.description,
1696
+ transactions: transactions.map((transaction) => transaction.label).join(" + "),
1697
+ gasUsed,
1698
+ typicalGasPriceGwei: formatGwei(typicalGasPriceWei),
1699
+ typicalEth: formatEthForDisplay(typicalEth),
1700
+ typicalUsd: formatUsdForDisplay(Number(typicalEth) * ethUsd),
1701
+ worstCaseGasPriceGwei: formatGwei(worstCaseGasPriceWei),
1702
+ worstCaseEth: formatEthForDisplay(worstCaseEth),
1703
+ worstCaseUsd: formatUsdForDisplay(Number(worstCaseEth) * ethUsd),
1704
+ sources: [...new Set(transactions.map((transaction) => transaction.source))].join(", "),
1705
+ sourceDetails: transactions.map((transaction) => transaction.sourceDetail),
1706
+ };
1707
+ });
1708
+ }
1709
+
1710
+ function expectTransactionFeeTransactions(entry) {
1711
+ expect(typeof entry?.command === "string" && entry.command.length > 0, "Transaction fee asset contains a command without command name.");
1712
+ expect(Array.isArray(entry.transactions) && entry.transactions.length > 0, `Transaction fee asset command ${entry.command} has no transactions.`);
1713
+ for (const transaction of entry.transactions) {
1714
+ expect(Number.isInteger(transaction.gasUsed) && transaction.gasUsed > 0, `Transaction fee asset command ${entry.command} has invalid gasUsed.`);
1715
+ }
1716
+ return entry.transactions;
1717
+ }
1718
+
1719
+ function formatGwei(wei) {
1720
+ return trimFixedNumber(ethers.formatUnits(wei, "gwei"), 6);
1721
+ }
1722
+
1723
+ function formatEthForDisplay(value) {
1724
+ return trimFixedNumber(value, 8);
1725
+ }
1726
+
1727
+ function formatUsdForDisplay(value) {
1728
+ if (value > 0 && value < 0.01) {
1729
+ return "<0.01";
1730
+ }
1731
+ return value.toFixed(2);
1732
+ }
1733
+
1734
+ function trimFixedNumber(value, maxDecimals) {
1735
+ const [integer, decimals = ""] = String(value).split(".");
1736
+ if (!decimals || maxDecimals <= 0) {
1737
+ return integer;
1738
+ }
1739
+ const trimmed = decimals.slice(0, maxDecimals).replace(/0+$/u, "");
1740
+ return trimmed ? `${integer}.${trimmed}` : integer;
1741
+ }
1742
+
1440
1743
  function handleGetMyL1Address({ args }) {
1441
1744
  const signer = requireL1Signer(args);
1442
1745
  printJson({
@@ -1513,6 +1816,7 @@ async function handleGuide({ args }) {
1513
1816
  nextSafeAction: null,
1514
1817
  why: null,
1515
1818
  candidateCommands: [],
1819
+ privacyTip: "For mint-notes, transfer-notes, and redeem-notes, add --tx-submitter <ACCOUNT> to let a separate local L1 account submit executeChannelTransaction and pay gas.",
1516
1820
  };
1517
1821
 
1518
1822
  guide.state.local = inspectGuideLocalState(args);
@@ -1957,8 +2261,8 @@ function applyGuideNextAction(guide) {
1957
2261
  }
1958
2262
  if (guide.selectors.channelName && guide.state.channel?.onchain?.exists && !guide.state.channel?.local?.workspaceExists) {
1959
2263
  setGuideNextAction(guide, {
1960
- command: `recover-workspace --channel-name ${guide.selectors.channelName} --network ${guide.selectors.network}`,
1961
- 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.",
1962
2266
  });
1963
2267
  return;
1964
2268
  }
@@ -2006,18 +2310,18 @@ function applyGuideNextAction(guide) {
2006
2310
  }
2007
2311
  if (guide.state.wallet?.exists && channelBalance !== null && channelBalance > 0n && unusedNotes === 0) {
2008
2312
  setGuideNextAction(guide, {
2009
- command: `mint-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amounts <A,B>`,
2010
- why: "The wallet has channel L2 balance and no unused private notes yet.",
2313
+ command: `mint-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amounts <A,B> [--tx-submitter <ACCOUNT>]`,
2314
+ why: "The wallet has channel L2 balance and no unused private notes yet. Use --tx-submitter for stronger transaction-submission privacy.",
2011
2315
  });
2012
2316
  return;
2013
2317
  }
2014
2318
  if (guide.state.wallet?.exists && unusedNotes !== null && unusedNotes > 0) {
2015
2319
  setGuideNextAction(guide, {
2016
- command: `transfer-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID,ID> --recipients <ADDR,ADDR> --amounts <A,B>`,
2017
- why: "The wallet has unused private notes. It can transfer or redeem those notes.",
2320
+ command: `transfer-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID,ID> --recipients <ADDR,ADDR> --amounts <A,B> [--tx-submitter <ACCOUNT>]`,
2321
+ why: "The wallet has unused private notes. It can transfer or redeem those notes. Use --tx-submitter for stronger transaction-submission privacy.",
2018
2322
  candidates: [
2019
2323
  `get-my-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network}`,
2020
- `redeem-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID>`,
2324
+ `redeem-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID> [--tx-submitter <ACCOUNT>]`,
2021
2325
  ],
2022
2326
  });
2023
2327
  return;
@@ -2097,21 +2401,24 @@ function redactRpcUrl(rpcUrl) {
2097
2401
 
2098
2402
  async function handleGetMyWalletMeta({ args, provider }) {
2099
2403
  const { wallet, walletMetadata } = loadUnlockedWalletWithMetadata(args);
2100
- const { signer, l2Identity } = restoreWalletParticipant(wallet, provider);
2101
- const context = await loadChannelContext({
2102
- args,
2103
- networkName: walletMetadata.network,
2404
+ const contextResult = await loadPreferredWalletChannelContext({
2405
+ walletContext: wallet,
2104
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({
2105
2417
  walletContext: wallet,
2418
+ context,
2419
+ provider,
2106
2420
  });
2107
2421
 
2108
- const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
2109
- const expectedStorageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
2110
- const matchesWallet = registration.exists
2111
- && ethers.toBigInt(getAddress(registration.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address))
2112
- && ethers.toBigInt(normalizeBytes32Hex(registration.channelTokenVaultKey))
2113
- === ethers.toBigInt(normalizeBytes32Hex(expectedStorageKey));
2114
-
2115
2422
  printJson({
2116
2423
  action: "get-my-wallet-meta",
2117
2424
  wallet: wallet.walletName,
@@ -2128,28 +2435,24 @@ async function handleGetMyWalletMeta({ args, provider }) {
2128
2435
  });
2129
2436
  }
2130
2437
 
2131
- async function loadWalletChannelFundState({ walletContext, provider }) {
2132
- const { signer, l2Identity } = restoreWalletParticipant(walletContext, provider);
2133
- const contextResult = await loadPreferredWalletChannelContext({ walletContext, provider });
2438
+ async function loadWalletChannelFundState({ walletContext, provider, progressAction = null }) {
2439
+ const contextResult = await loadPreferredWalletChannelContext({
2440
+ walletContext,
2441
+ provider,
2442
+ progressAction,
2443
+ });
2134
2444
  const context = contextResult.context;
2135
- const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
2136
- expect(
2137
- registration.exists,
2138
- cliError(
2139
- CLI_ERROR_CODES.MISSING_CHANNEL_REGISTRATION,
2140
- `No channelTokenVault registration exists for ${signer.address}. Run join-channel first.`,
2141
- ),
2142
- );
2143
- const expectedStorageKey = deriveLiquidBalanceStorageKey(l2Identity.l2Address, context.workspace.liquidBalancesSlot);
2144
- expect(
2145
- ethers.toBigInt(getAddress(registration.l2Address)) === ethers.toBigInt(getAddress(l2Identity.l2Address)),
2146
- "The local wallet L2 address does not match the registered channel L2 address.",
2147
- );
2148
- expect(
2149
- ethers.toBigInt(normalizeBytes32Hex(registration.channelTokenVaultKey))
2150
- === ethers.toBigInt(normalizeBytes32Hex(expectedStorageKey)),
2151
- "The local wallet L2 storage key does not match the registered channelTokenVault key.",
2152
- );
2445
+ const {
2446
+ signer,
2447
+ l2Identity,
2448
+ registration,
2449
+ expectedStorageKey,
2450
+ } = await loadWalletChannelRegistrationState({
2451
+ walletContext,
2452
+ context,
2453
+ provider,
2454
+ requireRegistration: true,
2455
+ });
2153
2456
 
2154
2457
  const stateManager = await buildStateManager(context.currentSnapshot, context.contractCodes);
2155
2458
  const channelDeposit = await currentStorageBigInt(
@@ -2168,6 +2471,43 @@ async function loadWalletChannelFundState({ walletContext, provider }) {
2168
2471
  };
2169
2472
  }
2170
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
+
2171
2511
  async function handleGetMyChannelFund({ args, provider }) {
2172
2512
  const { wallet, walletMetadata } = loadUnlockedWalletWithMetadata(args);
2173
2513
  const {
@@ -2177,7 +2517,11 @@ async function handleGetMyChannelFund({ args, provider }) {
2177
2517
  registration,
2178
2518
  expectedStorageKey,
2179
2519
  channelFund,
2180
- } = await loadWalletChannelFundState({ walletContext: wallet, provider });
2520
+ } = await loadWalletChannelFundState({
2521
+ walletContext: wallet,
2522
+ provider,
2523
+ progressAction: "get-my-channel-fund",
2524
+ });
2181
2525
 
2182
2526
  printJson({
2183
2527
  action: "get-my-channel-fund",
@@ -2200,9 +2544,9 @@ async function handleGetMyChannelFund({ args, provider }) {
2200
2544
  }
2201
2545
 
2202
2546
  async function handleJoinChannel({ args, network, provider, rpcUrl }) {
2203
- const context = await loadChannelContext({
2547
+ const context = await loadJoinChannelContext({
2204
2548
  args,
2205
- networkName: network.name,
2549
+ network,
2206
2550
  provider,
2207
2551
  });
2208
2552
  const signer = requireL1Signer(args, provider);
@@ -2357,7 +2701,11 @@ async function handleGrothVaultMove({ args, provider, direction }) {
2357
2701
  : "withdraw";
2358
2702
  emitProgress(operationName, "loading");
2359
2703
  const { wallet: walletContext } = loadUnlockedWalletWithMetadata(args);
2360
- const contextResult = await loadPreferredWalletChannelContext({ walletContext, provider });
2704
+ const contextResult = await loadPreferredWalletChannelContext({
2705
+ walletContext,
2706
+ provider,
2707
+ progressAction: operationName,
2708
+ });
2361
2709
  const context = contextResult.context;
2362
2710
  const network = contextResult.network;
2363
2711
  expect(
@@ -2403,6 +2751,7 @@ async function handleGrothVaultMove({ args, provider, direction }) {
2403
2751
  "The derived L2 address does not match the registered channel L2 address.",
2404
2752
  );
2405
2753
 
2754
+ await assertWorkspaceAlignedWithChain(context);
2406
2755
  const stateManager = await buildStateManager(context.currentSnapshot, context.contractCodes);
2407
2756
  const keyHex = storageKey;
2408
2757
  const currentValue = await currentStorageBigInt(stateManager, context.workspace.l2AccountingVault, keyHex);
@@ -2578,6 +2927,7 @@ async function handleMintNotes({ args, provider }) {
2578
2927
  const { channelFund } = await loadWalletChannelFundState({
2579
2928
  walletContext: wallet,
2580
2929
  provider,
2930
+ progressAction: "mint-notes",
2581
2931
  });
2582
2932
  expect(
2583
2933
  totalMintAmount <= channelFund,
@@ -2591,6 +2941,7 @@ async function handleMintNotes({ args, provider }) {
2591
2941
  baseUnitAmounts: baseUnitAmounts.map(({ amountBaseUnits }) => amountBaseUnits),
2592
2942
  });
2593
2943
  const { execution, contextResult, recoveredWorkspace } = await executeWalletDirectTemplateCommand({
2944
+ args,
2594
2945
  wallet,
2595
2946
  provider,
2596
2947
  operationName: "mint-notes",
@@ -2602,7 +2953,10 @@ async function handleMintNotes({ args, provider }) {
2602
2953
  wallet: wallet.walletName,
2603
2954
  workspace: execution.context.workspaceName,
2604
2955
  operationDir: execution.operationDir,
2605
- l1Submitter: execution.signer.address,
2956
+ l1Submitter: execution.txSubmitter.address,
2957
+ l1WalletOwner: execution.signer.address,
2958
+ txSubmitterSource: execution.txSubmitterSource,
2959
+ txSubmitterAccount: execution.txSubmitterAccount,
2606
2960
  l2Address: execution.l2Identity.l2Address,
2607
2961
  underlyingMethod: templatePayload.method,
2608
2962
  nonce: execution.nonce,
@@ -2631,6 +2985,7 @@ async function handleRedeemNotes({ args, provider }) {
2631
2985
  inputNotes,
2632
2986
  });
2633
2987
  const { execution, contextResult, recoveredWorkspace } = await executeWalletDirectTemplateCommand({
2988
+ args,
2634
2989
  wallet,
2635
2990
  provider,
2636
2991
  operationName: "redeem-notes",
@@ -2642,7 +2997,10 @@ async function handleRedeemNotes({ args, provider }) {
2642
2997
  wallet: wallet.walletName,
2643
2998
  workspace: execution.context.workspaceName,
2644
2999
  operationDir: execution.operationDir,
2645
- l1Submitter: execution.signer.address,
3000
+ l1Submitter: execution.txSubmitter.address,
3001
+ l1WalletOwner: execution.signer.address,
3002
+ txSubmitterSource: execution.txSubmitterSource,
3003
+ txSubmitterAccount: execution.txSubmitterAccount,
2646
3004
  l2Address: execution.l2Identity.l2Address,
2647
3005
  receiver: wallet.wallet.l2Address,
2648
3006
  underlyingMethod: templatePayload.method,
@@ -2669,20 +3027,18 @@ async function handleGetMyNotes({ args, provider }) {
2669
3027
  `Wallet ${wallet.walletName} is missing the stored controller address.`,
2670
3028
  );
2671
3029
  const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
2672
- const { context } = await loadPreferredWalletChannelContext({ walletContext: wallet, provider });
2673
- const signer = restoreWalletSigner(wallet, provider);
2674
- const noteReceiveKeyMaterial = await deriveNoteReceiveKeyMaterial({
2675
- signer,
2676
- chainId: context.workspace.chainId,
2677
- channelId: context.workspace.channelId,
2678
- channelName: context.workspace.channelName,
2679
- account: signer.address,
3030
+ const { context } = await loadPreferredWalletChannelContext({
3031
+ walletContext: wallet,
3032
+ provider,
3033
+ progressAction: "get-my-notes",
2680
3034
  });
2681
- const recoveredDeliveryState = await recoverDeliveredNotesFromEventLogs({
3035
+ const signer = restoreWalletSigner(wallet, provider);
3036
+ const { recoveredDeliveryState } = await recoverWalletReceivedNotes({
2682
3037
  walletContext: wallet,
2683
3038
  context,
2684
3039
  provider,
2685
- noteReceivePrivateKey: noteReceiveKeyMaterial.privateKey,
3040
+ signer,
3041
+ progressAction: "get-my-notes",
2686
3042
  });
2687
3043
 
2688
3044
  const unusedTrackedNotes = wallet.wallet.notes.unusedOrder
@@ -2728,7 +3084,11 @@ async function handleGetMyNotes({ args, provider }) {
2728
3084
  async function handleTransferNotes({ args, provider }) {
2729
3085
  const { wallet } = loadUnlockedWalletWithMetadata(args);
2730
3086
  const { signer } = restoreWalletParticipant(wallet, provider);
2731
- const preparedContextResult = await loadPreferredWalletChannelContext({ walletContext: wallet, provider });
3087
+ const preparedContextResult = await loadPreferredWalletChannelContext({
3088
+ walletContext: wallet,
3089
+ provider,
3090
+ progressAction: "transfer-notes",
3091
+ });
2732
3092
  const context = preparedContextResult.context;
2733
3093
  const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
2734
3094
  const noteIds = parseNoteIdVector(requireArg(args.noteIds, "--note-ids"));
@@ -2760,6 +3120,7 @@ async function handleTransferNotes({ args, provider }) {
2760
3120
  outputAmounts,
2761
3121
  });
2762
3122
  const { execution, contextResult, recoveredWorkspace } = await executeWalletDirectTemplateCommand({
3123
+ args,
2763
3124
  wallet,
2764
3125
  provider,
2765
3126
  operationName: "transfer-notes",
@@ -2777,7 +3138,10 @@ async function handleTransferNotes({ args, provider }) {
2777
3138
  wallet: wallet.walletName,
2778
3139
  workspace: execution.context.workspaceName,
2779
3140
  operationDir: execution.operationDir,
2780
- l1Submitter: execution.signer.address,
3141
+ l1Submitter: execution.txSubmitter.address,
3142
+ l1WalletOwner: execution.signer.address,
3143
+ txSubmitterSource: execution.txSubmitterSource,
3144
+ txSubmitterAccount: execution.txSubmitterAccount,
2781
3145
  l2Address: execution.l2Identity.l2Address,
2782
3146
  underlyingMethod: templatePayload.method,
2783
3147
  nonce: execution.nonce,
@@ -3092,17 +3456,47 @@ function buildLifecycleTrackedOutputs({
3092
3456
  }));
3093
3457
  }
3094
3458
 
3459
+ async function recoverWalletReceivedNotes({
3460
+ walletContext,
3461
+ context,
3462
+ provider,
3463
+ signer,
3464
+ noteReceiveKeyMaterial = null,
3465
+ progressAction = null,
3466
+ }) {
3467
+ const resolvedNoteReceiveKeyMaterial = noteReceiveKeyMaterial ?? await deriveNoteReceiveKeyMaterial({
3468
+ signer,
3469
+ chainId: context.workspace.chainId,
3470
+ channelId: context.workspace.channelId,
3471
+ channelName: context.workspace.channelName,
3472
+ account: signer.address,
3473
+ });
3474
+ const recoveredDeliveryState = await recoverDeliveredNotesFromEventLogs({
3475
+ walletContext,
3476
+ context,
3477
+ provider,
3478
+ noteReceivePrivateKey: resolvedNoteReceiveKeyMaterial.privateKey,
3479
+ progressAction,
3480
+ });
3481
+ return {
3482
+ noteReceiveKeyMaterial: resolvedNoteReceiveKeyMaterial,
3483
+ recoveredDeliveryState,
3484
+ };
3485
+ }
3486
+
3095
3487
  async function recoverDeliveredNotesFromEventLogs({
3096
3488
  walletContext,
3097
3489
  context,
3098
3490
  provider,
3099
3491
  noteReceivePrivateKey,
3492
+ progressAction = null,
3100
3493
  }) {
3101
- const scanStartBlock = Math.max(
3102
- Number(walletContext.wallet.noteReceiveLastScannedBlock),
3103
- Number(context.workspace.genesisBlockNumber),
3104
- );
3105
3494
  const latestBlock = await provider.getBlockNumber();
3495
+ const scanStartBlock = requireUsableWalletNoteReceiveRecoveryIndex({
3496
+ walletContext,
3497
+ context,
3498
+ latestBlock,
3499
+ });
3106
3500
  const scanRange = {
3107
3501
  fromBlock: scanStartBlock,
3108
3502
  toBlock: latestBlock,
@@ -3128,6 +3522,9 @@ async function recoverDeliveredNotesFromEventLogs({
3128
3522
  topics: [NOTE_VALUE_ENCRYPTED_TOPIC],
3129
3523
  fromBlock: scanStartBlock,
3130
3524
  toBlock: latestBlock,
3525
+ onProgress: progressAction
3526
+ ? createRpcLogScanProgress({ action: progressAction, label: "note-delivery events" })
3527
+ : null,
3131
3528
  });
3132
3529
 
3133
3530
  const importedCandidates = [];
@@ -3203,6 +3600,23 @@ async function recoverDeliveredNotesFromEventLogs({
3203
3600
  };
3204
3601
  }
3205
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
+
3206
3620
  function extractEncryptedNoteValueFromBridgeLog(log) {
3207
3621
  if (!Array.isArray(log?.topics) || log.topics.length !== 1) {
3208
3622
  return null;
@@ -3666,10 +4080,15 @@ function walletChannelWorkspaceIsReady(walletContext) {
3666
4080
  && fs.existsSync(path.join(channelWorkspaceCurrentPath(workspaceDir), "contract_codes.json"));
3667
4081
  }
3668
4082
 
3669
- async function loadPreferredWalletChannelContext({ walletContext, provider, forceRecover = false }) {
4083
+ async function loadPreferredWalletChannelContext({
4084
+ walletContext,
4085
+ provider,
4086
+ forceRecover = false,
4087
+ progressAction = null,
4088
+ }) {
3670
4089
  let recoveredWorkspace = false;
3671
4090
  if (forceRecover || !walletChannelWorkspaceIsReady(walletContext)) {
3672
- await recoverWalletChannelWorkspace({ walletContext, provider });
4091
+ await recoverWalletChannelWorkspace({ walletContext, provider, progressAction });
3673
4092
  recoveredWorkspace = true;
3674
4093
  }
3675
4094
  let context = await loadWorkspaceContext(walletContext.wallet.channelName, walletContext.wallet.network, provider);
@@ -3679,7 +4098,7 @@ async function loadPreferredWalletChannelContext({ walletContext, provider, forc
3679
4098
  if (!isRecoverableWalletWorkspaceFailure(error)) {
3680
4099
  throw error;
3681
4100
  }
3682
- await recoverWalletChannelWorkspace({ walletContext, provider });
4101
+ await recoverWalletChannelWorkspace({ walletContext, provider, progressAction });
3683
4102
  recoveredWorkspace = true;
3684
4103
  context = await loadWorkspaceContext(walletContext.wallet.channelName, walletContext.wallet.network, provider);
3685
4104
  await assertWorkspaceAlignedWithChain(context);
@@ -3692,7 +4111,7 @@ async function loadPreferredWalletChannelContext({ walletContext, provider, forc
3692
4111
  };
3693
4112
  }
3694
4113
 
3695
- async function recoverWalletChannelWorkspace({ walletContext, provider }) {
4114
+ async function recoverWalletChannelWorkspace({ walletContext, provider, progressAction = null }) {
3696
4115
  const networkName = walletContext.wallet.network ?? networkNameFromChainId(Number(walletContext.wallet.chainId));
3697
4116
  const network = resolveCliNetwork(networkName);
3698
4117
  const bridgeResources = loadBridgeResources({ chainId: network.chainId });
@@ -3704,6 +4123,8 @@ async function recoverWalletChannelWorkspace({ walletContext, provider }) {
3704
4123
  bridgeResources,
3705
4124
  persist: true,
3706
4125
  allowExistingWorkspaceSync: true,
4126
+ useWorkspaceRecoveryIndex: true,
4127
+ progressAction,
3707
4128
  });
3708
4129
  }
3709
4130
 
@@ -3725,6 +4146,7 @@ function assertWalletMatchesChannelContext(walletContext, l2Identity, context) {
3725
4146
  }
3726
4147
 
3727
4148
  async function executeWalletDirectTemplateCommand({
4149
+ args,
3728
4150
  wallet,
3729
4151
  provider,
3730
4152
  operationName,
@@ -3732,13 +4154,29 @@ async function executeWalletDirectTemplateCommand({
3732
4154
  }) {
3733
4155
  emitProgress(operationName, "loading");
3734
4156
  const { signer, l2Identity } = restoreWalletParticipant(wallet, provider);
3735
- let contextResult = await loadPreferredWalletChannelContext({ walletContext: wallet, provider });
4157
+ const {
4158
+ txSubmitter,
4159
+ source: txSubmitterSource,
4160
+ account: txSubmitterAccount,
4161
+ } = resolveTxSubmitterSigner({
4162
+ args,
4163
+ ownerSigner: signer,
4164
+ provider,
4165
+ });
4166
+ let contextResult = await loadPreferredWalletChannelContext({
4167
+ walletContext: wallet,
4168
+ provider,
4169
+ progressAction: operationName,
4170
+ });
3736
4171
  let recoveredWorkspace = contextResult.recoveredWorkspace;
3737
4172
 
3738
4173
  try {
3739
4174
  const execution = await executeWalletTemplateSend({
3740
4175
  wallet,
3741
4176
  signer,
4177
+ txSubmitter,
4178
+ txSubmitterSource,
4179
+ txSubmitterAccount,
3742
4180
  l2Identity,
3743
4181
  context: contextResult.context,
3744
4182
  operationName,
@@ -3761,11 +4199,15 @@ async function executeWalletDirectTemplateCommand({
3761
4199
  walletContext: wallet,
3762
4200
  provider,
3763
4201
  forceRecover: true,
4202
+ progressAction: operationName,
3764
4203
  });
3765
4204
  recoveredWorkspace = contextResult.recoveredWorkspace;
3766
4205
  const execution = await executeWalletTemplateSend({
3767
4206
  wallet,
3768
4207
  signer,
4208
+ txSubmitter,
4209
+ txSubmitterSource,
4210
+ txSubmitterAccount,
3769
4211
  l2Identity,
3770
4212
  context: contextResult.context,
3771
4213
  operationName,
@@ -3783,6 +4225,9 @@ async function executeWalletDirectTemplateCommand({
3783
4225
  async function executeWalletTemplateSend({
3784
4226
  wallet,
3785
4227
  signer,
4228
+ txSubmitter,
4229
+ txSubmitterSource,
4230
+ txSubmitterAccount,
3786
4231
  l2Identity,
3787
4232
  context,
3788
4233
  operationName,
@@ -3860,7 +4305,7 @@ async function executeWalletTemplateSend({
3860
4305
  emitProgress(operationName, "submitting");
3861
4306
  const receipt =
3862
4307
  await waitForReceipt(
3863
- await context.channelManager.connect(signer).executeChannelTransaction(payload, functionProof),
4308
+ await context.channelManager.connect(txSubmitter).executeChannelTransaction(payload, functionProof),
3864
4309
  );
3865
4310
 
3866
4311
  const onchainRootVectorHash = normalizeBytes32Hex(await context.channelManager.currentRootVectorHash());
@@ -3884,6 +4329,9 @@ async function executeWalletTemplateSend({
3884
4329
  return {
3885
4330
  wallet,
3886
4331
  signer,
4332
+ txSubmitter,
4333
+ txSubmitterSource,
4334
+ txSubmitterAccount,
3887
4335
  l2Identity,
3888
4336
  context,
3889
4337
  noteLifecycle,
@@ -3929,24 +4377,10 @@ async function loadWorkspaceContext(workspaceName, networkName, provider) {
3929
4377
  };
3930
4378
  }
3931
4379
 
3932
- async function loadChannelContext({ args, networkName, provider, walletContext = null }) {
4380
+ async function loadJoinChannelContext({ args, network, provider }) {
3933
4381
  const chainId = Number((await provider.getNetwork()).chainId);
3934
- const resolvedNetworkName = networkName ?? networkNameFromChainId(chainId);
3935
- const channelName = args.channelName ?? walletContext?.wallet.channelName;
3936
- if (args.channelName && walletContext) {
3937
- expect(
3938
- args.channelName === walletContext.wallet.channelName,
3939
- [
3940
- `The provided --channel-name (${args.channelName}) does not match the wallet channel`,
3941
- `(${walletContext.wallet.channelName}).`,
3942
- ].join(" "),
3943
- );
3944
- }
3945
- if (!channelName) {
3946
- throw new Error(
3947
- "Missing channel selector. Provide either --channel-name or --wallet bound to a channel.",
3948
- );
3949
- }
4382
+ const resolvedNetworkName = network?.name ?? networkNameFromChainId(chainId);
4383
+ const channelName = requireArg(args.channelName, "--channel-name");
3950
4384
 
3951
4385
  const bridgeResources = loadBridgeResources({ chainId });
3952
4386
  const initialized = await initializeChannelWorkspace({
@@ -4732,6 +5166,7 @@ async function reconstructChannelSnapshot({
4732
5166
  baseSnapshot = null,
4733
5167
  fromBlock = genesisBlockNumber,
4734
5168
  toBlock = null,
5169
+ progressAction = null,
4735
5170
  }) {
4736
5171
  let startingSnapshot = baseSnapshot;
4737
5172
  if (!startingSnapshot) {
@@ -4763,6 +5198,9 @@ async function reconstructChannelSnapshot({
4763
5198
  ]],
4764
5199
  fromBlock: scanFromBlock,
4765
5200
  toBlock: latestBlock,
5201
+ onProgress: progressAction
5202
+ ? createRpcLogScanProgress({ action: progressAction, label: "channel-manager events" })
5203
+ : null,
4766
5204
  });
4767
5205
  const channelManagerEvents = channelManagerLogs.map((log) => {
4768
5206
  const topic0 = log.topics[0] ? normalizeBytes32Hex(log.topics[0]) : null;
@@ -4781,6 +5219,9 @@ async function reconstructChannelSnapshot({
4781
5219
  eventName: "StorageWriteObserved",
4782
5220
  fromBlock: scanFromBlock,
4783
5221
  toBlock: latestBlock,
5222
+ onProgress: progressAction
5223
+ ? createRpcLogScanProgress({ action: progressAction, label: "bridge-vault events" })
5224
+ : null,
4784
5225
  });
4785
5226
 
4786
5227
  const groupedEvents = new Map();
@@ -4900,15 +5341,34 @@ async function fetchLogsChunked(provider, {
4900
5341
  fromBlock,
4901
5342
  toBlock,
4902
5343
  initialChunkSize = DEFAULT_LOG_CHUNK_SIZE,
5344
+ onProgress = null,
4903
5345
  }) {
4904
5346
  const normalizedFromBlock = Number(fromBlock);
4905
5347
  const resolvedToBlock = toBlock === "latest" ? await provider.getBlockNumber() : Number(toBlock);
4906
5348
  const aggregatedLogs = [];
4907
5349
 
4908
5350
  if (normalizedFromBlock > resolvedToBlock) {
5351
+ onProgress?.({
5352
+ status: "skipped",
5353
+ fromBlock: normalizedFromBlock,
5354
+ toBlock: resolvedToBlock,
5355
+ scannedBlocks: 0,
5356
+ totalBlocks: 0,
5357
+ logsFound: 0,
5358
+ });
4909
5359
  return aggregatedLogs;
4910
5360
  }
4911
5361
 
5362
+ const totalBlocks = resolvedToBlock - normalizedFromBlock + 1;
5363
+ onProgress?.({
5364
+ status: "start",
5365
+ fromBlock: normalizedFromBlock,
5366
+ toBlock: resolvedToBlock,
5367
+ scannedBlocks: 0,
5368
+ totalBlocks,
5369
+ logsFound: 0,
5370
+ });
5371
+
4912
5372
  let chunkSize = Math.max(1, Number(initialChunkSize));
4913
5373
  let cursor = normalizedFromBlock;
4914
5374
  while (cursor <= resolvedToBlock) {
@@ -4922,6 +5382,17 @@ async function fetchLogsChunked(provider, {
4922
5382
  toBlock: chunkToBlock,
4923
5383
  });
4924
5384
  aggregatedLogs.push(...logs);
5385
+ onProgress?.({
5386
+ status: "progress",
5387
+ fromBlock: normalizedFromBlock,
5388
+ toBlock: resolvedToBlock,
5389
+ chunkFromBlock: cursor,
5390
+ chunkToBlock,
5391
+ scannedBlocks: chunkToBlock - normalizedFromBlock + 1,
5392
+ totalBlocks,
5393
+ logsFound: aggregatedLogs.length,
5394
+ chunkLogs: logs.length,
5395
+ });
4925
5396
  cursor = chunkToBlock + 1;
4926
5397
  } catch (error) {
4927
5398
  if (isRateLimitError(error)) {
@@ -4938,6 +5409,15 @@ async function fetchLogsChunked(provider, {
4938
5409
  }
4939
5410
  }
4940
5411
 
5412
+ onProgress?.({
5413
+ status: "done",
5414
+ fromBlock: normalizedFromBlock,
5415
+ toBlock: resolvedToBlock,
5416
+ scannedBlocks: totalBlocks,
5417
+ totalBlocks,
5418
+ logsFound: aggregatedLogs.length,
5419
+ });
5420
+
4941
5421
  return aggregatedLogs;
4942
5422
  }
4943
5423
 
@@ -4995,6 +5475,7 @@ async function queryContractEventsChunked({
4995
5475
  eventName,
4996
5476
  fromBlock,
4997
5477
  toBlock,
5478
+ onProgress = null,
4998
5479
  }) {
4999
5480
  const eventFragment = contract.interface.getEvent(eventName);
5000
5481
  const eventTopic = contract.interface.getEvent(eventName).topicHash;
@@ -5006,6 +5487,7 @@ async function queryContractEventsChunked({
5006
5487
  topics: [eventTopic],
5007
5488
  fromBlock,
5008
5489
  toBlock,
5490
+ onProgress,
5009
5491
  });
5010
5492
 
5011
5493
  return logs.map((log) => {
@@ -5278,6 +5760,33 @@ function requireArg(value, label) {
5278
5760
  return value;
5279
5761
  }
5280
5762
 
5763
+ function assertVersionArgs(args) {
5764
+ if (args.version !== true) {
5765
+ throw new Error("--version does not accept a value.");
5766
+ }
5767
+ if (args.command) {
5768
+ throw new Error("--version must be used without a command.");
5769
+ }
5770
+ const allowedKeys = new Set(["version", "json"]);
5771
+ const unknownKeys = Object.keys(args)
5772
+ .filter((key) => key !== "positional" && key !== "command" && !allowedKeys.has(key));
5773
+ if (unknownKeys.length > 0) {
5774
+ throw new Error(`Unsupported --version option(s): ${unknownKeys.map(toKebabCase).join(", ")}.`);
5775
+ }
5776
+ }
5777
+
5778
+ function printVersion() {
5779
+ if (isJsonOutputRequested()) {
5780
+ printJson({
5781
+ action: "version",
5782
+ packageName: privateStateCliPackageJson.name,
5783
+ version: privateStateCliPackageJson.version,
5784
+ });
5785
+ return;
5786
+ }
5787
+ console.log(privateStateCliPackageJson.version);
5788
+ }
5789
+
5281
5790
  function requireWorkspaceName(args) {
5282
5791
  const value = typeof args === "string" ? args : args.workspace;
5283
5792
  if (!value) {
@@ -5306,6 +5815,29 @@ function requireL1Signer(args, provider) {
5306
5815
  return new Wallet(resolvePrivateKeySource(args), provider);
5307
5816
  }
5308
5817
 
5818
+ function resolveTxSubmitterSigner({ args, ownerSigner, provider }) {
5819
+ if (args.txSubmitter === undefined) {
5820
+ return {
5821
+ txSubmitter: ownerSigner,
5822
+ source: "wallet-owner",
5823
+ account: null,
5824
+ };
5825
+ }
5826
+ if (args.txSubmitter === true || String(args.txSubmitter).trim() === "") {
5827
+ throw new Error("--tx-submitter requires a local account name.");
5828
+ }
5829
+ const networkName = requireNetworkName(args);
5830
+ const account = String(args.txSubmitter).trim();
5831
+ return {
5832
+ txSubmitter: new Wallet(
5833
+ normalizePrivateKey(readSecretFile(accountPrivateKeyPath(networkName, account), "--tx-submitter")),
5834
+ provider,
5835
+ ),
5836
+ source: "tx-submitter-account",
5837
+ account,
5838
+ };
5839
+ }
5840
+
5309
5841
  function resolvePrivateKeySource(args) {
5310
5842
  const networkName = requireNetworkName(args);
5311
5843
  const account = requireAccountName(args);
@@ -5593,6 +6125,10 @@ function assertUninstallArgs(args) {
5593
6125
  assertAllowedCommandSchema(args, "uninstall");
5594
6126
  }
5595
6127
 
6128
+ function assertUpdateArgs(args) {
6129
+ assertAllowedCommandSchema(args, "update");
6130
+ }
6131
+
5596
6132
  function assertDoctorArgs(args) {
5597
6133
  assertAllowedCommandSchema(args, "doctor");
5598
6134
  if (args.gpu !== undefined && args.gpu !== true) {
@@ -5616,12 +6152,17 @@ function assertGuideArgs(args) {
5616
6152
  assertAllowedCommandSchema(args, "guide");
5617
6153
  }
5618
6154
 
6155
+ function assertTransactionFeesArgs(args) {
6156
+ assertAllowedCommandSchema(args, "transaction-fees");
6157
+ }
6158
+
5619
6159
  function assertAccountImportArgs(args) {
5620
6160
  assertAllowedCommandSchema(args, "account-import");
5621
6161
  }
5622
6162
 
5623
6163
  function assertMintNotesArgs(args) {
5624
6164
  assertAllowedCommandSchema(args, "mint-notes");
6165
+ assertTxSubmitterArg(args);
5625
6166
  parseAmountVector(args.amounts, {
5626
6167
  allowZeroEntries: true,
5627
6168
  requireAnyPositive: true,
@@ -5630,11 +6171,13 @@ function assertMintNotesArgs(args) {
5630
6171
 
5631
6172
  function assertRedeemNotesArgs(args) {
5632
6173
  assertAllowedCommandSchema(args, "redeem-notes");
6174
+ assertTxSubmitterArg(args);
5633
6175
  selectRedeemNotesMethod(parseNoteIdVector(args.noteIds).length);
5634
6176
  }
5635
6177
 
5636
6178
  function assertTransferNotesArgs(args) {
5637
6179
  assertAllowedCommandSchema(args, "transfer-notes");
6180
+ assertTxSubmitterArg(args);
5638
6181
  const noteIds = parseNoteIdVector(args.noteIds);
5639
6182
  const recipients = parseRecipientVector(args.recipients);
5640
6183
  const amounts = parseAmountVector(args.amounts);
@@ -5645,6 +6188,15 @@ function assertTransferNotesArgs(args) {
5645
6188
  selectTransferNotesMethod(noteIds.length, recipients.length);
5646
6189
  }
5647
6190
 
6191
+ function assertTxSubmitterArg(args) {
6192
+ if (args.txSubmitter === undefined) {
6193
+ return;
6194
+ }
6195
+ if (args.txSubmitter === true || String(args.txSubmitter).trim() === "") {
6196
+ throw new Error("--tx-submitter requires a local account name.");
6197
+ }
6198
+ }
6199
+
5648
6200
  function assertGetMyNotesArgs(args) {
5649
6201
  assertWalletSecretArgs(args, "get-my-notes");
5650
6202
  }
@@ -5675,6 +6227,9 @@ function assertExplicitSignerCommandArgs(args, commandName) {
5675
6227
 
5676
6228
  function assertRecoverWalletArgs(args) {
5677
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
+ }
5678
6233
  }
5679
6234
 
5680
6235
  function assertJoinChannelArgs(args) {
@@ -5769,6 +6324,9 @@ Secret source options:
5769
6324
  canonical CLI secret files remain protected. On macOS/Linux this means 0600; on Windows the CLI repairs ACLs when possible.
5770
6325
 
5771
6326
  Options:
6327
+ --version
6328
+ Print the private-state CLI package version and exit.
6329
+
5772
6330
  --json
5773
6331
  Print the command result as JSON. Without --json, commands print human-readable output.
5774
6332
 
@@ -6197,6 +6755,8 @@ function loadWalletCommandRuntime(args) {
6197
6755
 
6198
6756
  const HUMAN_RESULT_RENDERERS = Object.freeze({
6199
6757
  guide: printGuideHumanResult,
6758
+ "transaction-fees": printTransactionFeesHumanResult,
6759
+ update: printUpdateHumanResult,
6200
6760
  });
6201
6761
 
6202
6762
  function normalizePrivateKey(value) {
@@ -6246,9 +6806,90 @@ function printGuideHumanResult(guide) {
6246
6806
  }
6247
6807
 
6248
6808
  lines.push("", "Run with --json to inspect the full guide state.");
6809
+ lines.push(
6810
+ "",
6811
+ "Privacy Tip",
6812
+ formatHumanValue(guide.privacyTip),
6813
+ );
6814
+ console.log(lines.join("\n"));
6815
+ }
6816
+
6817
+ function printTransactionFeesHumanResult(report) {
6818
+ const lines = [
6819
+ "Transaction Fees",
6820
+ `Generated: ${formatHumanValue(report.generatedAt)}`,
6821
+ `Network: ${formatHumanValue(report.network)} (${formatHumanValue(report.chainId)})`,
6822
+ `Typical gas price: ${formatHumanValue(report.livePricing?.typicalGasPriceGwei)} gwei (${formatHumanValue(report.livePricing?.typicalGasPriceSource)})`,
6823
+ `Worst-case gas price: ${formatHumanValue(report.livePricing?.worstCaseGasPriceGwei)} gwei (${formatHumanValue(report.livePricing?.worstCaseGasPriceSource)})`,
6824
+ `ETH/USD: $${formatHumanValue(report.livePricing?.ethUsd)} (${formatHumanValue(report.livePricing?.ethUsdSource)})`,
6825
+ `Measured gas asset: ${formatHumanValue(report.asset?.schema)}, measured ${formatHumanValue(report.asset?.measuredAt)}`,
6826
+ "",
6827
+ formatHumanTable(
6828
+ ["Command", "Transactions", "Gas", "Typical ETH", "Typical USD", "Worst ETH", "Worst USD", "Source"],
6829
+ (report.rows ?? []).map((row) => [
6830
+ row.command,
6831
+ row.transactions,
6832
+ String(row.gasUsed),
6833
+ row.typicalEth,
6834
+ `$${row.typicalUsd}`,
6835
+ row.worstCaseEth,
6836
+ `$${row.worstCaseUsd}`,
6837
+ row.sources,
6838
+ ]),
6839
+ ),
6840
+ ];
6841
+ if (Array.isArray(report.asset?.notes) && report.asset.notes.length > 0) {
6842
+ lines.push(
6843
+ "",
6844
+ "Notes",
6845
+ ...report.asset.notes.map((note) => `- ${note}`),
6846
+ );
6847
+ }
6249
6848
  console.log(lines.join("\n"));
6250
6849
  }
6251
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
+
6880
+ function formatHumanTable(headers, rows) {
6881
+ const values = [headers, ...rows].map((row) => row.map((value) => String(value ?? "")));
6882
+ const widths = headers.map((_header, columnIndex) =>
6883
+ Math.max(...values.map((row) => row[columnIndex].length)),
6884
+ );
6885
+ const formatRow = (row) => `| ${row.map((value, index) => value.padEnd(widths[index])).join(" | ")} |`;
6886
+ return [
6887
+ formatRow(values[0]),
6888
+ formatRow(widths.map((width) => "-".repeat(width))),
6889
+ ...values.slice(1).map(formatRow),
6890
+ ].join("\n");
6891
+ }
6892
+
6252
6893
  function formatGuideSelector(value) {
6253
6894
  return value === null || value === undefined || value === "" ? "not selected" : String(value);
6254
6895
  }
@@ -6327,6 +6968,51 @@ function emitProgress(action, phase) {
6327
6968
  }
6328
6969
  }
6329
6970
 
6971
+ function createRpcLogScanProgress({ action, label }) {
6972
+ let lastBucket = -1;
6973
+ return (event) => {
6974
+ const totalBlocks = Number(event.totalBlocks ?? 0);
6975
+ const scannedBlocks = Number(event.scannedBlocks ?? 0);
6976
+ const logsFound = Number(event.logsFound ?? 0);
6977
+ if (event.status === "skipped") {
6978
+ emitProgress(action, `rpc-log-scan ${label}: skipped (no blocks to scan, ${logsFound} logs)`);
6979
+ return;
6980
+ }
6981
+ if (event.status === "start") {
6982
+ lastBucket = 0;
6983
+ emitProgress(
6984
+ action,
6985
+ `rpc-log-scan ${label}: 0% (0/${totalBlocks} blocks, ${logsFound} logs, blocks ${event.fromBlock}-${event.toBlock})`,
6986
+ );
6987
+ return;
6988
+ }
6989
+ if (event.status === "done") {
6990
+ emitProgress(
6991
+ action,
6992
+ `rpc-log-scan ${label}: 100% (${totalBlocks}/${totalBlocks} blocks, ${logsFound} logs, done)`,
6993
+ );
6994
+ return;
6995
+ }
6996
+ if (event.status !== "progress" || totalBlocks <= 0) {
6997
+ return;
6998
+ }
6999
+
7000
+ const percent = Math.min(100, Math.floor((scannedBlocks * 100) / totalBlocks));
7001
+ if (percent >= 100) {
7002
+ return;
7003
+ }
7004
+ const bucket = Math.floor(percent / 10) * 10;
7005
+ if (bucket <= lastBucket) {
7006
+ return;
7007
+ }
7008
+ lastBucket = bucket;
7009
+ emitProgress(
7010
+ action,
7011
+ `rpc-log-scan ${label}: ${percent}% (${scannedBlocks}/${totalBlocks} blocks, ${logsFound} logs)`,
7012
+ );
7013
+ };
7014
+ }
7015
+
6330
7016
  function formatCliErrorForDisplay(error, args = {}) {
6331
7017
  const message = String(error?.message ?? error);
6332
7018
  const hints = buildRecoveryHints(error, args);
@@ -6411,6 +7097,15 @@ function buildRecoveryHints(error, args = {}) {
6411
7097
  hints.push(`private-state-cli guide --network ${networkName} --channel-name ${channelName}`);
6412
7098
  }
6413
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
+
6414
7109
  if (message.includes("Missing channel selector")) {
6415
7110
  hints.push(`private-state-cli list-local-wallets --network ${networkName}`);
6416
7111
  hints.push(`private-state-cli guide --network ${networkName} --channel-name <CHANNEL> --wallet <WALLET>`);