@tokamak-private-dapps/private-state-cli 2.4.1 → 2.4.3

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/lib/runtime.mjs CHANGED
@@ -166,6 +166,8 @@ const CLI_ERROR_CODES = Object.freeze({
166
166
  MISSING_CHANNEL_REGISTRATION: "MISSING_CHANNEL_REGISTRATION",
167
167
  STALE_WORKSPACE: "STALE_WORKSPACE",
168
168
  STALE_CHANNEL_ROOT: "STALE_CHANNEL_ROOT",
169
+ TX_DRY_RUN_FAILED: "TX_DRY_RUN_FAILED",
170
+ TX_SUBMIT_FAILED: "TX_SUBMIT_FAILED",
169
171
  });
170
172
 
171
173
  class PrivateStateCliError extends Error {
@@ -288,7 +290,13 @@ function printImmutableChannelPolicyWarning({
288
290
  "Do not sign if any snapshot value is unexpected or has not been reviewed.",
289
291
  );
290
292
  }
291
- console.error(details.join("\n"));
293
+ cliOutput.warning("channel-policy", details.join("\n"), {
294
+ action,
295
+ channelName,
296
+ channelId: channelId.toString(),
297
+ channelManager,
298
+ policySnapshot,
299
+ });
292
300
  }
293
301
 
294
302
  const ACTION_IMPACT_SUMMARIES = Object.freeze({
@@ -483,7 +491,16 @@ function printActionImpactSummary(summary, details) {
483
491
  lines.push(`- Exchange-controlled address warning: ${summary.exchangeControlledAddressWarning}`);
484
492
  }
485
493
  lines.push(`- Confirmation: pass --acknowledge-action-impact or type the exact confirmation phrase when prompted.`);
486
- console.error(lines.join("\n"));
494
+ cliOutput.warning("action-impact", lines.join("\n"), {
495
+ command: summary.display,
496
+ l1PublicEvent: summary.l1PublicEvent,
497
+ privateNoteState: summary.privateNoteState,
498
+ publicFields: normalizeImpactLines(summary.publicFields, details),
499
+ notPublic: normalizeImpactLines(summary.notPublic, details),
500
+ noteProvenance: summary.noteProvenance,
501
+ policy: summary.policy,
502
+ exchangeControlledAddressWarning: summary.exchangeControlledAddressWarning ?? null,
503
+ });
487
504
  }
488
505
 
489
506
  function normalizeImpactLines(value, details) {
@@ -609,8 +626,15 @@ async function handleChannelCreate({ args, network, provider }) {
609
626
  channelId,
610
627
  policySnapshot,
611
628
  });
612
- const receipt =
613
- await waitForReceipt(await bridgeCore.createChannel(channelId, dappId, joinToll, dapp.metadataDigest));
629
+ const receipt = await dryRunThenSubmitTransaction({
630
+ operationName: "channel create",
631
+ call: contractTxCall(
632
+ bridgeCore.createChannel,
633
+ [channelId, dappId, joinToll, dapp.metadataDigest],
634
+ undefined,
635
+ bridgeCore.interface,
636
+ ),
637
+ });
614
638
  const channelInfo = await bridgeCore.getChannel(channelId);
615
639
 
616
640
  const workspaceResult = await syncChannelWorkspace({
@@ -625,7 +649,7 @@ async function handleChannelCreate({ args, network, provider }) {
625
649
  progressAction: "channel create",
626
650
  });
627
651
 
628
- printJson({
652
+ cliOutput.result({
629
653
  action: "channel create",
630
654
  channelName,
631
655
  channelId: channelId.toString(),
@@ -767,7 +791,7 @@ async function handleWorkspaceInit({ args, network, provider }) {
767
791
  })
768
792
  : null;
769
793
 
770
- printJson({
794
+ cliOutput.result({
771
795
  action: "channel recover-workspace",
772
796
  source: workspace.recoverySource ?? recoverySource,
773
797
  workspace: workspaceName,
@@ -804,7 +828,7 @@ async function handleGetChannel({ args, network, provider }) {
804
828
  channelInfo = await bridgeCore.getChannel(channelId);
805
829
  } catch (error) {
806
830
  if (isContractError(error, bridgeCore.interface, "UnknownChannel")) {
807
- printJson({
831
+ cliOutput.result({
808
832
  action: "channel get-meta",
809
833
  channelName,
810
834
  channelId: channelId.toString(),
@@ -843,7 +867,7 @@ async function handleGetChannel({ args, network, provider }) {
843
867
  readChannelRefundSchedule(channelManager),
844
868
  ]);
845
869
 
846
- printJson({
870
+ cliOutput.result({
847
871
  action: "channel get-meta",
848
872
  channelName,
849
873
  channelId: channelId.toString(),
@@ -880,10 +904,18 @@ async function handleSetChannelWorkspaceMirror({ args, network, provider }) {
880
904
  );
881
905
  const channelId = deriveChannelIdFromName(channelName);
882
906
  const previousUrl = await readChannelWorkspaceMirror({ bridgeCore, channelId });
883
- const receipt = await waitForReceipt(await bridgeCore.setChannelWorkspaceMirror(channelId, url));
907
+ const receipt = await dryRunThenSubmitTransaction({
908
+ operationName: "channel set-workspace-mirror",
909
+ call: contractTxCall(
910
+ bridgeCore.setChannelWorkspaceMirror,
911
+ [channelId, url],
912
+ undefined,
913
+ bridgeCore.interface,
914
+ ),
915
+ });
884
916
  const currentUrl = await readChannelWorkspaceMirror({ bridgeCore, channelId });
885
917
 
886
- printJson({
918
+ cliOutput.result({
887
919
  action: "channel set-workspace-mirror",
888
920
  channelName,
889
921
  channelId: channelId.toString(),
@@ -2061,7 +2093,8 @@ async function syncChannelWorkspace({
2061
2093
  throw new Error([
2062
2094
  `Workspace recovery index is missing or unusable for channel ${channelName} on ${networkNameFromChainId(network.chainId)}.`,
2063
2095
  "The CLI will not fall back to replaying channel logs from genesis unless explicitly requested.",
2064
- "Run channel recover-workspace first to refresh the local channel workspace.",
2096
+ `If a workspace mirror is registered, run channel recover-workspace --channel-name ${channelName} --network ${networkNameFromChainId(network.chainId)} --source mirror first.`,
2097
+ `Use channel recover-workspace --channel-name ${channelName} --network ${networkNameFromChainId(network.chainId)} --source rpc --from-genesis only when no compatible mirror is available.`,
2065
2098
  ].join(" "));
2066
2099
  }
2067
2100
  const workspaceBase = {
@@ -2209,12 +2242,28 @@ async function handleDepositBridge({ args, network, provider }) {
2209
2242
  signer,
2210
2243
  );
2211
2244
  let nextNonce = await provider.getTransactionCount(signer.address, "pending");
2212
- const approveReceipt =
2213
- await waitForReceipt(await asset.approve(bridgeVaultContext.bridgeTokenVaultAddress, amount, { nonce: nextNonce++ }));
2214
- const fundReceipt = await waitForReceipt(await bridgeTokenVault.fund(amount, { nonce: nextNonce++ }));
2245
+ const approveReceipt = await dryRunThenSubmitTransaction({
2246
+ operationName: "account deposit-bridge approve",
2247
+ call: contractTxCall(
2248
+ asset.approve,
2249
+ [bridgeVaultContext.bridgeTokenVaultAddress, amount],
2250
+ { nonce: nextNonce++ },
2251
+ asset.interface,
2252
+ ),
2253
+ });
2254
+ const fundReceipt = await dryRunThenSubmitTransaction({
2255
+ operationName: "account deposit-bridge fund",
2256
+ call: contractTxCall(
2257
+ bridgeTokenVault.fund,
2258
+ [amount],
2259
+ { nonce: nextNonce++ },
2260
+ bridgeTokenVault.interface,
2261
+ ),
2262
+ submittedBefore: [submittedReceiptSummary("account deposit-bridge approve", approveReceipt)],
2263
+ });
2215
2264
  const availableBalance = await bridgeTokenVault.availableBalanceOf(signer.address);
2216
2265
 
2217
- printJson({
2266
+ cliOutput.result({
2218
2267
  action: "account deposit-bridge",
2219
2268
  amountInput,
2220
2269
  amountBaseUnits: amount.toString(),
@@ -2242,7 +2291,7 @@ async function handleAccountGetBridgeFund({ args, provider }) {
2242
2291
  );
2243
2292
  const availableBalance = await bridgeTokenVault.availableBalanceOf(signer.address);
2244
2293
 
2245
- printJson({
2294
+ cliOutput.result({
2246
2295
  action: "account get-bridge-fund",
2247
2296
  l1Address: signer.address,
2248
2297
  bridgeTokenVault: bridgeVaultContext.bridgeTokenVaultAddress,
@@ -2392,7 +2441,7 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
2392
2441
  latestBlock: recoveryEventScan.scanRange.toBlock,
2393
2442
  });
2394
2443
 
2395
- printJson({
2444
+ cliOutput.result({
2396
2445
  action: "wallet recover-workspace",
2397
2446
  status,
2398
2447
  wallet: walletName,
@@ -2490,7 +2539,7 @@ async function handleInstallZkEvm({ args }) {
2490
2539
  tokamakCliRuntime,
2491
2540
  groth16Runtime,
2492
2541
  });
2493
- printJson({
2542
+ cliOutput.result({
2494
2543
  action: "install",
2495
2544
  installMode,
2496
2545
  selectedVersions,
@@ -2531,7 +2580,7 @@ async function handleUninstall() {
2531
2580
  });
2532
2581
  const globalPackage = uninstallGlobalPrivateStateCliPackage();
2533
2582
 
2534
- printJson({
2583
+ cliOutput.result({
2535
2584
  action: "uninstall",
2536
2585
  confirmationAccepted: true,
2537
2586
  removedPrivateStateRoots,
@@ -2558,7 +2607,7 @@ async function handleSetRpc({ args }) {
2558
2607
  LOG_CHUNK_SIZE: rpcScanLimits.blockRangeCap,
2559
2608
  RPC_BLOCK_RANGE_CAP: rpcScanLimits.blockRangeCap,
2560
2609
  });
2561
- printJson({
2610
+ cliOutput.result({
2562
2611
  action: "set rpc",
2563
2612
  network: networkName,
2564
2613
  rpcConfigPath: rpcConfigEnvPath(networkName),
@@ -2963,12 +3012,12 @@ async function handleUpdate() {
2963
3012
  };
2964
3013
 
2965
3014
  if (!updateAvailable) {
2966
- printJson(result);
3015
+ cliOutput.result(result);
2967
3016
  return;
2968
3017
  }
2969
3018
 
2970
3019
  if (runningFromRepositoryCheckout) {
2971
- printJson({
3020
+ cliOutput.result({
2972
3021
  ...result,
2973
3022
  reason: "running from a repository checkout; update the checkout with git/npm instead of mutating source files",
2974
3023
  });
@@ -2976,7 +3025,7 @@ async function handleUpdate() {
2976
3025
  }
2977
3026
 
2978
3027
  if (!globalPackage.installed) {
2979
- printJson({
3028
+ cliOutput.result({
2980
3029
  ...result,
2981
3030
  reason: "global npm package is not installed; install or update the CLI with the printed command",
2982
3031
  });
@@ -2990,7 +3039,7 @@ async function handleUpdate() {
2990
3039
  stripAnsi(install.stderr || install.stdout).trim(),
2991
3040
  ].filter(Boolean).join(" "));
2992
3041
  }
2993
- printJson({
3042
+ cliOutput.result({
2994
3043
  ...result,
2995
3044
  attempted: true,
2996
3045
  updated: true,
@@ -3042,18 +3091,14 @@ function isRepositoryCliPackageRoot(packageRoot) {
3042
3091
 
3043
3092
  async function handleDoctor({ args }) {
3044
3093
  const report = buildDoctorReport({ probeGpu: args.gpu === true });
3045
- if (isJsonOutputRequested()) {
3046
- printJson(report);
3047
- } else {
3048
- printDoctorHumanReport(report);
3049
- }
3094
+ cliOutput.result(report);
3050
3095
  if (!report.ok) {
3051
3096
  process.exitCode = 1;
3052
3097
  }
3053
3098
  }
3054
3099
 
3055
3100
  function handleObserver() {
3056
- printJson({
3101
+ cliOutput.result({
3057
3102
  action: "observer",
3058
3103
  url: PRIVATE_STATE_OBSERVER_URL,
3059
3104
  scope: "Public monitoring observer for Tokamak Private App Channels and the private-state DApp.",
@@ -3068,7 +3113,7 @@ function handleInvestigator() {
3068
3113
  const htmlPath = resolveInvestigatorIndexPath();
3069
3114
  const fileUrl = pathToFileURL(htmlPath).href;
3070
3115
  const browser = openFileInDefaultBrowser(fileUrl);
3071
- printJson({
3116
+ cliOutput.result({
3072
3117
  action: "investigator",
3073
3118
  htmlPath,
3074
3119
  fileUrl,
@@ -3137,7 +3182,7 @@ async function handleTransactionFees({ network, provider, rpcUrl }) {
3137
3182
  ethUsd,
3138
3183
  });
3139
3184
 
3140
- printJson({
3185
+ cliOutput.result({
3141
3186
  action: "transaction-fees",
3142
3187
  generatedAt: new Date().toISOString(),
3143
3188
  network: network.name,
@@ -3265,7 +3310,7 @@ function trimFixedNumber(value, maxDecimals) {
3265
3310
 
3266
3311
  function handleAccountGetL1Address({ args }) {
3267
3312
  const signer = requireL1Signer(args);
3268
- printJson({
3313
+ cliOutput.result({
3269
3314
  action: "account get-l1-address",
3270
3315
  l1Address: signer.address,
3271
3316
  account: args.account ?? null,
@@ -3292,7 +3337,7 @@ function handleAccountImport({ args }) {
3292
3337
  l1Address: getAddress(signer.address),
3293
3338
  privateKeyPath,
3294
3339
  }, 0o600);
3295
- printJson({
3340
+ cliOutput.result({
3296
3341
  action: "account import",
3297
3342
  account,
3298
3343
  network: networkName,
@@ -3313,7 +3358,7 @@ function handleListLocalWallets({ args }) {
3313
3358
  channelFilter,
3314
3359
  });
3315
3360
 
3316
- printJson({
3361
+ cliOutput.result({
3317
3362
  action: "wallet list",
3318
3363
  workspaceRoot,
3319
3364
  filters: {
@@ -3381,7 +3426,7 @@ function handleWalletExportBackup({ args }) {
3381
3426
  archive.writeZip(outputPath);
3382
3427
  protectSecretFile(outputPath, "wallet export ZIP");
3383
3428
 
3384
- printJson({
3429
+ cliOutput.result({
3385
3430
  action: "wallet export backup",
3386
3431
  output: outputPath,
3387
3432
  exportMode: manifest.exportMode,
@@ -3406,7 +3451,7 @@ function handleWalletExportKey({ args, keyKind }) {
3406
3451
  validateWalletKeyPayload(payload, keyKind);
3407
3452
  fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
3408
3453
  protectSecretFile(outputPath, `${keyKind} key export`);
3409
- printJson({
3454
+ cliOutput.result({
3410
3455
  action: `wallet export ${keyKind}-key`,
3411
3456
  wallet: wallet.walletName,
3412
3457
  network: networkName,
@@ -3451,7 +3496,7 @@ function handleWalletImportBackup({ args }) {
3451
3496
 
3452
3497
  commitWalletImportFiles({ targetRoot, plannedWrites });
3453
3498
 
3454
- printJson({
3499
+ cliOutput.result({
3455
3500
  action: "wallet import backup",
3456
3501
  input: inputPath,
3457
3502
  exportMode: manifest.exportMode,
@@ -3494,7 +3539,7 @@ function handleWalletImportKey({ args, keyKind }) {
3494
3539
  writeJson(metadataPath, metadata);
3495
3540
  }
3496
3541
  }
3497
- printJson({
3542
+ cliOutput.result({
3498
3543
  action: `wallet import ${keyKind}-key`,
3499
3544
  input: inputPath,
3500
3545
  network: networkName,
@@ -3636,7 +3681,7 @@ async function handleGuide({ args }) {
3636
3681
  "help guide --network anvil",
3637
3682
  ],
3638
3683
  });
3639
- printJson(guide);
3684
+ cliOutput.result(guide);
3640
3685
  return;
3641
3686
  }
3642
3687
 
@@ -3649,7 +3694,7 @@ async function handleGuide({ args }) {
3649
3694
  command: "help guide --network <NAME>",
3650
3695
  why: `The requested network ${networkName} is not supported by the CLI network config.`,
3651
3696
  });
3652
- printJson(guide);
3697
+ cliOutput.result(guide);
3653
3698
  return;
3654
3699
  }
3655
3700
 
@@ -3736,7 +3781,7 @@ async function handleGuide({ args }) {
3736
3781
 
3737
3782
  destroyGuideProvider(provider);
3738
3783
  applyGuideNextAction(guide);
3739
- printJson(guide);
3784
+ cliOutput.result(guide);
3740
3785
  }
3741
3786
 
3742
3787
  function inspectGuideLocalState(args) {
@@ -3873,12 +3918,14 @@ async function inspectGuideChannel({ channelName, network, provider, artifactsIn
3873
3918
  bridgeResources.bridgeAbiManifest.contracts.channelManager.abi,
3874
3919
  provider,
3875
3920
  );
3876
- const [joinToll, refundSchedule] = await Promise.all([
3921
+ const [joinToll, refundSchedule, workspaceMirror] = await Promise.all([
3877
3922
  channelManager.joinToll(),
3878
3923
  readChannelRefundSchedule(channelManager),
3924
+ readChannelWorkspaceMirror({ bridgeCore, channelId }),
3879
3925
  ]);
3880
3926
  result.onchain.joinTollBaseUnits = joinToll.toString();
3881
3927
  result.onchain.refundSchedule = refundSchedule;
3928
+ result.onchain.workspaceMirror = workspaceMirror;
3882
3929
  }
3883
3930
  } catch (error) {
3884
3931
  result.error = error.message;
@@ -4054,9 +4101,19 @@ function applyGuideNextAction(guide) {
4054
4101
  return;
4055
4102
  }
4056
4103
  if (guide.selectors.channelName && guide.state.channel?.onchain?.exists && !guide.state.channel?.local?.workspaceExists) {
4104
+ const workspaceMirror = typeof guide.state.channel.onchain.workspaceMirror === "string"
4105
+ ? guide.state.channel.onchain.workspaceMirror.trim()
4106
+ : "";
4107
+ if (workspaceMirror) {
4108
+ setGuideNextAction(guide, {
4109
+ command: `channel recover-workspace --channel-name ${guide.selectors.channelName} --network ${guide.selectors.network} --source mirror`,
4110
+ why: "The channel has a registered workspace mirror. Use mirror recovery before considering an explicit RPC genesis rebuild.",
4111
+ });
4112
+ return;
4113
+ }
4057
4114
  setGuideNextAction(guide, {
4058
4115
  command: `channel recover-workspace --channel-name ${guide.selectors.channelName} --network ${guide.selectors.network} --source rpc --from-genesis`,
4059
- 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.",
4116
+ why: "The channel exists on-chain, but the local channel workspace has not been recovered yet and no workspace mirror is registered. RPC genesis rebuild is the remaining explicit bootstrap path.",
4060
4117
  });
4061
4118
  return;
4062
4119
  }
@@ -4213,7 +4270,7 @@ async function handleWalletGetMeta({ args, provider }) {
4213
4270
  provider,
4214
4271
  });
4215
4272
 
4216
- printJson({
4273
+ cliOutput.result({
4217
4274
  action: "wallet get-meta",
4218
4275
  wallet: wallet.walletName,
4219
4276
  ...walletLifecycleMetadata(wallet.wallet),
@@ -4547,7 +4604,7 @@ async function handleWalletGetChannelFund({ args, provider }) {
4547
4604
  provider,
4548
4605
  });
4549
4606
 
4550
- printJson({
4607
+ cliOutput.result({
4551
4608
  action: "wallet get-channel-fund",
4552
4609
  wallet: wallet.walletName,
4553
4610
  network: walletMetadata.network,
@@ -4628,20 +4685,32 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4628
4685
  channelId: context.workspace.channelId,
4629
4686
  });
4630
4687
  if (joinToll !== 0n) {
4631
- approveReceipt = await waitForReceipt(
4632
- await asset.approve(context.workspace.bridgeTokenVault, joinToll, { nonce: nextNonce++ }),
4633
- );
4688
+ approveReceipt = await dryRunThenSubmitTransaction({
4689
+ operationName: "channel join approve",
4690
+ call: contractTxCall(
4691
+ asset.approve,
4692
+ [context.workspace.bridgeTokenVault, joinToll],
4693
+ { nonce: nextNonce++ },
4694
+ asset.interface,
4695
+ ),
4696
+ });
4634
4697
  }
4635
- receipt = await waitForReceipt(
4636
- await context.bridgeTokenVault.connect(signer).joinChannel(
4637
- ethers.toBigInt(context.workspace.channelId),
4638
- l2Identity.l2Address,
4639
- storageKey,
4640
- leafIndex,
4641
- noteReceiveKeyMaterial.noteReceivePubKey,
4698
+ receipt = await dryRunThenSubmitTransaction({
4699
+ operationName: "channel join",
4700
+ call: contractTxCall(
4701
+ context.bridgeTokenVault.connect(signer).joinChannel,
4702
+ [
4703
+ ethers.toBigInt(context.workspace.channelId),
4704
+ l2Identity.l2Address,
4705
+ storageKey,
4706
+ leafIndex,
4707
+ noteReceiveKeyMaterial.noteReceivePubKey,
4708
+ ],
4642
4709
  { nonce: nextNonce++ },
4710
+ context.bridgeTokenVault.interface,
4643
4711
  ),
4644
- );
4712
+ submittedBefore: approveReceipt ? [submittedReceiptSummary("channel join approve", approveReceipt)] : [],
4713
+ });
4645
4714
  const registered = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
4646
4715
  const lifecycleEpoch = await walletEpochFromJoinReceipt({
4647
4716
  receipt,
@@ -4670,7 +4739,7 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
4670
4739
  rpcUrl,
4671
4740
  });
4672
4741
 
4673
- printJson({
4742
+ cliOutput.result({
4674
4743
  action: "channel join",
4675
4744
  workspace: context.workspaceName,
4676
4745
  wallet: walletContext.walletName,
@@ -4720,16 +4789,22 @@ async function handleExitChannel({ args, provider }) {
4720
4789
  ].join(" "),
4721
4790
  );
4722
4791
  const [refundAmount, refundBps] = await context.channelManager.getExitTollRefundQuote(ownerSigner.address);
4723
- const receipt = await waitForReceipt(
4724
- await context.bridgeTokenVault.connect(ownerSigner).exitChannel(ethers.toBigInt(context.workspace.channelId)),
4725
- );
4792
+ const receipt = await dryRunThenSubmitTransaction({
4793
+ operationName: "channel exit",
4794
+ call: contractTxCall(
4795
+ context.bridgeTokenVault.connect(ownerSigner).exitChannel,
4796
+ [ethers.toBigInt(context.workspace.channelId)],
4797
+ undefined,
4798
+ context.bridgeTokenVault.interface,
4799
+ ),
4800
+ });
4726
4801
  const lifecycleEpoch = await markWalletEpochExited({
4727
4802
  walletContext,
4728
4803
  receipt,
4729
4804
  provider,
4730
4805
  });
4731
4806
 
4732
- printJson({
4807
+ cliOutput.result({
4733
4808
  action: "channel exit",
4734
4809
  wallet: walletContext.walletName,
4735
4810
  network: walletMetadata.network,
@@ -4854,16 +4929,22 @@ async function handleGrothVaultMove({ args, provider, direction }) {
4854
4929
  });
4855
4930
 
4856
4931
  const methodName = direction === "deposit" ? "depositToChannelVault" : "withdrawFromChannelVault";
4857
- await assertWorkspaceAlignedWithChain(context);
4858
4932
  emitProgress(operationName, "submitting");
4859
- const receipt = await submitProofBackedRootUpdate({
4933
+ const receipt = await dryRunThenSubmitTransaction({
4934
+ operationName,
4860
4935
  context,
4861
4936
  walletName: walletContext.walletName,
4862
- operationName,
4863
- submit: () => bridgeTokenVault[methodName](
4864
- ethers.toBigInt(context.workspace.channelId),
4865
- transition.proof,
4866
- transition.update,
4937
+ operationDir,
4938
+ precheck: () => precheckGrothRootUpdate({ context, transition, operationName }),
4939
+ call: contractTxCall(
4940
+ bridgeTokenVault[methodName],
4941
+ [
4942
+ ethers.toBigInt(context.workspace.channelId),
4943
+ transition.proof,
4944
+ transition.update,
4945
+ ],
4946
+ undefined,
4947
+ bridgeTokenVault.interface,
4867
4948
  ),
4868
4949
  });
4869
4950
  const onchainRootVectorHash = normalizeBytes32Hex(await context.channelManager.currentRootVectorHash());
@@ -4887,7 +4968,7 @@ async function handleGrothVaultMove({ args, provider, direction }) {
4887
4968
  });
4888
4969
 
4889
4970
  emitProgress(operationName, "done");
4890
- printJson({
4971
+ cliOutput.result({
4891
4972
  action: operationName,
4892
4973
  workspace: context.workspaceName,
4893
4974
  wallet: walletContext.walletName,
@@ -4921,9 +5002,17 @@ async function handleWithdrawBridge({ args, network, provider }) {
4921
5002
  bridgeVaultContext.bridgeAbiManifest.contracts.bridgeTokenVault.abi,
4922
5003
  signer,
4923
5004
  );
4924
- const receipt = await waitForReceipt(await bridgeTokenVault.claimToWallet(amount));
5005
+ const receipt = await dryRunThenSubmitTransaction({
5006
+ operationName: "account withdraw-bridge",
5007
+ call: contractTxCall(
5008
+ bridgeTokenVault.claimToWallet,
5009
+ [amount],
5010
+ undefined,
5011
+ bridgeTokenVault.interface,
5012
+ ),
5013
+ });
4925
5014
 
4926
- printJson({
5015
+ cliOutput.result({
4927
5016
  action: "account withdraw-bridge",
4928
5017
  l1Address: signer.address,
4929
5018
  amountInput,
@@ -5054,7 +5143,7 @@ async function handleMintNotes({ args, provider }) {
5054
5143
  preparedContextResult,
5055
5144
  });
5056
5145
 
5057
- printJson({
5146
+ cliOutput.result({
5058
5147
  action: "wallet mint-notes",
5059
5148
  wallet: wallet.walletName,
5060
5149
  workspace: execution.context.workspaceName,
@@ -5129,7 +5218,7 @@ async function handleRedeemNotes({ args, provider }) {
5129
5218
  preparedContextResult,
5130
5219
  });
5131
5220
 
5132
- printJson({
5221
+ cliOutput.result({
5133
5222
  action: "wallet redeem-notes",
5134
5223
  wallet: wallet.walletName,
5135
5224
  workspace: execution.context.workspaceName,
@@ -5221,7 +5310,7 @@ async function handleWalletGetNotes({ args, provider }) {
5221
5310
  })
5222
5311
  : null;
5223
5312
 
5224
- printJson({
5313
+ cliOutput.result({
5225
5314
  action: "wallet get-notes",
5226
5315
  wallet: wallet.walletName,
5227
5316
  network: walletMetadata.network,
@@ -5989,7 +6078,7 @@ async function handleTransferNotes({ args, provider }) {
5989
6078
  counterpartyDirection: "sent",
5990
6079
  });
5991
6080
 
5992
- printJson({
6081
+ cliOutput.result({
5993
6082
  action: "wallet transfer-notes",
5994
6083
  wallet: wallet.walletName,
5995
6084
  workspace: execution.context.workspaceName,
@@ -7643,7 +7732,7 @@ async function executeWalletTemplateSend({
7643
7732
  functionName,
7644
7733
  templatePayload,
7645
7734
  }) {
7646
- await assertWorkspaceAlignedWithChain(context, signer.provider);
7735
+ await assertWorkspaceAlignedWithChain(context);
7647
7736
  assertWalletMatchesChannelContext(wallet, l2Identity, context);
7648
7737
  await assertChannelProofBackendVersionCompatibility({ context, operationName });
7649
7738
 
@@ -7699,19 +7788,26 @@ async function executeWalletTemplateSend({
7699
7788
  expectedFunctionRoot: context.workspace.functionRoot ?? context.workspace.policySnapshot?.functionRoot,
7700
7789
  });
7701
7790
  const aPubBlockHash = hashTokamakPublicInputs(payload.aPubBlock);
7702
- expect(
7703
- ethers.toBigInt(normalizeBytes32Hex(aPubBlockHash))
7704
- === ethers.toBigInt(normalizeBytes32Hex(context.workspace.aPubBlockHash)),
7705
- "Generated Tokamak proof does not match the channel aPubBlockHash. Check the workspace block_info.json context.",
7706
- );
7707
7791
 
7708
- await assertWorkspaceAlignedWithChain(context);
7709
7792
  emitProgress(operationName, "submitting");
7710
- const receipt = await submitProofBackedRootUpdate({
7793
+ const receipt = await dryRunThenSubmitTransaction({
7794
+ operationName,
7711
7795
  context,
7712
7796
  walletName: wallet.walletName,
7713
- operationName,
7714
- submit: () => context.channelManager.connect(txSubmitter).executeChannelTransaction(payload, functionProof),
7797
+ operationDir,
7798
+ precheck: () => precheckTokamakExecution({
7799
+ context,
7800
+ payload,
7801
+ functionProof,
7802
+ aPubBlockHash,
7803
+ operationName,
7804
+ }),
7805
+ call: contractTxCall(
7806
+ context.channelManager.connect(txSubmitter).executeChannelTransaction,
7807
+ [payload, functionProof],
7808
+ undefined,
7809
+ context.channelManager.interface,
7810
+ ),
7715
7811
  });
7716
7812
  await waitForProviderBlockAtLeast(provider, receipt.blockNumber, { action: operationName });
7717
7813
 
@@ -8386,25 +8482,219 @@ function isUnexpectedCurrentRootVectorError(error, context) {
8386
8482
  return String(error?.message ?? error).includes("UnexpectedCurrentRootVector");
8387
8483
  }
8388
8484
 
8389
- async function submitProofBackedRootUpdate({
8390
- context,
8391
- walletName,
8485
+ function precheckGrothRootUpdate({ context, transition, operationName }) {
8486
+ expect(transition && typeof transition === "object", `${operationName} proof precheck failed: missing transition.`);
8487
+ expect(transition.proof && typeof transition.proof === "object", `${operationName} proof precheck failed: missing Groth16 proof.`);
8488
+ expect(transition.update && typeof transition.update === "object", `${operationName} proof precheck failed: missing root update.`);
8489
+ expect(
8490
+ Array.isArray(transition.update.currentRootVector) && transition.update.currentRootVector.length > 0,
8491
+ `${operationName} proof precheck failed: missing current root vector.`,
8492
+ );
8493
+ expect(
8494
+ normalizeBytes32Hex(hashRootVector(transition.update.currentRootVector))
8495
+ === normalizeBytes32Hex(hashRootVector(context.currentSnapshot.stateRoots)),
8496
+ `${operationName} proof precheck failed: root update does not match the local workspace snapshot.`,
8497
+ );
8498
+ normalizeBytes32Hex(transition.update.updatedRoot);
8499
+ normalizeBytes32Hex(transition.update.currentUserKey);
8500
+ normalizeBytes32Hex(transition.update.updatedUserKey);
8501
+ expect(transition.nextSnapshot?.stateRoots, `${operationName} proof precheck failed: missing next snapshot roots.`);
8502
+ }
8503
+
8504
+ function precheckTokamakExecution({ context, payload, functionProof, aPubBlockHash, operationName }) {
8505
+ expect(payload && typeof payload === "object", `${operationName} proof precheck failed: missing Tokamak payload.`);
8506
+ for (const key of [
8507
+ "proofPart1",
8508
+ "proofPart2",
8509
+ "functionPreprocessPart1",
8510
+ "functionPreprocessPart2",
8511
+ "aPubUser",
8512
+ "aPubBlock",
8513
+ ]) {
8514
+ expect(Array.isArray(payload[key]) && payload[key].length > 0, `${operationName} proof precheck failed: payload.${key} is missing.`);
8515
+ }
8516
+ expect(functionProof?.metadata, `${operationName} proof precheck failed: missing function metadata proof.`);
8517
+ expect(Array.isArray(functionProof?.siblings), `${operationName} proof precheck failed: missing function metadata proof siblings.`);
8518
+ expect(
8519
+ ethers.toBigInt(normalizeBytes32Hex(aPubBlockHash))
8520
+ === ethers.toBigInt(normalizeBytes32Hex(context.workspace.aPubBlockHash)),
8521
+ `${operationName} proof precheck failed: generated aPubBlockHash does not match the channel aPubBlockHash.`,
8522
+ );
8523
+ }
8524
+
8525
+ function contractTxCall(contractMethod, args = [], overrides = undefined, contractInterface = null) {
8526
+ expect(typeof contractMethod === "function", "Internal error: contract transaction method must be callable.");
8527
+ expect(
8528
+ typeof contractMethod.staticCall === "function",
8529
+ "Internal error: contract transaction method must support staticCall.",
8530
+ );
8531
+ const finalArgs = overrides === undefined ? [...args] : [...args, overrides];
8532
+ return {
8533
+ contractInterface,
8534
+ dryRun: () => contractMethod.staticCall(...finalArgs),
8535
+ submit: () => contractMethod(...finalArgs),
8536
+ };
8537
+ }
8538
+
8539
+ async function dryRunThenSubmitTransaction({
8392
8540
  operationName,
8393
- submit,
8541
+ call,
8542
+ precheck = null,
8543
+ context = null,
8544
+ walletName = null,
8545
+ operationDir = null,
8546
+ submittedBefore = [],
8394
8547
  }) {
8548
+ expect(typeof call?.dryRun === "function", "Internal error: transaction dry-run callback is required.");
8549
+ expect(typeof call?.submit === "function", "Internal error: transaction submit callback is required.");
8550
+ if (precheck) {
8551
+ await precheck();
8552
+ }
8395
8553
  try {
8396
- return await waitForReceipt(await submit());
8554
+ await call.dryRun();
8397
8555
  } catch (error) {
8398
- if (isUnexpectedCurrentRootVectorError(error, context)) {
8399
- throw staleChannelRootError({
8400
- cause: error,
8401
- context,
8402
- walletName,
8403
- operationName,
8404
- });
8556
+ throw transactionPreflightOrSubmitError({
8557
+ phase: "dry-run",
8558
+ operationName,
8559
+ cause: error,
8560
+ context,
8561
+ walletName,
8562
+ operationDir,
8563
+ submittedBefore,
8564
+ contractInterface: call.contractInterface,
8565
+ });
8566
+ }
8567
+ try {
8568
+ return await waitForReceipt(await call.submit());
8569
+ } catch (error) {
8570
+ throw transactionPreflightOrSubmitError({
8571
+ phase: "submit",
8572
+ operationName,
8573
+ cause: error,
8574
+ context,
8575
+ walletName,
8576
+ operationDir,
8577
+ submittedBefore,
8578
+ contractInterface: call.contractInterface,
8579
+ });
8580
+ }
8581
+ }
8582
+
8583
+ function transactionPreflightOrSubmitError({
8584
+ phase,
8585
+ operationName,
8586
+ cause,
8587
+ context = null,
8588
+ walletName = null,
8589
+ operationDir = null,
8590
+ submittedBefore = [],
8591
+ contractInterface = null,
8592
+ }) {
8593
+ if (context && isUnexpectedCurrentRootVectorError(cause, context)) {
8594
+ return staleChannelRootError({
8595
+ cause,
8596
+ context,
8597
+ walletName,
8598
+ operationName,
8599
+ phase,
8600
+ });
8601
+ }
8602
+ const decodedError = decodeTransactionContractError(cause, [
8603
+ contractInterface,
8604
+ context?.channelManager?.interface,
8605
+ context?.bridgeTokenVault?.interface,
8606
+ ]);
8607
+ const details = [
8608
+ phase === "dry-run"
8609
+ ? `${operationName} pre-submit dry-run failed. No ${operationName} transaction was submitted.`
8610
+ : `${operationName} transaction submission failed.`,
8611
+ ];
8612
+ const submitted = normalizeSubmittedBefore(submittedBefore);
8613
+ if (submitted.length > 0) {
8614
+ details.push(`Already submitted before this failure: ${submitted.join("; ")}.`);
8615
+ }
8616
+ if (walletName) {
8617
+ details.push(`Wallet: ${walletName}.`);
8618
+ }
8619
+ if (operationDir) {
8620
+ details.push(`Operation directory: ${operationDir}.`);
8621
+ }
8622
+ if (decodedError) {
8623
+ details.push(`Decoded contract error: ${decodedError}.`);
8624
+ }
8625
+ details.push(`Provider error: ${extractProviderErrorMessage(cause)}.`);
8626
+ const error = cliError(
8627
+ phase === "dry-run" ? CLI_ERROR_CODES.TX_DRY_RUN_FAILED : CLI_ERROR_CODES.TX_SUBMIT_FAILED,
8628
+ details.join(" "),
8629
+ { cause },
8630
+ );
8631
+ error.phase = phase;
8632
+ error.operationName = operationName;
8633
+ error.transactionSubmitted = phase !== "dry-run";
8634
+ error.submittedBefore = submitted;
8635
+ error.walletName = walletName;
8636
+ error.operationDir = operationDir;
8637
+ error.decodedContractError = decodedError;
8638
+ error.providerError = extractProviderErrorMessage(cause);
8639
+ if (context) {
8640
+ error.channelName = context.workspace?.channelName;
8641
+ error.networkName = context.workspace?.network;
8642
+ }
8643
+ return error;
8644
+ }
8645
+
8646
+ function submittedReceiptSummary(label, receipt) {
8647
+ return `${label} tx ${receipt?.hash ?? "<unknown>"} in block ${receipt?.blockNumber ?? "<unknown>"}`;
8648
+ }
8649
+
8650
+ function normalizeSubmittedBefore(entries) {
8651
+ return entries
8652
+ .filter(Boolean)
8653
+ .map((entry) => {
8654
+ if (typeof entry === "string") {
8655
+ return entry;
8656
+ }
8657
+ if (entry?.hash) {
8658
+ return submittedReceiptSummary(entry.label ?? "transaction", entry);
8659
+ }
8660
+ return String(entry);
8661
+ });
8662
+ }
8663
+
8664
+ function decodeTransactionContractError(error, contractInterfaces) {
8665
+ if (error?.revert?.name) {
8666
+ return formatDecodedContractError(error.revert.name, error.revert.args ?? []);
8667
+ }
8668
+ for (const contractInterface of contractInterfaces.filter(Boolean)) {
8669
+ for (const errorData of extractContractErrorDataCandidates(error)) {
8670
+ try {
8671
+ const parsed = contractInterface.parseError(errorData);
8672
+ if (parsed) {
8673
+ return formatDecodedContractError(parsed.name, parsed.args ?? []);
8674
+ }
8675
+ } catch {
8676
+ // Keep scanning provider error payloads and interfaces.
8677
+ }
8405
8678
  }
8406
- throw error;
8407
8679
  }
8680
+ return null;
8681
+ }
8682
+
8683
+ function formatDecodedContractError(name, args) {
8684
+ const renderedArgs = Array.from(args ?? [])
8685
+ .map((value) => serializeBigInts(normalizeCliOutputValue(value, [])));
8686
+ return `${name}(${renderedArgs.map((value) => JSON.stringify(value)).join(", ")})`;
8687
+ }
8688
+
8689
+ function extractProviderErrorMessage(error) {
8690
+ return String(
8691
+ error?.shortMessage
8692
+ ?? error?.reason
8693
+ ?? error?.info?.error?.message
8694
+ ?? error?.error?.message
8695
+ ?? error?.message
8696
+ ?? error,
8697
+ );
8408
8698
  }
8409
8699
 
8410
8700
  function staleChannelRootError({
@@ -8412,17 +8702,24 @@ function staleChannelRootError({
8412
8702
  context,
8413
8703
  walletName,
8414
8704
  operationName,
8705
+ phase = "submit",
8415
8706
  }) {
8416
8707
  const message = [
8417
- `${operationName} failed because the submitted proof was generated for an older channel root.`,
8708
+ phase === "dry-run"
8709
+ ? `${operationName} pre-submit dry-run failed because the generated proof targets an older channel root. No ${operationName} transaction was submitted.`
8710
+ : `${operationName} failed because the submitted proof was generated for an older channel root.`,
8418
8711
  "The rejected proof cannot be reused.",
8419
8712
  "Do not change recipients, amounts, note counts, function arity, or split the command as recovery.",
8420
8713
  "Refresh the channel workspace, re-check affected wallet state when the command uses notes, then rerun the original intended command so the CLI regenerates a proof from a fresh snapshot.",
8421
8714
  ].join(" ");
8422
8715
  const error = cliError(CLI_ERROR_CODES.STALE_CHANNEL_ROOT, message, { cause });
8716
+ error.phase = phase;
8717
+ error.operationName = operationName;
8718
+ error.transactionSubmitted = phase !== "dry-run";
8423
8719
  error.channelName = context.workspace.channelName;
8424
8720
  error.networkName = context.workspace.network;
8425
8721
  error.walletName = walletName;
8722
+ error.providerError = extractProviderErrorMessage(cause);
8426
8723
  error.retryPolicy = "recover_workspace_then_regenerate_proof";
8427
8724
  error.semanticMutationAllowed = false;
8428
8725
  error.reuseProofAllowed = false;
@@ -9729,15 +10026,11 @@ function assertVersionArgs(args) {
9729
10026
  }
9730
10027
 
9731
10028
  function printVersion() {
9732
- if (isJsonOutputRequested()) {
9733
- printJson({
9734
- action: "version",
9735
- packageName: privateStateCliPackageJson.name,
9736
- version: privateStateCliPackageJson.version,
9737
- });
9738
- return;
9739
- }
9740
- console.log(privateStateCliPackageJson.version);
10029
+ cliOutput.result({
10030
+ action: "version",
10031
+ packageName: privateStateCliPackageJson.name,
10032
+ version: privateStateCliPackageJson.version,
10033
+ });
9741
10034
  }
9742
10035
 
9743
10036
  function requireWorkspaceName(args) {
@@ -10782,38 +11075,53 @@ function buildWalletViewingKeyMetadata(wallet) {
10782
11075
  }
10783
11076
 
10784
11077
  function printHelp() {
10785
- const commandHelp = PRIVATE_STATE_CLI_COMMANDS.map((command) => [
10786
- ` ${privateStateCliCommandSynopsis(command)}`,
10787
- ` ${command.description}`,
10788
- ...(command.help ?? []).map((line) => ` ${line}`),
10789
- ].join("\n")).join("\n\n");
10790
- console.log(`
10791
- Commands:
10792
- ${commandHelp}
10793
-
10794
- Secret source options:
10795
- Use account import --private-key-file once to create a protected local account secret.
10796
- L1 signing commands use --account only.
10797
- A wallet secret source file is arbitrary high-entropy secret text read once by channel join.
10798
- Create one before joining a channel, for example:
10799
- openssl rand -hex 32 > ./wallet-secret.txt
10800
- private-state-cli channel join --channel-name <NAME> --network <NAME> --account <NAME> --wallet-secret-path ./wallet-secret.txt --acknowledge-action-impact
10801
- Configure each network RPC endpoint once with set rpc. The CLI reads RPC_URL, LOG_CHUNK_SIZE,
10802
- and LOG_REQUESTS_PER_SECOND from ~/tokamak-private-channels/workspace/<network>/rpc-config.env.
10803
- Wallet commands use separate protected viewing-key and spending-key files when those capabilities are needed.
10804
- Source files passed to --private-key-file and --wallet-secret-path are not required to use 0600 permissions, but
10805
- canonical CLI secret files remain protected. On macOS/Linux this means 0600; on Windows the CLI repairs ACLs when possible.
10806
-
10807
- Options:
10808
- --version
10809
- Print the private-state CLI package version and exit.
11078
+ cliOutput.result(buildHelpCommandsResult());
11079
+ }
10810
11080
 
10811
- --json
10812
- Print the command result as JSON. Without --json, commands print human-readable output.
11081
+ function buildHelpCommandsResult() {
11082
+ return {
11083
+ action: "help commands",
11084
+ commands: PRIVATE_STATE_CLI_COMMANDS.map(buildHelpCommandEntry),
11085
+ secretSourceOptions: [
11086
+ "Use account import --private-key-file once to create a protected local account secret.",
11087
+ "L1 signing commands use --account only.",
11088
+ "A wallet secret source file is arbitrary high-entropy secret text read once by channel join.",
11089
+ "Configure each network RPC endpoint once with set rpc.",
11090
+ "Wallet commands use separate protected viewing-key and spending-key files when those capabilities are needed.",
11091
+ "Source files passed to --private-key-file and --wallet-secret-path are not required to use 0600 permissions, but canonical CLI secret files remain protected.",
11092
+ ],
11093
+ globalOptions: [
11094
+ {
11095
+ option: "--version",
11096
+ description: "Print the private-state CLI package version and exit.",
11097
+ },
11098
+ {
11099
+ option: "--json",
11100
+ description: "Print the final success or failure result as JSON on stdout. Progress, warning, and info events are JSONL on stderr.",
11101
+ },
11102
+ {
11103
+ option: "--help",
11104
+ description: "Show this help. Equivalent to help commands.",
11105
+ },
11106
+ ],
11107
+ };
11108
+ }
10813
11109
 
10814
- --help
10815
- Show this help. Equivalent to help commands.
10816
- `);
11110
+ function buildHelpCommandEntry(command) {
11111
+ const requiredFields = privateStateCliCommandRequiredOptionKeys(command);
11112
+ const fields = command.fields ?? [];
11113
+ return {
11114
+ id: command.id,
11115
+ display: privateStateCliCommandDisplay(command),
11116
+ synopsis: privateStateCliCommandSynopsis(command),
11117
+ description: command.description,
11118
+ usage: command.usage,
11119
+ fields,
11120
+ requiredFields,
11121
+ optionalFields: fields.filter((field) => !requiredFields.includes(field)),
11122
+ installMode: privateStateCliCommandInstallMode(command),
11123
+ help: command.help ?? [],
11124
+ };
10817
11125
  }
10818
11126
 
10819
11127
  function readJson(filePath) {
@@ -11337,29 +11645,122 @@ function loadWalletCommandRuntime(args, { prepareArtifacts = false } = {}) {
11337
11645
  }
11338
11646
 
11339
11647
  const HUMAN_RESULT_RENDERERS = Object.freeze({
11648
+ doctor: printDoctorHumanReport,
11340
11649
  guide: printGuideHumanResult,
11650
+ "help commands": printHelpCommandsHumanResult,
11341
11651
  investigator: printInvestigatorHumanResult,
11342
11652
  observer: printObserverHumanResult,
11343
11653
  "transaction-fees": printTransactionFeesHumanResult,
11344
11654
  update: printUpdateHumanResult,
11655
+ version: printVersionHumanResult,
11345
11656
  });
11346
11657
 
11347
11658
  function normalizePrivateKey(value) {
11348
11659
  return value.startsWith("0x") ? value : `0x${value}`;
11349
11660
  }
11350
11661
 
11351
- function printJson(value) {
11352
- const normalized = normalizeCliOutput(value);
11662
+ const cliOutput = Object.freeze({
11663
+ result(value) {
11664
+ const normalized = normalizeCliOutput(value);
11665
+ if (isJsonOutputRequested()) {
11666
+ console.log(JSON.stringify(buildJsonSuccessPayload(normalized), null, 2));
11667
+ return;
11668
+ }
11669
+ const renderer = HUMAN_RESULT_RENDERERS[normalized?.action];
11670
+ if (renderer) {
11671
+ renderer(normalized);
11672
+ return;
11673
+ }
11674
+ printHumanResult(normalized);
11675
+ },
11676
+ error(error, args = {}) {
11677
+ if (isJsonOutputRequested()) {
11678
+ console.log(JSON.stringify(normalizeCliOutput(buildJsonErrorPayload(error, args)), null, 2));
11679
+ return;
11680
+ }
11681
+ console.error(formatHumanError(error, args));
11682
+ },
11683
+ progress(action, phase, details = {}) {
11684
+ emitOutputEvent({
11685
+ event: "progress",
11686
+ action,
11687
+ phase,
11688
+ message: details.message ?? `[${action}] ${phase}`,
11689
+ details,
11690
+ });
11691
+ },
11692
+ warning(kind, message, details = {}) {
11693
+ emitOutputEvent({
11694
+ event: "warning",
11695
+ kind,
11696
+ message,
11697
+ details,
11698
+ });
11699
+ },
11700
+ });
11701
+
11702
+ function buildJsonSuccessPayload(value) {
11703
+ if (value && typeof value === "object" && !Array.isArray(value)) {
11704
+ return Object.hasOwn(value, "ok") ? value : { ok: true, ...value };
11705
+ }
11706
+ return {
11707
+ ok: true,
11708
+ action: "result",
11709
+ value,
11710
+ };
11711
+ }
11712
+
11713
+ function emitOutputEvent(event) {
11714
+ const normalized = normalizeCliOutput({
11715
+ timestamp: new Date().toISOString(),
11716
+ ...event,
11717
+ });
11353
11718
  if (isJsonOutputRequested()) {
11354
- console.log(JSON.stringify(normalized, null, 2));
11719
+ console.error(JSON.stringify(normalized));
11355
11720
  return;
11356
11721
  }
11357
- const renderer = HUMAN_RESULT_RENDERERS[normalized?.action];
11358
- if (renderer) {
11359
- renderer(normalized);
11722
+ const message = event.message ?? `[${event.action ?? event.kind ?? "cli"}] ${event.phase ?? event.event}`;
11723
+ if (event.event === "warning") {
11724
+ console.error(message);
11360
11725
  return;
11361
11726
  }
11362
- printHumanResult(normalized);
11727
+ console.log(message);
11728
+ }
11729
+
11730
+ function printVersionHumanResult(result) {
11731
+ console.log(result.version);
11732
+ }
11733
+
11734
+ function printHelpCommandsHumanResult(help) {
11735
+ const commandHelp = (help.commands ?? []).map((command) => [
11736
+ ` ${command.synopsis}`,
11737
+ ` ${command.description}`,
11738
+ ...(command.help ?? []).map((line) => ` ${line}`),
11739
+ ].join("\n")).join("\n\n");
11740
+ const globalOptions = (help.globalOptions ?? []).map((option) => [
11741
+ ` ${option.option}`,
11742
+ ` ${option.description}`,
11743
+ ].join("\n")).join("\n\n");
11744
+ console.log(`
11745
+ Commands:
11746
+ ${commandHelp}
11747
+
11748
+ Secret source options:
11749
+ Use account import --private-key-file once to create a protected local account secret.
11750
+ L1 signing commands use --account only.
11751
+ A wallet secret source file is arbitrary high-entropy secret text read once by channel join.
11752
+ Create one before joining a channel, for example:
11753
+ openssl rand -hex 32 > ./wallet-secret.txt
11754
+ private-state-cli channel join --channel-name <NAME> --network <NAME> --account <NAME> --wallet-secret-path ./wallet-secret.txt --acknowledge-action-impact
11755
+ Configure each network RPC endpoint once with set rpc. The CLI reads RPC_URL, LOG_CHUNK_SIZE,
11756
+ and LOG_REQUESTS_PER_SECOND from ~/tokamak-private-channels/workspace/<network>/rpc-config.env.
11757
+ Wallet commands use separate protected viewing-key and spending-key files when those capabilities are needed.
11758
+ Source files passed to --private-key-file and --wallet-secret-path are not required to use 0600 permissions, but
11759
+ canonical CLI secret files remain protected. On macOS/Linux this means 0600; on Windows the CLI repairs ACLs when possible.
11760
+
11761
+ Options:
11762
+ ${globalOptions}
11763
+ `);
11363
11764
  }
11364
11765
 
11365
11766
  function printGuideHumanResult(guide) {
@@ -11591,12 +11992,7 @@ function humanizeLabel(value) {
11591
11992
  }
11592
11993
 
11593
11994
  function emitProgress(action, phase) {
11594
- const line = `[${action}] ${phase}`;
11595
- if (isJsonOutputRequested()) {
11596
- console.error(line);
11597
- } else {
11598
- console.log(line);
11599
- }
11995
+ cliOutput.progress(action, phase);
11600
11996
  }
11601
11997
 
11602
11998
  function createByteDownloadProgress({ action, label, url }) {
@@ -11732,7 +12128,7 @@ function createRpcLogScanProgress({ action, label }) {
11732
12128
  };
11733
12129
  }
11734
12130
 
11735
- function formatCliErrorForDisplay(error, args = {}) {
12131
+ function formatHumanError(error, args = {}) {
11736
12132
  const message = String(error?.message ?? error);
11737
12133
  const hints = buildRecoveryHints(error, args);
11738
12134
  if (hints.length === 0) {
@@ -11745,6 +12141,41 @@ function formatCliErrorForDisplay(error, args = {}) {
11745
12141
  ].join("\n");
11746
12142
  }
11747
12143
 
12144
+ function buildJsonErrorPayload(error, args = {}) {
12145
+ const message = String(error?.message ?? error);
12146
+ const hints = buildRecoveryHints(error, args);
12147
+ return {
12148
+ ok: false,
12149
+ action: "error",
12150
+ error: {
12151
+ name: error?.name ?? "Error",
12152
+ code: error?.code ?? "ERROR",
12153
+ command: typeof args.command === "string" ? args.command : null,
12154
+ message,
12155
+ hints,
12156
+ phase: error?.phase ?? null,
12157
+ operationName: error?.operationName ?? null,
12158
+ transactionSubmitted: typeof error?.transactionSubmitted === "boolean"
12159
+ ? error.transactionSubmitted
12160
+ : null,
12161
+ submittedBefore: Array.isArray(error?.submittedBefore) ? error.submittedBefore : [],
12162
+ decodedContractError: error?.decodedContractError ?? null,
12163
+ providerError: error?.providerError ?? null,
12164
+ channelName: error?.channelName ?? (typeof args.channelName === "string" ? args.channelName : null),
12165
+ networkName: error?.networkName ?? (typeof args.network === "string" ? args.network : null),
12166
+ walletName: error?.walletName ?? (typeof args.wallet === "string" ? args.wallet : null),
12167
+ operationDir: error?.operationDir ?? null,
12168
+ retryPolicy: error?.retryPolicy ?? null,
12169
+ semanticMutationAllowed: typeof error?.semanticMutationAllowed === "boolean"
12170
+ ? error.semanticMutationAllowed
12171
+ : null,
12172
+ reuseProofAllowed: typeof error?.reuseProofAllowed === "boolean"
12173
+ ? error.reuseProofAllowed
12174
+ : null,
12175
+ },
12176
+ };
12177
+ }
12178
+
11748
12179
  function buildRecoveryHints(error, args = {}) {
11749
12180
  const message = String(error?.message ?? error);
11750
12181
  const hints = [];
@@ -11802,7 +12233,9 @@ function buildRecoveryHints(error, args = {}) {
11802
12233
  }
11803
12234
 
11804
12235
  if (error?.code === CLI_ERROR_CODES.STALE_WORKSPACE) {
11805
- hints.push(`private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName}`);
12236
+ hints.push(`private-state-cli channel get-meta --channel-name ${channelName} --network ${networkName}`);
12237
+ hints.push(`if workspaceMirror is set: private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName} --source mirror`);
12238
+ hints.push(`otherwise use indexed RPC recovery: private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName}`);
11806
12239
  hints.push(`private-state-cli help guide --network ${networkName} --channel-name ${channelName}`);
11807
12240
  }
11808
12241
 
@@ -11810,7 +12243,9 @@ function buildRecoveryHints(error, args = {}) {
11810
12243
  error?.code === CLI_ERROR_CODES.STALE_CHANNEL_ROOT
11811
12244
  || message.includes("UnexpectedCurrentRootVector")
11812
12245
  ) {
11813
- hints.push(`private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName}`);
12246
+ hints.push(`private-state-cli channel get-meta --channel-name ${channelName} --network ${networkName}`);
12247
+ hints.push(`if workspaceMirror is set: private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName} --source mirror`);
12248
+ hints.push(`otherwise use indexed RPC recovery: private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName}`);
11814
12249
  if (walletName !== "<WALLET>") {
11815
12250
  hints.push(`private-state-cli wallet get-notes --wallet ${walletName} --network ${networkName}`);
11816
12251
  }
@@ -11818,7 +12253,9 @@ function buildRecoveryHints(error, args = {}) {
11818
12253
  }
11819
12254
 
11820
12255
  if (message.includes("Workspace recovery index is missing or unusable")) {
11821
- hints.push(`private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName}`);
12256
+ hints.push(`private-state-cli channel get-meta --channel-name ${channelName} --network ${networkName}`);
12257
+ hints.push(`if workspaceMirror is set: private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName} --source mirror`);
12258
+ hints.push(`only if no compatible workspace mirror is available: private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName} --source rpc --from-genesis`);
11822
12259
  }
11823
12260
 
11824
12261
  if (message.includes("Wallet note recovery index is missing or unusable")) {
@@ -11931,5 +12368,5 @@ export {
11931
12368
  loadExplicitCommandRuntime,
11932
12369
  loadWalletCommandRuntime,
11933
12370
  assertProviderChainIdMatchesNetwork,
11934
- formatCliErrorForDisplay,
12371
+ cliOutput,
11935
12372
  };