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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Added `transaction-fees`, which reads packaged measured gas data from `assets/tx-fees.json`,
6
+ combines it with live RPC fee data and live ETH/USD pricing, and prints a per-command ETH/USD
7
+ fee table.
8
+ - Split `transaction-fees` estimates into typical cost from RPC `gasPrice` and worst-case cost
9
+ from EIP-1559 `maxFeePerGas`.
10
+ - Added optional `--tx-submitter <ACCOUNT>` support to `mint-notes`, `transfer-notes`, and
11
+ `redeem-notes` so proof-backed note owners can separate note ownership from the L1 account
12
+ that submits `executeChannelTransaction` and pays gas.
13
+ - Expanded LLM-agent README guidance so agents explain private key files, local account aliases,
14
+ wallet secret source files, network RPC URLs, and immutable channel policy step by step before
15
+ guiding new users through `join-channel`.
16
+ - Added RPC log scan progress output to `recover-workspace` and `recover-wallet`, with progress
17
+ routed to stderr in `--json` mode so machine-readable command results stay valid.
18
+ - Unified wallet command workspace refresh through the same recovery-indexed path used by
19
+ `recover-workspace`, and shared received-note recovery through the wallet's
20
+ `noteReceiveLastScannedBlock` index.
21
+
22
+ ## 1.0.1 - 2026-05-05
23
+
24
+ - Added global `--version` output for scripts that need the installed private-state CLI package
25
+ version without running `doctor`.
5
26
  - Changed the channel-bound L2 identity derivation signing domain and mode from password wording
6
27
  to wallet-secret wording. Existing local wallets from the pre-1.0.0 cleanup path are not
7
28
  compatibility targets.
package/README.md CHANGED
@@ -51,6 +51,12 @@ Check the installed package and runtime state with:
51
51
  private-state-cli doctor
52
52
  ```
53
53
 
54
+ Print only the installed CLI package version with:
55
+
56
+ ```bash
57
+ private-state-cli --version
58
+ ```
59
+
54
60
  Remove all local private-state CLI data with:
55
61
 
56
62
  ```bash
@@ -80,6 +86,28 @@ A common private-state flow is:
80
86
 
81
87
  Use `private-state-cli --help` for the full command list and required options.
82
88
 
89
+ Estimate live transaction costs before sending commands with:
90
+
91
+ ```bash
92
+ private-state-cli transaction-fees --network mainnet --rpc-url <RPC_URL>
93
+ ```
94
+
95
+ `transaction-fees` uses the measured gas data packaged in `assets/tx-fees.json`, the selected network's live fee data,
96
+ and live ETH/USD pricing to print an ETH/USD fee table for transaction-sending commands. The table separates typical
97
+ cost, based on the RPC `gasPrice`, from worst-case cost, based on `maxFeePerGas` when the network reports EIP-1559 fee
98
+ data.
99
+
100
+ Proof-backed note commands can use a separate L1 transaction submitter:
101
+
102
+ ```bash
103
+ private-state-cli mint-notes --wallet <WALLET> --network mainnet --amounts '[1]' --tx-submitter <ACCOUNT>
104
+ ```
105
+
106
+ `--tx-submitter <ACCOUNT>` is available on `mint-notes`, `transfer-notes`, and `redeem-notes`. The wallet still proves
107
+ note ownership and builds the ZK proof, but the selected local account submits `executeChannelTransaction` and pays gas.
108
+ Use this option when you want stronger privacy by avoiding a direct on-chain link between the note owner's wallet L1
109
+ account and the proof-submission transaction.
110
+
83
111
  Channel policy warning:
84
112
 
85
113
  - `create-channel` commits to an immutable channel policy: verifier bindings, DApp execution metadata, function layout,
@@ -162,6 +190,40 @@ Operating rules:
162
190
 
163
191
  - Do not ask the user to reveal raw private keys or wallet secrets in chat. Use `account import --private-key-file`
164
192
  once, then use `--account` for L1 signing commands. Wallet commands use wallet-local default secret files.
193
+ - Treat `private key file`, `account`, `wallet secret`, `wallet`, `network RPC URL`, and `channel policy` as
194
+ new concepts unless the user has already demonstrated that they understand them. Define each term before using it
195
+ in an instruction.
196
+ - Explain local-secret handling in plain language:
197
+ - A private key file is a local file that contains the user's L1 wallet private key. The CLI reads it once during
198
+ `account import` and stores a protected local account secret.
199
+ - An account is the local nickname created by `account import`. After import, signing commands should use
200
+ `--account <NAME>` instead of asking for the raw key again.
201
+ - A wallet secret source file is a separate high-entropy local secret chosen by the user for this private-state
202
+ wallet. It is not the L1 private key. `join-channel` imports it once and uses it to protect and recover the
203
+ channel-local wallet.
204
+ - A wallet is the encrypted local private-state wallet created during `join-channel`. Its deterministic name is
205
+ `<channelName>-<l1Address>`.
206
+ - The network RPC URL is the endpoint used to read and write chain state. It can be supplied once with `--rpc-url`
207
+ on a bridge-facing command, after which the CLI saves it under the selected network.
208
+ - When the user does not have a network RPC URL yet, explain that they need an Ethereum JSON-RPC endpoint for the
209
+ selected network. They can obtain one from an infrastructure provider such as Alchemy, Infura, QuickNode, or from
210
+ their own node. Ask the user to create or select the endpoint in that provider's UI, then paste only the endpoint URL
211
+ into the CLI command that accepts `--rpc-url`; do not ask for provider account passwords, API dashboards, seed phrases,
212
+ private keys, or wallet secrets.
213
+ - When a user wants to join a channel, do not jump straight to `join-channel`. Walk them through:
214
+ 1. choose the network and channel name
215
+ 2. run `private-state-cli install`
216
+ 3. run `private-state-cli doctor`
217
+ 4. obtain or confirm a network RPC URL for the selected network
218
+ 5. prepare a private key source file locally, without pasting the key into chat
219
+ 6. run `account import --account <NAME> --network <NETWORK> --private-key-file <PATH>`
220
+ 7. prepare a wallet secret source file locally, for example with `openssl rand -hex 32 > ./wallet-secret.txt`
221
+ 8. inspect the channel with `get-channel` if it already exists, or create it with `create-channel` if the user is
222
+ the channel creator
223
+ 9. explain the immutable policy warning printed by the CLI
224
+ 10. run `join-channel --channel-name <CHANNEL> --network <NETWORK> --account <ACCOUNT> --wallet-secret-path <PATH>`
225
+ - Before asking the user to create a file, explain what will be inside that file, who should be able to read it, and
226
+ whether losing it prevents wallet recovery.
165
227
  - Prefer testnet examples unless the user explicitly asks for mainnet.
166
228
  - Before any proof-backed or bridge-facing workflow, ask the user to run `private-state-cli doctor` and inspect
167
229
  whether the runtime, Docker mode, CUDA/GPU probes, Groth16 runtime, and deployment artifacts are healthy.
@@ -175,6 +237,9 @@ Operating rules:
175
237
  telling the user to move funds.
176
238
  - Explain that wallet names are local CLI identifiers, while private transfers use notes owned by L2 addresses
177
239
  registered in the channel.
240
+ - Explain `--tx-submitter <ACCOUNT>` when the user wants stronger privacy for `mint-notes`, `transfer-notes`, or
241
+ `redeem-notes`: the wallet owner still proves note ownership, but another imported local L1 account can submit the
242
+ on-chain `executeChannelTransaction` and pay gas.
178
243
  - Before guiding a user through `create-channel` or `join-channel`, explain that channel policy is immutable after
179
244
  creation and that joining a channel means accepting its current verifier, DApp metadata, function layout, managed
180
245
  storage vector, and refund policy.
@@ -198,6 +263,14 @@ Suggested interaction flow:
198
263
  `get-my-wallet-meta`, then build `transfer-notes`.
199
264
  8. After transfer, guide the recipient to run `get-my-notes` to recover received notes from event logs.
200
265
 
266
+ Example onboarding explanation for `join-channel`:
267
+
268
+ > First we need two different local secrets. Your L1 private key proves which Ethereum account pays gas and signs
269
+ > bridge transactions. We import it once into a local account nickname, so later commands can say `--account alice`
270
+ > instead of handling the raw key again. Separately, the wallet secret protects the encrypted private-state wallet for
271
+ > this channel. It is not sent on-chain and it is not the same as your L1 private key. If you lose the wallet secret,
272
+ > recovering this channel wallet can become impossible.
273
+
201
274
  Example style: if the user says, "ADDR6 sends 10 tokens privately to ADDR8", do not assume the required note exists.
202
275
  First ask or check which channel and network to use, whether ADDR6 and ADDR8 are already joined, what the local wallet
203
276
  names are, and whether ADDR6 has an unused note worth exactly 10 or notes that sum to 10. Then provide the next concrete
@@ -0,0 +1,170 @@
1
+ {
2
+ "schema": "tokamak-private-state-cli-tx-fees.v1",
3
+ "measuredAt": "2026-05-05",
4
+ "measurementBasis": "Measured directly from the current repository worktree with forge test --root bridge --gas-report and completed local CLI e2e transaction receipts.",
5
+ "notes": [
6
+ "Gas values are protocol transaction gasUsed values, not a guarantee for every future calldata shape or verifier implementation.",
7
+ "ERC-20 approval transactions are listed as separate approve components when the CLI command sends an approval transaction.",
8
+ "transfer-notes and redeem-notes use the same proof-backed executeChannelTransaction baseline measured from the successful mint-notes CLI e2e receipt because the current e2e run stopped before producing fresh transfer/redeem receipts."
9
+ ],
10
+ "commands": [
11
+ {
12
+ "command": "create-channel",
13
+ "description": "Create a bridge channel and initialize its policy snapshot.",
14
+ "transactions": [
15
+ {
16
+ "label": "createChannel",
17
+ "contract": "BridgeCore",
18
+ "function": "createChannel",
19
+ "gasUsed": 2736229,
20
+ "source": "forge-gas-report",
21
+ "sourceDetail": "BridgeCore.createChannel max successful path from forge test --root bridge --gas-report."
22
+ }
23
+ ]
24
+ },
25
+ {
26
+ "command": "deposit-bridge",
27
+ "description": "Deposit canonical tokens into the shared bridge vault.",
28
+ "transactions": [
29
+ {
30
+ "label": "approve",
31
+ "contract": "MockERC20",
32
+ "function": "approve",
33
+ "gasUsed": 45729,
34
+ "source": "cast-local-measurement",
35
+ "sourceDetail": "Measured on local anvil by deploying bridge/src/mocks/MockERC20.sol and sending approve(address,uint256)."
36
+ },
37
+ {
38
+ "label": "fund",
39
+ "contract": "L1TokenVault",
40
+ "function": "fund",
41
+ "gasUsed": 68343,
42
+ "source": "forge-gas-report",
43
+ "sourceDetail": "L1TokenVault.fund from forge test --root bridge --gas-report."
44
+ }
45
+ ]
46
+ },
47
+ {
48
+ "command": "withdraw-bridge",
49
+ "description": "Withdraw canonical tokens from the shared bridge vault.",
50
+ "transactions": [
51
+ {
52
+ "label": "claimToWallet",
53
+ "contract": "L1TokenVault",
54
+ "function": "claimToWallet",
55
+ "gasUsed": 33824,
56
+ "source": "forge-gas-report",
57
+ "sourceDetail": "L1TokenVault.claimToWallet from forge test --root bridge --gas-report."
58
+ }
59
+ ]
60
+ },
61
+ {
62
+ "command": "join-channel",
63
+ "description": "Pay the channel join toll and register a wallet identity.",
64
+ "transactions": [
65
+ {
66
+ "label": "approve",
67
+ "contract": "MockERC20",
68
+ "function": "approve",
69
+ "gasUsed": 45729,
70
+ "source": "cast-local-measurement",
71
+ "sourceDetail": "Measured on local anvil by deploying bridge/src/mocks/MockERC20.sol and sending approve(address,uint256). join-channel sends this approval only when the channel join toll is nonzero."
72
+ },
73
+ {
74
+ "label": "joinChannel",
75
+ "contract": "L1TokenVault",
76
+ "function": "joinChannel",
77
+ "gasUsed": 341775,
78
+ "source": "forge-gas-report",
79
+ "sourceDetail": "L1TokenVault.joinChannel max successful path from forge test --root bridge --gas-report."
80
+ }
81
+ ]
82
+ },
83
+ {
84
+ "command": "deposit-channel",
85
+ "description": "Move bridged funds into the channel L2 accounting vault.",
86
+ "transactions": [
87
+ {
88
+ "label": "depositToChannelVault",
89
+ "contract": "L1TokenVault",
90
+ "function": "depositToChannelVault",
91
+ "gasUsed": 336496,
92
+ "gasUsedMin": 336464,
93
+ "gasUsedMax": 336496,
94
+ "source": "cli-e2e-receipts",
95
+ "sourceDetail": "Three completed local CLI e2e deposit-channel receipts from the current worktree."
96
+ }
97
+ ]
98
+ },
99
+ {
100
+ "command": "withdraw-channel",
101
+ "description": "Move channel L2 accounting vault funds back into the shared bridge vault.",
102
+ "transactions": [
103
+ {
104
+ "label": "withdrawFromChannelVault",
105
+ "contract": "L1TokenVault",
106
+ "function": "withdrawFromChannelVault",
107
+ "gasUsed": 95861,
108
+ "source": "forge-gas-report",
109
+ "sourceDetail": "L1TokenVault.withdrawFromChannelVault max successful path from forge test --root bridge --gas-report."
110
+ }
111
+ ]
112
+ },
113
+ {
114
+ "command": "exit-channel",
115
+ "description": "Exit a channel after the channel balance is zero.",
116
+ "transactions": [
117
+ {
118
+ "label": "exitChannel",
119
+ "contract": "L1TokenVault",
120
+ "function": "exitChannel",
121
+ "gasUsed": 119031,
122
+ "source": "forge-gas-report",
123
+ "sourceDetail": "L1TokenVault.exitChannel max successful path from forge test --root bridge --gas-report."
124
+ }
125
+ ]
126
+ },
127
+ {
128
+ "command": "mint-notes",
129
+ "description": "Submit a proof-backed private-state mint transaction.",
130
+ "transactions": [
131
+ {
132
+ "label": "executeChannelTransaction",
133
+ "contract": "ChannelManager",
134
+ "function": "executeChannelTransaction",
135
+ "gasUsed": 861627,
136
+ "source": "cli-e2e-receipt",
137
+ "sourceDetail": "Completed local CLI e2e mint-notes bridge-submit-receipt.json from the current worktree."
138
+ }
139
+ ]
140
+ },
141
+ {
142
+ "command": "transfer-notes",
143
+ "description": "Submit a proof-backed private-state transfer transaction.",
144
+ "transactions": [
145
+ {
146
+ "label": "executeChannelTransaction",
147
+ "contract": "ChannelManager",
148
+ "function": "executeChannelTransaction",
149
+ "gasUsed": 861627,
150
+ "source": "cli-e2e-baseline",
151
+ "sourceDetail": "Uses the current successful mint-notes executeChannelTransaction receipt as the shared proof-backed DApp command baseline."
152
+ }
153
+ ]
154
+ },
155
+ {
156
+ "command": "redeem-notes",
157
+ "description": "Submit a proof-backed private-state redeem transaction.",
158
+ "transactions": [
159
+ {
160
+ "label": "executeChannelTransaction",
161
+ "contract": "ChannelManager",
162
+ "function": "executeChannelTransaction",
163
+ "gasUsed": 861627,
164
+ "source": "cli-e2e-baseline",
165
+ "sourceDetail": "Uses the current successful mint-notes executeChannelTransaction receipt as the shared proof-backed DApp command baseline."
166
+ }
167
+ ]
168
+ }
169
+ ]
170
+ }
@@ -29,6 +29,15 @@ export const PRIVATE_STATE_CLI_FIELD_CATALOG = Object.freeze({
29
29
  valueLabel: "<NAME>",
30
30
  option: "--account",
31
31
  },
32
+ txSubmitter: {
33
+ label: "Transaction Submitter",
34
+ type: "text",
35
+ placeholder: "relayer-account",
36
+ valueLabel: "<ACCOUNT>",
37
+ hint: "Optional for proof-backed note commands. Uses a separate local L1 account to submit executeChannelTransaction.",
38
+ option: "--tx-submitter",
39
+ optional: true,
40
+ },
32
41
  privateKeyFile: {
33
42
  label: "Private Key File",
34
43
  type: "text",
@@ -176,6 +185,16 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
176
185
  usage: "optional --network, --channel-name, --account, and --wallet",
177
186
  help: ["Does not accept --rpc-url and never writes RPC configuration"],
178
187
  },
188
+ {
189
+ id: "transaction-fees",
190
+ description: "Estimate ETH and USD fees for transaction-sending commands from packaged measured gas data and live network fee data.",
191
+ fields: ["network", "rpcUrl", "json"],
192
+ usage: "--network, optional --rpc-url, and optional --json",
193
+ help: [
194
+ "Uses packages/apps/private-state/cli/assets/tx-fees.json as the measured gas source packaged with the CLI",
195
+ "Reads live fee data from the selected network RPC and live ETH/USD from CoinGecko",
196
+ ],
197
+ },
179
198
  {
180
199
  id: "account-import",
181
200
  display: "account import",
@@ -198,6 +217,7 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
198
217
  help: [
199
218
  "By default, resumes RPC log scanning from the workspace recovery index when available",
200
219
  "Use --from-genesis to ignore the recovery index and replay logs from channel genesis",
220
+ "Prints RPC log scan progress while rebuilding the workspace",
201
221
  ],
202
222
  },
203
223
  {
@@ -232,6 +252,7 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
232
252
  help: [
233
253
  "Requires the protected wallet-local secret imported during join-channel to exist at the canonical secret path",
234
254
  "Does not create or recover the wallet secret itself",
255
+ "Prints RPC log scan progress while rebuilding channel state and received-note state",
235
256
  ],
236
257
  },
237
258
  {
@@ -290,20 +311,23 @@ export const PRIVATE_STATE_CLI_COMMANDS = Object.freeze([
290
311
  {
291
312
  id: "mint-notes",
292
313
  description: "Mint one or two private-state notes from the wallet's channel balance.",
293
- fields: ["wallet", "network", "amounts"],
294
- usage: "--wallet, --network, and --amounts",
314
+ fields: ["wallet", "network", "amounts", "txSubmitter"],
315
+ usage: "--wallet, --network, --amounts, and optional --tx-submitter",
316
+ help: ["Use --tx-submitter <ACCOUNT> to let a separate local L1 account pay gas for stronger transaction privacy"],
295
317
  },
296
318
  {
297
319
  id: "transfer-notes",
298
320
  description: "Spend input notes into the registered 1->1, 1->2, or 2->1 private transfer shapes.",
299
- fields: ["wallet", "network", "noteIds", "recipients", "amounts"],
300
- usage: "--wallet, --network, --note-ids, --recipients, and --amounts",
321
+ fields: ["wallet", "network", "noteIds", "recipients", "amounts", "txSubmitter"],
322
+ usage: "--wallet, --network, --note-ids, --recipients, --amounts, and optional --tx-submitter",
323
+ help: ["Use --tx-submitter <ACCOUNT> to let a separate local L1 account pay gas for stronger transaction privacy"],
301
324
  },
302
325
  {
303
326
  id: "redeem-notes",
304
327
  description: "Redeem one tracked note back into the wallet's channel balance.",
305
- fields: ["wallet", "network", "noteIds"],
306
- usage: "--wallet, --network, and --note-ids",
328
+ fields: ["wallet", "network", "noteIds", "txSubmitter"],
329
+ usage: "--wallet, --network, --note-ids, and optional --tx-submitter",
330
+ help: ["Use --tx-submitter <ACCOUNT> to let a separate local L1 account pay gas for stronger transaction privacy"],
307
331
  },
308
332
  {
309
333
  id: "get-my-notes",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamak-private-dapps/private-state-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Command-line client for the Tokamak private-state DApp.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": "Tokamak Network",
@@ -30,6 +30,7 @@
30
30
  "LICENSE",
31
31
  "private-state-bridge-cli.mjs",
32
32
  "cli-assistant.html",
33
+ "assets",
33
34
  "lib"
34
35
  ],
35
36
  "scripts": {
@@ -120,6 +120,7 @@ import {
120
120
 
121
121
  const require = createRequire(import.meta.url);
122
122
  const defaultCommandCwd = process.cwd();
123
+ const privateStateCliPackageJson = require("./package.json");
123
124
  const privateStateCliPackageRoot = path.dirname(require.resolve("./package.json"));
124
125
  const workspaceRoot = path.resolve(os.homedir(), "tokamak-private-channels", "workspace");
125
126
  const secretRoot = path.resolve(os.homedir(), "tokamak-private-channels", "secrets");
@@ -314,6 +315,12 @@ async function main() {
314
315
  activeCliArgs = args;
315
316
  configureOutput(args);
316
317
 
318
+ if (args.version !== undefined) {
319
+ assertVersionArgs(args);
320
+ printVersion();
321
+ return;
322
+ }
323
+
317
324
  if (args.help || !args.command) {
318
325
  printHelp();
319
326
  return;
@@ -343,6 +350,13 @@ async function main() {
343
350
  return;
344
351
  }
345
352
 
353
+ if (args.command === "transaction-fees") {
354
+ assertTransactionFeesArgs(args);
355
+ const { network, provider, rpcUrl } = loadExplicitCommandRuntime(args);
356
+ await handleTransactionFees({ network, provider, rpcUrl });
357
+ return;
358
+ }
359
+
346
360
  if (args.command === "get-my-l1-address") {
347
361
  assertGetMyL1AddressArgs(args);
348
362
  handleGetMyL1Address({ args });
@@ -622,6 +636,7 @@ async function handleWorkspaceInit({ args, network, provider }) {
622
636
  allowExistingWorkspaceSync: true,
623
637
  useWorkspaceRecoveryIndex: true,
624
638
  fromGenesis: args.fromGenesis === true,
639
+ progressAction: "recover-workspace",
625
640
  });
626
641
 
627
642
  printJson({
@@ -729,6 +744,7 @@ async function initializeChannelWorkspace({
729
744
  allowExistingWorkspaceSync = false,
730
745
  useWorkspaceRecoveryIndex = false,
731
746
  fromGenesis = false,
747
+ progressAction = null,
732
748
  }) {
733
749
  const workspaceDir = channelWorkspacePath(networkNameFromChainId(network.chainId), workspaceName);
734
750
  const channelDir = channelDataPath(workspaceDir);
@@ -831,6 +847,7 @@ async function initializeChannelWorkspace({
831
847
  baseSnapshot: recoveryIndex?.stateSnapshot ?? null,
832
848
  fromBlock: recoveryIndex?.nextBlock ?? genesisBlockNumber,
833
849
  toBlock: latestBlock,
850
+ progressAction,
834
851
  });
835
852
  const currentSnapshot = reconstruction.currentSnapshot;
836
853
  const recoveryRootVectorHash = normalizeBytes32Hex(hashRootVector(currentSnapshot.stateRoots));
@@ -975,6 +992,8 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
975
992
  bridgeResources,
976
993
  persist: true,
977
994
  allowExistingWorkspaceSync: true,
995
+ useWorkspaceRecoveryIndex: true,
996
+ progressAction: "recover-wallet",
978
997
  });
979
998
  const context = {
980
999
  workspaceName: channelName,
@@ -1056,6 +1075,14 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
1056
1075
  });
1057
1076
 
1058
1077
  if (existingWallet) {
1078
+ const { recoveredDeliveryState } = await recoverWalletReceivedNotes({
1079
+ walletContext: existingWallet,
1080
+ context,
1081
+ provider,
1082
+ signer,
1083
+ noteReceiveKeyMaterial,
1084
+ progressAction: "recover-wallet",
1085
+ });
1059
1086
  printJson({
1060
1087
  action: "recover-wallet",
1061
1088
  status: "already-recovered",
@@ -1071,6 +1098,10 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
1071
1098
  l2StorageKey: storageKey,
1072
1099
  leafIndex: registration.leafIndex.toString(),
1073
1100
  noteReceivePubKey: noteReceiveKeyMaterial.noteReceivePubKey,
1101
+ l2Nonce: existingWallet.wallet.l2Nonce,
1102
+ recoveredFromLogs: recoveredDeliveryState.importedNotes,
1103
+ scannedDeliveryLogs: recoveredDeliveryState.scannedLogs,
1104
+ noteReceiveScanRange: recoveredDeliveryState.scanRange,
1074
1105
  });
1075
1106
  return;
1076
1107
  }
@@ -1091,11 +1122,13 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
1091
1122
  walletContext.wallet.l2Nonce = 0;
1092
1123
  persistWallet(walletContext);
1093
1124
 
1094
- const recoveredDeliveryState = await recoverDeliveredNotesFromEventLogs({
1125
+ const { recoveredDeliveryState } = await recoverWalletReceivedNotes({
1095
1126
  walletContext,
1096
1127
  context,
1097
1128
  provider,
1098
- noteReceivePrivateKey: noteReceiveKeyMaterial.privateKey,
1129
+ signer,
1130
+ noteReceiveKeyMaterial,
1131
+ progressAction: "recover-wallet",
1099
1132
  });
1100
1133
 
1101
1134
  printJson({
@@ -1437,6 +1470,144 @@ async function handleDoctor({ args }) {
1437
1470
  }
1438
1471
  }
1439
1472
 
1473
+ async function handleTransactionFees({ network, provider, rpcUrl }) {
1474
+ const feeAsset = loadTransactionFeeAsset();
1475
+ const feeData = await provider.getFeeData();
1476
+ const gasPrices = requireTransactionFeeGasPrices(feeData);
1477
+ const ethUsd = await fetchEthUsdPrice();
1478
+ const rows = buildTransactionFeeRows({
1479
+ commands: feeAsset.commands,
1480
+ typicalGasPriceWei: gasPrices.typical,
1481
+ worstCaseGasPriceWei: gasPrices.worstCase,
1482
+ ethUsd,
1483
+ });
1484
+
1485
+ printJson({
1486
+ action: "transaction-fees",
1487
+ generatedAt: new Date().toISOString(),
1488
+ network: network.name,
1489
+ chainId: Number(network.chainId),
1490
+ rpcUrl,
1491
+ asset: {
1492
+ schema: feeAsset.schema,
1493
+ measuredAt: feeAsset.measuredAt,
1494
+ measurementBasis: feeAsset.measurementBasis,
1495
+ notes: feeAsset.notes,
1496
+ },
1497
+ livePricing: {
1498
+ typicalGasPriceWei: gasPrices.typical.toString(),
1499
+ typicalGasPriceGwei: formatGwei(gasPrices.typical),
1500
+ typicalGasPriceSource: gasPrices.typicalSource,
1501
+ worstCaseGasPriceWei: gasPrices.worstCase.toString(),
1502
+ worstCaseGasPriceGwei: formatGwei(gasPrices.worstCase),
1503
+ worstCaseGasPriceSource: gasPrices.worstCaseSource,
1504
+ maxFeePerGasWei: feeData.maxFeePerGas?.toString() ?? null,
1505
+ maxPriorityFeePerGasWei: feeData.maxPriorityFeePerGas?.toString() ?? null,
1506
+ gasPriceWei: feeData.gasPrice?.toString() ?? null,
1507
+ ethUsd,
1508
+ ethUsdSource: "CoinGecko simple price API",
1509
+ },
1510
+ rows,
1511
+ });
1512
+ }
1513
+
1514
+ function loadTransactionFeeAsset() {
1515
+ const assetPath = path.join(privateStateCliPackageRoot, "assets", "tx-fees.json");
1516
+ const asset = readJson(assetPath);
1517
+ expect(asset.schema === "tokamak-private-state-cli-tx-fees.v1", `Unsupported transaction fee asset schema: ${assetPath}`);
1518
+ expect(Array.isArray(asset.commands), `Transaction fee asset is missing commands: ${assetPath}`);
1519
+ return asset;
1520
+ }
1521
+
1522
+ function requireTransactionFeeGasPrices(feeData) {
1523
+ const typical = feeData.gasPrice ?? feeData.maxFeePerGas;
1524
+ const worstCase = feeData.maxFeePerGas ?? feeData.gasPrice;
1525
+ if (typical === null || typical === undefined || worstCase === null || worstCase === undefined) {
1526
+ throw new Error("RPC provider did not return gasPrice or maxFeePerGas.");
1527
+ }
1528
+ return {
1529
+ typical: ethers.toBigInt(typical),
1530
+ typicalSource: feeData.gasPrice ? "gasPrice" : "maxFeePerGas",
1531
+ worstCase: ethers.toBigInt(worstCase),
1532
+ worstCaseSource: feeData.maxFeePerGas ? "maxFeePerGas" : "gasPrice",
1533
+ };
1534
+ }
1535
+
1536
+ async function fetchEthUsdPrice() {
1537
+ const url = "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd";
1538
+ const response = await fetch(url, {
1539
+ headers: {
1540
+ accept: "application/json",
1541
+ "user-agent": `${privateStateCliPackageJson.name}/${privateStateCliPackageJson.version}`,
1542
+ },
1543
+ });
1544
+ if (!response.ok) {
1545
+ throw new Error(`Unable to fetch live ETH/USD price from CoinGecko: HTTP ${response.status}.`);
1546
+ }
1547
+ const payload = await response.json();
1548
+ const value = Number(payload?.ethereum?.usd);
1549
+ if (!Number.isFinite(value) || value <= 0) {
1550
+ throw new Error("CoinGecko response did not include a valid ethereum.usd price.");
1551
+ }
1552
+ return value;
1553
+ }
1554
+
1555
+ function buildTransactionFeeRows({ commands, typicalGasPriceWei, worstCaseGasPriceWei, ethUsd }) {
1556
+ return commands.map((entry) => {
1557
+ const transactions = expectTransactionFeeTransactions(entry);
1558
+ const gasUsed = transactions.reduce((sum, transaction) => sum + Number(transaction.gasUsed), 0);
1559
+ const typicalEth = ethers.formatEther(BigInt(gasUsed) * typicalGasPriceWei);
1560
+ const worstCaseEth = ethers.formatEther(BigInt(gasUsed) * worstCaseGasPriceWei);
1561
+ return {
1562
+ command: entry.command,
1563
+ description: entry.description,
1564
+ transactions: transactions.map((transaction) => transaction.label).join(" + "),
1565
+ gasUsed,
1566
+ typicalGasPriceGwei: formatGwei(typicalGasPriceWei),
1567
+ typicalEth: formatEthForDisplay(typicalEth),
1568
+ typicalUsd: formatUsdForDisplay(Number(typicalEth) * ethUsd),
1569
+ worstCaseGasPriceGwei: formatGwei(worstCaseGasPriceWei),
1570
+ worstCaseEth: formatEthForDisplay(worstCaseEth),
1571
+ worstCaseUsd: formatUsdForDisplay(Number(worstCaseEth) * ethUsd),
1572
+ sources: [...new Set(transactions.map((transaction) => transaction.source))].join(", "),
1573
+ sourceDetails: transactions.map((transaction) => transaction.sourceDetail),
1574
+ };
1575
+ });
1576
+ }
1577
+
1578
+ function expectTransactionFeeTransactions(entry) {
1579
+ expect(typeof entry?.command === "string" && entry.command.length > 0, "Transaction fee asset contains a command without command name.");
1580
+ expect(Array.isArray(entry.transactions) && entry.transactions.length > 0, `Transaction fee asset command ${entry.command} has no transactions.`);
1581
+ for (const transaction of entry.transactions) {
1582
+ expect(Number.isInteger(transaction.gasUsed) && transaction.gasUsed > 0, `Transaction fee asset command ${entry.command} has invalid gasUsed.`);
1583
+ }
1584
+ return entry.transactions;
1585
+ }
1586
+
1587
+ function formatGwei(wei) {
1588
+ return trimFixedNumber(ethers.formatUnits(wei, "gwei"), 6);
1589
+ }
1590
+
1591
+ function formatEthForDisplay(value) {
1592
+ return trimFixedNumber(value, 8);
1593
+ }
1594
+
1595
+ function formatUsdForDisplay(value) {
1596
+ if (value > 0 && value < 0.01) {
1597
+ return "<0.01";
1598
+ }
1599
+ return value.toFixed(2);
1600
+ }
1601
+
1602
+ function trimFixedNumber(value, maxDecimals) {
1603
+ const [integer, decimals = ""] = String(value).split(".");
1604
+ if (!decimals || maxDecimals <= 0) {
1605
+ return integer;
1606
+ }
1607
+ const trimmed = decimals.slice(0, maxDecimals).replace(/0+$/u, "");
1608
+ return trimmed ? `${integer}.${trimmed}` : integer;
1609
+ }
1610
+
1440
1611
  function handleGetMyL1Address({ args }) {
1441
1612
  const signer = requireL1Signer(args);
1442
1613
  printJson({
@@ -1513,6 +1684,7 @@ async function handleGuide({ args }) {
1513
1684
  nextSafeAction: null,
1514
1685
  why: null,
1515
1686
  candidateCommands: [],
1687
+ 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
1688
  };
1517
1689
 
1518
1690
  guide.state.local = inspectGuideLocalState(args);
@@ -2006,18 +2178,18 @@ function applyGuideNextAction(guide) {
2006
2178
  }
2007
2179
  if (guide.state.wallet?.exists && channelBalance !== null && channelBalance > 0n && unusedNotes === 0) {
2008
2180
  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.",
2181
+ command: `mint-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --amounts <A,B> [--tx-submitter <ACCOUNT>]`,
2182
+ why: "The wallet has channel L2 balance and no unused private notes yet. Use --tx-submitter for stronger transaction-submission privacy.",
2011
2183
  });
2012
2184
  return;
2013
2185
  }
2014
2186
  if (guide.state.wallet?.exists && unusedNotes !== null && unusedNotes > 0) {
2015
2187
  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.",
2188
+ command: `transfer-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID,ID> --recipients <ADDR,ADDR> --amounts <A,B> [--tx-submitter <ACCOUNT>]`,
2189
+ why: "The wallet has unused private notes. It can transfer or redeem those notes. Use --tx-submitter for stronger transaction-submission privacy.",
2018
2190
  candidates: [
2019
2191
  `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>`,
2192
+ `redeem-notes --wallet ${guide.selectors.wallet} --network ${guide.selectors.network} --note-ids <ID> [--tx-submitter <ACCOUNT>]`,
2021
2193
  ],
2022
2194
  });
2023
2195
  return;
@@ -2128,9 +2300,13 @@ async function handleGetMyWalletMeta({ args, provider }) {
2128
2300
  });
2129
2301
  }
2130
2302
 
2131
- async function loadWalletChannelFundState({ walletContext, provider }) {
2303
+ async function loadWalletChannelFundState({ walletContext, provider, progressAction = null }) {
2132
2304
  const { signer, l2Identity } = restoreWalletParticipant(walletContext, provider);
2133
- const contextResult = await loadPreferredWalletChannelContext({ walletContext, provider });
2305
+ const contextResult = await loadPreferredWalletChannelContext({
2306
+ walletContext,
2307
+ provider,
2308
+ progressAction,
2309
+ });
2134
2310
  const context = contextResult.context;
2135
2311
  const registration = await context.channelManager.getChannelTokenVaultRegistration(signer.address);
2136
2312
  expect(
@@ -2357,7 +2533,11 @@ async function handleGrothVaultMove({ args, provider, direction }) {
2357
2533
  : "withdraw";
2358
2534
  emitProgress(operationName, "loading");
2359
2535
  const { wallet: walletContext } = loadUnlockedWalletWithMetadata(args);
2360
- const contextResult = await loadPreferredWalletChannelContext({ walletContext, provider });
2536
+ const contextResult = await loadPreferredWalletChannelContext({
2537
+ walletContext,
2538
+ provider,
2539
+ progressAction: operationName,
2540
+ });
2361
2541
  const context = contextResult.context;
2362
2542
  const network = contextResult.network;
2363
2543
  expect(
@@ -2403,6 +2583,7 @@ async function handleGrothVaultMove({ args, provider, direction }) {
2403
2583
  "The derived L2 address does not match the registered channel L2 address.",
2404
2584
  );
2405
2585
 
2586
+ await assertWorkspaceAlignedWithChain(context);
2406
2587
  const stateManager = await buildStateManager(context.currentSnapshot, context.contractCodes);
2407
2588
  const keyHex = storageKey;
2408
2589
  const currentValue = await currentStorageBigInt(stateManager, context.workspace.l2AccountingVault, keyHex);
@@ -2578,6 +2759,7 @@ async function handleMintNotes({ args, provider }) {
2578
2759
  const { channelFund } = await loadWalletChannelFundState({
2579
2760
  walletContext: wallet,
2580
2761
  provider,
2762
+ progressAction: "mint-notes",
2581
2763
  });
2582
2764
  expect(
2583
2765
  totalMintAmount <= channelFund,
@@ -2591,6 +2773,7 @@ async function handleMintNotes({ args, provider }) {
2591
2773
  baseUnitAmounts: baseUnitAmounts.map(({ amountBaseUnits }) => amountBaseUnits),
2592
2774
  });
2593
2775
  const { execution, contextResult, recoveredWorkspace } = await executeWalletDirectTemplateCommand({
2776
+ args,
2594
2777
  wallet,
2595
2778
  provider,
2596
2779
  operationName: "mint-notes",
@@ -2602,7 +2785,10 @@ async function handleMintNotes({ args, provider }) {
2602
2785
  wallet: wallet.walletName,
2603
2786
  workspace: execution.context.workspaceName,
2604
2787
  operationDir: execution.operationDir,
2605
- l1Submitter: execution.signer.address,
2788
+ l1Submitter: execution.txSubmitter.address,
2789
+ l1WalletOwner: execution.signer.address,
2790
+ txSubmitterSource: execution.txSubmitterSource,
2791
+ txSubmitterAccount: execution.txSubmitterAccount,
2606
2792
  l2Address: execution.l2Identity.l2Address,
2607
2793
  underlyingMethod: templatePayload.method,
2608
2794
  nonce: execution.nonce,
@@ -2631,6 +2817,7 @@ async function handleRedeemNotes({ args, provider }) {
2631
2817
  inputNotes,
2632
2818
  });
2633
2819
  const { execution, contextResult, recoveredWorkspace } = await executeWalletDirectTemplateCommand({
2820
+ args,
2634
2821
  wallet,
2635
2822
  provider,
2636
2823
  operationName: "redeem-notes",
@@ -2642,7 +2829,10 @@ async function handleRedeemNotes({ args, provider }) {
2642
2829
  wallet: wallet.walletName,
2643
2830
  workspace: execution.context.workspaceName,
2644
2831
  operationDir: execution.operationDir,
2645
- l1Submitter: execution.signer.address,
2832
+ l1Submitter: execution.txSubmitter.address,
2833
+ l1WalletOwner: execution.signer.address,
2834
+ txSubmitterSource: execution.txSubmitterSource,
2835
+ txSubmitterAccount: execution.txSubmitterAccount,
2646
2836
  l2Address: execution.l2Identity.l2Address,
2647
2837
  receiver: wallet.wallet.l2Address,
2648
2838
  underlyingMethod: templatePayload.method,
@@ -2669,20 +2859,18 @@ async function handleGetMyNotes({ args, provider }) {
2669
2859
  `Wallet ${wallet.walletName} is missing the stored controller address.`,
2670
2860
  );
2671
2861
  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,
2862
+ const { context } = await loadPreferredWalletChannelContext({
2863
+ walletContext: wallet,
2864
+ provider,
2865
+ progressAction: "get-my-notes",
2680
2866
  });
2681
- const recoveredDeliveryState = await recoverDeliveredNotesFromEventLogs({
2867
+ const signer = restoreWalletSigner(wallet, provider);
2868
+ const { recoveredDeliveryState } = await recoverWalletReceivedNotes({
2682
2869
  walletContext: wallet,
2683
2870
  context,
2684
2871
  provider,
2685
- noteReceivePrivateKey: noteReceiveKeyMaterial.privateKey,
2872
+ signer,
2873
+ progressAction: "get-my-notes",
2686
2874
  });
2687
2875
 
2688
2876
  const unusedTrackedNotes = wallet.wallet.notes.unusedOrder
@@ -2728,7 +2916,11 @@ async function handleGetMyNotes({ args, provider }) {
2728
2916
  async function handleTransferNotes({ args, provider }) {
2729
2917
  const { wallet } = loadUnlockedWalletWithMetadata(args);
2730
2918
  const { signer } = restoreWalletParticipant(wallet, provider);
2731
- const preparedContextResult = await loadPreferredWalletChannelContext({ walletContext: wallet, provider });
2919
+ const preparedContextResult = await loadPreferredWalletChannelContext({
2920
+ walletContext: wallet,
2921
+ provider,
2922
+ progressAction: "transfer-notes",
2923
+ });
2732
2924
  const context = preparedContextResult.context;
2733
2925
  const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
2734
2926
  const noteIds = parseNoteIdVector(requireArg(args.noteIds, "--note-ids"));
@@ -2760,6 +2952,7 @@ async function handleTransferNotes({ args, provider }) {
2760
2952
  outputAmounts,
2761
2953
  });
2762
2954
  const { execution, contextResult, recoveredWorkspace } = await executeWalletDirectTemplateCommand({
2955
+ args,
2763
2956
  wallet,
2764
2957
  provider,
2765
2958
  operationName: "transfer-notes",
@@ -2777,7 +2970,10 @@ async function handleTransferNotes({ args, provider }) {
2777
2970
  wallet: wallet.walletName,
2778
2971
  workspace: execution.context.workspaceName,
2779
2972
  operationDir: execution.operationDir,
2780
- l1Submitter: execution.signer.address,
2973
+ l1Submitter: execution.txSubmitter.address,
2974
+ l1WalletOwner: execution.signer.address,
2975
+ txSubmitterSource: execution.txSubmitterSource,
2976
+ txSubmitterAccount: execution.txSubmitterAccount,
2781
2977
  l2Address: execution.l2Identity.l2Address,
2782
2978
  underlyingMethod: templatePayload.method,
2783
2979
  nonce: execution.nonce,
@@ -3092,11 +3288,40 @@ function buildLifecycleTrackedOutputs({
3092
3288
  }));
3093
3289
  }
3094
3290
 
3291
+ async function recoverWalletReceivedNotes({
3292
+ walletContext,
3293
+ context,
3294
+ provider,
3295
+ signer,
3296
+ noteReceiveKeyMaterial = null,
3297
+ progressAction = null,
3298
+ }) {
3299
+ const resolvedNoteReceiveKeyMaterial = noteReceiveKeyMaterial ?? await deriveNoteReceiveKeyMaterial({
3300
+ signer,
3301
+ chainId: context.workspace.chainId,
3302
+ channelId: context.workspace.channelId,
3303
+ channelName: context.workspace.channelName,
3304
+ account: signer.address,
3305
+ });
3306
+ const recoveredDeliveryState = await recoverDeliveredNotesFromEventLogs({
3307
+ walletContext,
3308
+ context,
3309
+ provider,
3310
+ noteReceivePrivateKey: resolvedNoteReceiveKeyMaterial.privateKey,
3311
+ progressAction,
3312
+ });
3313
+ return {
3314
+ noteReceiveKeyMaterial: resolvedNoteReceiveKeyMaterial,
3315
+ recoveredDeliveryState,
3316
+ };
3317
+ }
3318
+
3095
3319
  async function recoverDeliveredNotesFromEventLogs({
3096
3320
  walletContext,
3097
3321
  context,
3098
3322
  provider,
3099
3323
  noteReceivePrivateKey,
3324
+ progressAction = null,
3100
3325
  }) {
3101
3326
  const scanStartBlock = Math.max(
3102
3327
  Number(walletContext.wallet.noteReceiveLastScannedBlock),
@@ -3128,6 +3353,9 @@ async function recoverDeliveredNotesFromEventLogs({
3128
3353
  topics: [NOTE_VALUE_ENCRYPTED_TOPIC],
3129
3354
  fromBlock: scanStartBlock,
3130
3355
  toBlock: latestBlock,
3356
+ onProgress: progressAction
3357
+ ? createRpcLogScanProgress({ action: progressAction, label: "note-delivery events" })
3358
+ : null,
3131
3359
  });
3132
3360
 
3133
3361
  const importedCandidates = [];
@@ -3666,10 +3894,15 @@ function walletChannelWorkspaceIsReady(walletContext) {
3666
3894
  && fs.existsSync(path.join(channelWorkspaceCurrentPath(workspaceDir), "contract_codes.json"));
3667
3895
  }
3668
3896
 
3669
- async function loadPreferredWalletChannelContext({ walletContext, provider, forceRecover = false }) {
3897
+ async function loadPreferredWalletChannelContext({
3898
+ walletContext,
3899
+ provider,
3900
+ forceRecover = false,
3901
+ progressAction = null,
3902
+ }) {
3670
3903
  let recoveredWorkspace = false;
3671
3904
  if (forceRecover || !walletChannelWorkspaceIsReady(walletContext)) {
3672
- await recoverWalletChannelWorkspace({ walletContext, provider });
3905
+ await recoverWalletChannelWorkspace({ walletContext, provider, progressAction });
3673
3906
  recoveredWorkspace = true;
3674
3907
  }
3675
3908
  let context = await loadWorkspaceContext(walletContext.wallet.channelName, walletContext.wallet.network, provider);
@@ -3679,7 +3912,7 @@ async function loadPreferredWalletChannelContext({ walletContext, provider, forc
3679
3912
  if (!isRecoverableWalletWorkspaceFailure(error)) {
3680
3913
  throw error;
3681
3914
  }
3682
- await recoverWalletChannelWorkspace({ walletContext, provider });
3915
+ await recoverWalletChannelWorkspace({ walletContext, provider, progressAction });
3683
3916
  recoveredWorkspace = true;
3684
3917
  context = await loadWorkspaceContext(walletContext.wallet.channelName, walletContext.wallet.network, provider);
3685
3918
  await assertWorkspaceAlignedWithChain(context);
@@ -3692,7 +3925,7 @@ async function loadPreferredWalletChannelContext({ walletContext, provider, forc
3692
3925
  };
3693
3926
  }
3694
3927
 
3695
- async function recoverWalletChannelWorkspace({ walletContext, provider }) {
3928
+ async function recoverWalletChannelWorkspace({ walletContext, provider, progressAction = null }) {
3696
3929
  const networkName = walletContext.wallet.network ?? networkNameFromChainId(Number(walletContext.wallet.chainId));
3697
3930
  const network = resolveCliNetwork(networkName);
3698
3931
  const bridgeResources = loadBridgeResources({ chainId: network.chainId });
@@ -3704,6 +3937,8 @@ async function recoverWalletChannelWorkspace({ walletContext, provider }) {
3704
3937
  bridgeResources,
3705
3938
  persist: true,
3706
3939
  allowExistingWorkspaceSync: true,
3940
+ useWorkspaceRecoveryIndex: true,
3941
+ progressAction,
3707
3942
  });
3708
3943
  }
3709
3944
 
@@ -3725,6 +3960,7 @@ function assertWalletMatchesChannelContext(walletContext, l2Identity, context) {
3725
3960
  }
3726
3961
 
3727
3962
  async function executeWalletDirectTemplateCommand({
3963
+ args,
3728
3964
  wallet,
3729
3965
  provider,
3730
3966
  operationName,
@@ -3732,13 +3968,29 @@ async function executeWalletDirectTemplateCommand({
3732
3968
  }) {
3733
3969
  emitProgress(operationName, "loading");
3734
3970
  const { signer, l2Identity } = restoreWalletParticipant(wallet, provider);
3735
- let contextResult = await loadPreferredWalletChannelContext({ walletContext: wallet, provider });
3971
+ const {
3972
+ txSubmitter,
3973
+ source: txSubmitterSource,
3974
+ account: txSubmitterAccount,
3975
+ } = resolveTxSubmitterSigner({
3976
+ args,
3977
+ ownerSigner: signer,
3978
+ provider,
3979
+ });
3980
+ let contextResult = await loadPreferredWalletChannelContext({
3981
+ walletContext: wallet,
3982
+ provider,
3983
+ progressAction: operationName,
3984
+ });
3736
3985
  let recoveredWorkspace = contextResult.recoveredWorkspace;
3737
3986
 
3738
3987
  try {
3739
3988
  const execution = await executeWalletTemplateSend({
3740
3989
  wallet,
3741
3990
  signer,
3991
+ txSubmitter,
3992
+ txSubmitterSource,
3993
+ txSubmitterAccount,
3742
3994
  l2Identity,
3743
3995
  context: contextResult.context,
3744
3996
  operationName,
@@ -3761,11 +4013,15 @@ async function executeWalletDirectTemplateCommand({
3761
4013
  walletContext: wallet,
3762
4014
  provider,
3763
4015
  forceRecover: true,
4016
+ progressAction: operationName,
3764
4017
  });
3765
4018
  recoveredWorkspace = contextResult.recoveredWorkspace;
3766
4019
  const execution = await executeWalletTemplateSend({
3767
4020
  wallet,
3768
4021
  signer,
4022
+ txSubmitter,
4023
+ txSubmitterSource,
4024
+ txSubmitterAccount,
3769
4025
  l2Identity,
3770
4026
  context: contextResult.context,
3771
4027
  operationName,
@@ -3783,6 +4039,9 @@ async function executeWalletDirectTemplateCommand({
3783
4039
  async function executeWalletTemplateSend({
3784
4040
  wallet,
3785
4041
  signer,
4042
+ txSubmitter,
4043
+ txSubmitterSource,
4044
+ txSubmitterAccount,
3786
4045
  l2Identity,
3787
4046
  context,
3788
4047
  operationName,
@@ -3860,7 +4119,7 @@ async function executeWalletTemplateSend({
3860
4119
  emitProgress(operationName, "submitting");
3861
4120
  const receipt =
3862
4121
  await waitForReceipt(
3863
- await context.channelManager.connect(signer).executeChannelTransaction(payload, functionProof),
4122
+ await context.channelManager.connect(txSubmitter).executeChannelTransaction(payload, functionProof),
3864
4123
  );
3865
4124
 
3866
4125
  const onchainRootVectorHash = normalizeBytes32Hex(await context.channelManager.currentRootVectorHash());
@@ -3884,6 +4143,9 @@ async function executeWalletTemplateSend({
3884
4143
  return {
3885
4144
  wallet,
3886
4145
  signer,
4146
+ txSubmitter,
4147
+ txSubmitterSource,
4148
+ txSubmitterAccount,
3887
4149
  l2Identity,
3888
4150
  context,
3889
4151
  noteLifecycle,
@@ -4732,6 +4994,7 @@ async function reconstructChannelSnapshot({
4732
4994
  baseSnapshot = null,
4733
4995
  fromBlock = genesisBlockNumber,
4734
4996
  toBlock = null,
4997
+ progressAction = null,
4735
4998
  }) {
4736
4999
  let startingSnapshot = baseSnapshot;
4737
5000
  if (!startingSnapshot) {
@@ -4763,6 +5026,9 @@ async function reconstructChannelSnapshot({
4763
5026
  ]],
4764
5027
  fromBlock: scanFromBlock,
4765
5028
  toBlock: latestBlock,
5029
+ onProgress: progressAction
5030
+ ? createRpcLogScanProgress({ action: progressAction, label: "channel-manager events" })
5031
+ : null,
4766
5032
  });
4767
5033
  const channelManagerEvents = channelManagerLogs.map((log) => {
4768
5034
  const topic0 = log.topics[0] ? normalizeBytes32Hex(log.topics[0]) : null;
@@ -4781,6 +5047,9 @@ async function reconstructChannelSnapshot({
4781
5047
  eventName: "StorageWriteObserved",
4782
5048
  fromBlock: scanFromBlock,
4783
5049
  toBlock: latestBlock,
5050
+ onProgress: progressAction
5051
+ ? createRpcLogScanProgress({ action: progressAction, label: "bridge-vault events" })
5052
+ : null,
4784
5053
  });
4785
5054
 
4786
5055
  const groupedEvents = new Map();
@@ -4900,15 +5169,34 @@ async function fetchLogsChunked(provider, {
4900
5169
  fromBlock,
4901
5170
  toBlock,
4902
5171
  initialChunkSize = DEFAULT_LOG_CHUNK_SIZE,
5172
+ onProgress = null,
4903
5173
  }) {
4904
5174
  const normalizedFromBlock = Number(fromBlock);
4905
5175
  const resolvedToBlock = toBlock === "latest" ? await provider.getBlockNumber() : Number(toBlock);
4906
5176
  const aggregatedLogs = [];
4907
5177
 
4908
5178
  if (normalizedFromBlock > resolvedToBlock) {
5179
+ onProgress?.({
5180
+ status: "skipped",
5181
+ fromBlock: normalizedFromBlock,
5182
+ toBlock: resolvedToBlock,
5183
+ scannedBlocks: 0,
5184
+ totalBlocks: 0,
5185
+ logsFound: 0,
5186
+ });
4909
5187
  return aggregatedLogs;
4910
5188
  }
4911
5189
 
5190
+ const totalBlocks = resolvedToBlock - normalizedFromBlock + 1;
5191
+ onProgress?.({
5192
+ status: "start",
5193
+ fromBlock: normalizedFromBlock,
5194
+ toBlock: resolvedToBlock,
5195
+ scannedBlocks: 0,
5196
+ totalBlocks,
5197
+ logsFound: 0,
5198
+ });
5199
+
4912
5200
  let chunkSize = Math.max(1, Number(initialChunkSize));
4913
5201
  let cursor = normalizedFromBlock;
4914
5202
  while (cursor <= resolvedToBlock) {
@@ -4922,6 +5210,17 @@ async function fetchLogsChunked(provider, {
4922
5210
  toBlock: chunkToBlock,
4923
5211
  });
4924
5212
  aggregatedLogs.push(...logs);
5213
+ onProgress?.({
5214
+ status: "progress",
5215
+ fromBlock: normalizedFromBlock,
5216
+ toBlock: resolvedToBlock,
5217
+ chunkFromBlock: cursor,
5218
+ chunkToBlock,
5219
+ scannedBlocks: chunkToBlock - normalizedFromBlock + 1,
5220
+ totalBlocks,
5221
+ logsFound: aggregatedLogs.length,
5222
+ chunkLogs: logs.length,
5223
+ });
4925
5224
  cursor = chunkToBlock + 1;
4926
5225
  } catch (error) {
4927
5226
  if (isRateLimitError(error)) {
@@ -4938,6 +5237,15 @@ async function fetchLogsChunked(provider, {
4938
5237
  }
4939
5238
  }
4940
5239
 
5240
+ onProgress?.({
5241
+ status: "done",
5242
+ fromBlock: normalizedFromBlock,
5243
+ toBlock: resolvedToBlock,
5244
+ scannedBlocks: totalBlocks,
5245
+ totalBlocks,
5246
+ logsFound: aggregatedLogs.length,
5247
+ });
5248
+
4941
5249
  return aggregatedLogs;
4942
5250
  }
4943
5251
 
@@ -4995,6 +5303,7 @@ async function queryContractEventsChunked({
4995
5303
  eventName,
4996
5304
  fromBlock,
4997
5305
  toBlock,
5306
+ onProgress = null,
4998
5307
  }) {
4999
5308
  const eventFragment = contract.interface.getEvent(eventName);
5000
5309
  const eventTopic = contract.interface.getEvent(eventName).topicHash;
@@ -5006,6 +5315,7 @@ async function queryContractEventsChunked({
5006
5315
  topics: [eventTopic],
5007
5316
  fromBlock,
5008
5317
  toBlock,
5318
+ onProgress,
5009
5319
  });
5010
5320
 
5011
5321
  return logs.map((log) => {
@@ -5278,6 +5588,33 @@ function requireArg(value, label) {
5278
5588
  return value;
5279
5589
  }
5280
5590
 
5591
+ function assertVersionArgs(args) {
5592
+ if (args.version !== true) {
5593
+ throw new Error("--version does not accept a value.");
5594
+ }
5595
+ if (args.command) {
5596
+ throw new Error("--version must be used without a command.");
5597
+ }
5598
+ const allowedKeys = new Set(["version", "json"]);
5599
+ const unknownKeys = Object.keys(args)
5600
+ .filter((key) => key !== "positional" && key !== "command" && !allowedKeys.has(key));
5601
+ if (unknownKeys.length > 0) {
5602
+ throw new Error(`Unsupported --version option(s): ${unknownKeys.map(toKebabCase).join(", ")}.`);
5603
+ }
5604
+ }
5605
+
5606
+ function printVersion() {
5607
+ if (isJsonOutputRequested()) {
5608
+ printJson({
5609
+ action: "version",
5610
+ packageName: privateStateCliPackageJson.name,
5611
+ version: privateStateCliPackageJson.version,
5612
+ });
5613
+ return;
5614
+ }
5615
+ console.log(privateStateCliPackageJson.version);
5616
+ }
5617
+
5281
5618
  function requireWorkspaceName(args) {
5282
5619
  const value = typeof args === "string" ? args : args.workspace;
5283
5620
  if (!value) {
@@ -5306,6 +5643,29 @@ function requireL1Signer(args, provider) {
5306
5643
  return new Wallet(resolvePrivateKeySource(args), provider);
5307
5644
  }
5308
5645
 
5646
+ function resolveTxSubmitterSigner({ args, ownerSigner, provider }) {
5647
+ if (args.txSubmitter === undefined) {
5648
+ return {
5649
+ txSubmitter: ownerSigner,
5650
+ source: "wallet-owner",
5651
+ account: null,
5652
+ };
5653
+ }
5654
+ if (args.txSubmitter === true || String(args.txSubmitter).trim() === "") {
5655
+ throw new Error("--tx-submitter requires a local account name.");
5656
+ }
5657
+ const networkName = requireNetworkName(args);
5658
+ const account = String(args.txSubmitter).trim();
5659
+ return {
5660
+ txSubmitter: new Wallet(
5661
+ normalizePrivateKey(readSecretFile(accountPrivateKeyPath(networkName, account), "--tx-submitter")),
5662
+ provider,
5663
+ ),
5664
+ source: "tx-submitter-account",
5665
+ account,
5666
+ };
5667
+ }
5668
+
5309
5669
  function resolvePrivateKeySource(args) {
5310
5670
  const networkName = requireNetworkName(args);
5311
5671
  const account = requireAccountName(args);
@@ -5616,12 +5976,17 @@ function assertGuideArgs(args) {
5616
5976
  assertAllowedCommandSchema(args, "guide");
5617
5977
  }
5618
5978
 
5979
+ function assertTransactionFeesArgs(args) {
5980
+ assertAllowedCommandSchema(args, "transaction-fees");
5981
+ }
5982
+
5619
5983
  function assertAccountImportArgs(args) {
5620
5984
  assertAllowedCommandSchema(args, "account-import");
5621
5985
  }
5622
5986
 
5623
5987
  function assertMintNotesArgs(args) {
5624
5988
  assertAllowedCommandSchema(args, "mint-notes");
5989
+ assertTxSubmitterArg(args);
5625
5990
  parseAmountVector(args.amounts, {
5626
5991
  allowZeroEntries: true,
5627
5992
  requireAnyPositive: true,
@@ -5630,11 +5995,13 @@ function assertMintNotesArgs(args) {
5630
5995
 
5631
5996
  function assertRedeemNotesArgs(args) {
5632
5997
  assertAllowedCommandSchema(args, "redeem-notes");
5998
+ assertTxSubmitterArg(args);
5633
5999
  selectRedeemNotesMethod(parseNoteIdVector(args.noteIds).length);
5634
6000
  }
5635
6001
 
5636
6002
  function assertTransferNotesArgs(args) {
5637
6003
  assertAllowedCommandSchema(args, "transfer-notes");
6004
+ assertTxSubmitterArg(args);
5638
6005
  const noteIds = parseNoteIdVector(args.noteIds);
5639
6006
  const recipients = parseRecipientVector(args.recipients);
5640
6007
  const amounts = parseAmountVector(args.amounts);
@@ -5645,6 +6012,15 @@ function assertTransferNotesArgs(args) {
5645
6012
  selectTransferNotesMethod(noteIds.length, recipients.length);
5646
6013
  }
5647
6014
 
6015
+ function assertTxSubmitterArg(args) {
6016
+ if (args.txSubmitter === undefined) {
6017
+ return;
6018
+ }
6019
+ if (args.txSubmitter === true || String(args.txSubmitter).trim() === "") {
6020
+ throw new Error("--tx-submitter requires a local account name.");
6021
+ }
6022
+ }
6023
+
5648
6024
  function assertGetMyNotesArgs(args) {
5649
6025
  assertWalletSecretArgs(args, "get-my-notes");
5650
6026
  }
@@ -5769,6 +6145,9 @@ Secret source options:
5769
6145
  canonical CLI secret files remain protected. On macOS/Linux this means 0600; on Windows the CLI repairs ACLs when possible.
5770
6146
 
5771
6147
  Options:
6148
+ --version
6149
+ Print the private-state CLI package version and exit.
6150
+
5772
6151
  --json
5773
6152
  Print the command result as JSON. Without --json, commands print human-readable output.
5774
6153
 
@@ -6197,6 +6576,7 @@ function loadWalletCommandRuntime(args) {
6197
6576
 
6198
6577
  const HUMAN_RESULT_RENDERERS = Object.freeze({
6199
6578
  guide: printGuideHumanResult,
6579
+ "transaction-fees": printTransactionFeesHumanResult,
6200
6580
  });
6201
6581
 
6202
6582
  function normalizePrivateKey(value) {
@@ -6246,9 +6626,61 @@ function printGuideHumanResult(guide) {
6246
6626
  }
6247
6627
 
6248
6628
  lines.push("", "Run with --json to inspect the full guide state.");
6629
+ lines.push(
6630
+ "",
6631
+ "Privacy Tip",
6632
+ formatHumanValue(guide.privacyTip),
6633
+ );
6249
6634
  console.log(lines.join("\n"));
6250
6635
  }
6251
6636
 
6637
+ function printTransactionFeesHumanResult(report) {
6638
+ const lines = [
6639
+ "Transaction Fees",
6640
+ `Generated: ${formatHumanValue(report.generatedAt)}`,
6641
+ `Network: ${formatHumanValue(report.network)} (${formatHumanValue(report.chainId)})`,
6642
+ `Typical gas price: ${formatHumanValue(report.livePricing?.typicalGasPriceGwei)} gwei (${formatHumanValue(report.livePricing?.typicalGasPriceSource)})`,
6643
+ `Worst-case gas price: ${formatHumanValue(report.livePricing?.worstCaseGasPriceGwei)} gwei (${formatHumanValue(report.livePricing?.worstCaseGasPriceSource)})`,
6644
+ `ETH/USD: $${formatHumanValue(report.livePricing?.ethUsd)} (${formatHumanValue(report.livePricing?.ethUsdSource)})`,
6645
+ `Measured gas asset: ${formatHumanValue(report.asset?.schema)}, measured ${formatHumanValue(report.asset?.measuredAt)}`,
6646
+ "",
6647
+ formatHumanTable(
6648
+ ["Command", "Transactions", "Gas", "Typical ETH", "Typical USD", "Worst ETH", "Worst USD", "Source"],
6649
+ (report.rows ?? []).map((row) => [
6650
+ row.command,
6651
+ row.transactions,
6652
+ String(row.gasUsed),
6653
+ row.typicalEth,
6654
+ `$${row.typicalUsd}`,
6655
+ row.worstCaseEth,
6656
+ `$${row.worstCaseUsd}`,
6657
+ row.sources,
6658
+ ]),
6659
+ ),
6660
+ ];
6661
+ if (Array.isArray(report.asset?.notes) && report.asset.notes.length > 0) {
6662
+ lines.push(
6663
+ "",
6664
+ "Notes",
6665
+ ...report.asset.notes.map((note) => `- ${note}`),
6666
+ );
6667
+ }
6668
+ console.log(lines.join("\n"));
6669
+ }
6670
+
6671
+ function formatHumanTable(headers, rows) {
6672
+ const values = [headers, ...rows].map((row) => row.map((value) => String(value ?? "")));
6673
+ const widths = headers.map((_header, columnIndex) =>
6674
+ Math.max(...values.map((row) => row[columnIndex].length)),
6675
+ );
6676
+ const formatRow = (row) => `| ${row.map((value, index) => value.padEnd(widths[index])).join(" | ")} |`;
6677
+ return [
6678
+ formatRow(values[0]),
6679
+ formatRow(widths.map((width) => "-".repeat(width))),
6680
+ ...values.slice(1).map(formatRow),
6681
+ ].join("\n");
6682
+ }
6683
+
6252
6684
  function formatGuideSelector(value) {
6253
6685
  return value === null || value === undefined || value === "" ? "not selected" : String(value);
6254
6686
  }
@@ -6327,6 +6759,51 @@ function emitProgress(action, phase) {
6327
6759
  }
6328
6760
  }
6329
6761
 
6762
+ function createRpcLogScanProgress({ action, label }) {
6763
+ let lastBucket = -1;
6764
+ return (event) => {
6765
+ const totalBlocks = Number(event.totalBlocks ?? 0);
6766
+ const scannedBlocks = Number(event.scannedBlocks ?? 0);
6767
+ const logsFound = Number(event.logsFound ?? 0);
6768
+ if (event.status === "skipped") {
6769
+ emitProgress(action, `rpc-log-scan ${label}: skipped (no blocks to scan, ${logsFound} logs)`);
6770
+ return;
6771
+ }
6772
+ if (event.status === "start") {
6773
+ lastBucket = 0;
6774
+ emitProgress(
6775
+ action,
6776
+ `rpc-log-scan ${label}: 0% (0/${totalBlocks} blocks, ${logsFound} logs, blocks ${event.fromBlock}-${event.toBlock})`,
6777
+ );
6778
+ return;
6779
+ }
6780
+ if (event.status === "done") {
6781
+ emitProgress(
6782
+ action,
6783
+ `rpc-log-scan ${label}: 100% (${totalBlocks}/${totalBlocks} blocks, ${logsFound} logs, done)`,
6784
+ );
6785
+ return;
6786
+ }
6787
+ if (event.status !== "progress" || totalBlocks <= 0) {
6788
+ return;
6789
+ }
6790
+
6791
+ const percent = Math.min(100, Math.floor((scannedBlocks * 100) / totalBlocks));
6792
+ if (percent >= 100) {
6793
+ return;
6794
+ }
6795
+ const bucket = Math.floor(percent / 10) * 10;
6796
+ if (bucket <= lastBucket) {
6797
+ return;
6798
+ }
6799
+ lastBucket = bucket;
6800
+ emitProgress(
6801
+ action,
6802
+ `rpc-log-scan ${label}: ${percent}% (${scannedBlocks}/${totalBlocks} blocks, ${logsFound} logs)`,
6803
+ );
6804
+ };
6805
+ }
6806
+
6330
6807
  function formatCliErrorForDisplay(error, args = {}) {
6331
6808
  const message = String(error?.message ?? error);
6332
6809
  const hints = buildRecoveryHints(error, args);