From 189e00b8d39743cb362907e3ddd3c4a85d849358 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=CE=BA=CE=B1=CF=83=CF=83=CE=AC=CE=BD=CE=B4=CF=81=CE=B1=2Ee?=
=?UTF-8?q?th?= <0xDADA@protonmail.com>
Date: Sat, 29 Aug 2026 13:00:25 +0200
Subject: [PATCH 1/3] feat: enhance shield command
---
CHANGELOG.md | 7 +
README.md | 9 +-
package-lock.json | 4 +-
package.json | 2 +-
src/commands/shield.ts | 441 ++++++++++++++++++++++++++++--------
src/utils/shield-max.ts | 108 +++++++++
src/utils/tornado-pools.ts | 18 +-
tests/shield-max.test.ts | 183 +++++++++++++++
tests/tornado-pools.test.ts | 8 +
9 files changed, 678 insertions(+), 102 deletions(-)
create mode 100644 src/utils/shield-max.ts
create mode 100644 tests/shield-max.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 016add8..9502a92 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## [0.0.5] — 2026-08-29
+
+### Added
+
+- `--skip-sim` on `shield` dry-runs so counterfactual (unfunded) senders still print call payloads. Cannot be combined with `--broadcast`. `--non-interactive` JSON schema is unchanged; `fees` are zeroed.
+- `--amount-max` on `shield`: spend the account's maximum (ETH minus estimated gas; ERC-20 full balance). Tornado amounts are floored to the smallest pool denomination, then refined if gas would push the deposit below a step.
+
## [0.0.4] — 2026-08-25
### Added
diff --git a/README.md b/README.md
index e896c6c..4621fb5 100644
--- a/README.md
+++ b/README.md
@@ -423,14 +423,16 @@ Move funds from a **public** account into a private protocol.
| `--token
` | Token (default: `eth`). |
| `--amount-wei ` | Amount in base units. |
| `--amount-formatted ` | Human amount (uses token decimals). |
+| `--amount-max` | Shield the maximum spendable amount. ETH: balance minus estimated gas. ERC-20: full token balance (ETH must still cover gas). Tornado: floored to a multiple of the smallest pool denomination; if gas then knocks the amount below a step, the CLI drops one step and rebuilds. Provide at most one of `--amount-wei`, `--amount-formatted`, or `--amount-max`. |
| `--rpc-url ` | RPC endpoint. |
| `--broadcast` | Sign and send on-chain. **Omit** for dry-run (transaction JSON only). |
+| `--skip-sim` | Dry-run only: skip `eth_call` / UserOp simulation and fee estimates. `fees` stay in `--non-interactive` JSON but are zeroed. Cannot be combined with `--broadcast`. Use this for counterfactual senders (no balance) so you can still print payloads for later `--tail-calls`. |
| `--base-fee-gwei`, `--priority-fee-gwei` | Optional fee overrides (reserved; auto fees used today). |
| `--without-tor` | Disable Tor for privacy HTTP (Subsquid / PPOI / saga / ASP / etc.). RPC stays clearnet. Or set `KOHAKU_WITHOUT_TOR=1`. |
-| `--non-interactive` | JSON output; requires `--wallet`, `--password`, `--from`, and an amount flag. |
+| `--non-interactive` | JSON output; requires `--wallet`, `--password`, `--from`, and an amount flag (`--amount-wei`, `--amount-formatted`, or `--amount-max`). |
| `--dataDir ` | Data root. |
-**Interactive (no amount / from flags):** lists public accounts with balances for the token → amount prompt → account picker → dry-run JSON or confirmations with `--broadcast`.
+**Interactive (no amount / from flags):** lists public accounts with balances for the token → amount prompt (or `max`) → account picker → dry-run JSON or confirmations with `--broadcast`. `--amount-max` skips the amount prompt and picks the account first.
**Protocols:**
@@ -444,8 +446,11 @@ When a shield needs more than one on-chain call, the CLI uses EIP-7702 Simple770
```bash
kohaku shield --protocol tornado --wallet testWallet --from 0 --amount-formatted 0.1 --broadcast
+kohaku shield --protocol tornado --wallet testWallet --from 0 --amount-max --broadcast
kohaku shield --protocol railgun --wallet testWallet --from 0 --token 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 --amount-formatted 10 --broadcast
kohaku shield --protocol tornado --wallet testWallet --from 0 --amount-formatted 0.1 --without-tor
+# Counterfactual sender: print payloads without simulating (e.g. to compose unshield --tail-calls)
+kohaku shield --protocol tornado --wallet testWallet --from 1 --amount-formatted 0.1 --skip-sim --non-interactive
```
---
diff --git a/package-lock.json b/package-lock.json
index a0a4db6..7a13ce8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "kohaku-cli",
- "version": "0.0.3",
+ "version": "0.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kohaku-cli",
- "version": "0.0.3",
+ "version": "0.0.5",
"dependencies": {
"@1001-digital/ethereum-names": "0.3.0",
"@clack/prompts": "0.7.0",
diff --git a/package.json b/package.json
index 317a113..24ddd68 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "kohaku-cli",
- "version": "0.0.4",
+ "version": "0.0.5",
"type": "module",
"engines": {
"node": ">=22"
diff --git a/src/commands/shield.ts b/src/commands/shield.ts
index c2e2b4e..79a1f08 100644
--- a/src/commands/shield.ts
+++ b/src/commands/shield.ts
@@ -1,5 +1,5 @@
import { confirm, input, select } from "@inquirer/prompts";
-import { spinner } from "@clack/prompts";
+import { log, spinner } from "@clack/prompts";
import chalk from "chalk";
import type { AssetAmount } from "@kohaku-eth/plugins";
import type { Command } from "commander";
@@ -9,6 +9,7 @@ import { Mnemonic } from "derive-railgun-keys";
import { makeHost } from "../host/makeHost";
import {
buildShieldCallList,
+ formatAccountSelector,
formatPublicAccountBalanceLabel,
listPublicAccountsWithBalance,
partitionShieldTxs,
@@ -16,7 +17,9 @@ import {
shieldTransactionConfirmMessage,
summarizeMultiShieldPlan,
toShieldTxs,
+ type PublicAccountWithBalance,
} from "../lib/shield-flow.js";
+import { parseStealthIndex } from "../lib/stealth/storage.js";
import { cliOptions } from "../utils/cli-command-options";
import {
logCliJson,
@@ -31,6 +34,7 @@ import {
sendEip7702BatchUserOperation,
} from "../utils/eip7702-batch-userop.js";
import {
+ buildFeePreview,
estimateEoaTxFeePreview,
feeConfirmLine,
printFeePreview,
@@ -60,6 +64,7 @@ import {
} from "../utils/sync-progress.js";
import { isFirstProtocolSync } from "../utils/first-sync.js";
import { resolveTokenMeta } from "../utils/tokens-util";
+import type { ResolvedTokenMeta } from "../utils/tokens-util";
import {
resolveWalletDir,
resolveWalletNameOrPrompt,
@@ -78,9 +83,15 @@ import {
SUPPORTED_PROTOCOLS_HELP,
type SupportedProtocol,
} from "../utils/plugins";
+import {
+ computeShieldMaxAmount,
+ estimateShieldGasReserveWei,
+ refineShieldMaxAmount,
+} from "../utils/shield-max.js";
import {
assertTornadoDepositAmount,
assertTornadoTokenSupported,
+ tornadoMinDenomination,
} from "../utils/tornado-pools.js";
type ShieldOpts = {
@@ -92,11 +103,13 @@ type ShieldOpts = {
token?: string;
amountWei?: string;
amountFormatted?: string;
+ amountMax?: boolean;
rpcUrl?: string;
baseFeeGwei?: string;
priorityFeeGwei?: string;
nonInteractive?: boolean;
broadcast?: boolean;
+ skipSim?: boolean;
withoutTor?: boolean;
dataDir?: string;
};
@@ -126,6 +139,47 @@ function parseFromIndex(fromValue: string): number | null {
return parsed;
}
+function findAccountWithBalance(
+ fromValue: string,
+ accounts: PublicAccountWithBalance[]
+): PublicAccountWithBalance | undefined {
+ const stealthIdx = parseStealthIndex(fromValue);
+ if (stealthIdx !== null) {
+ return accounts.find(
+ (a) => a.kind === "stealth" && a.stealthIndex === stealthIdx
+ );
+ }
+ const idx = parseFromIndex(fromValue);
+ if (idx !== null) {
+ return accounts.find((a) => a.kind !== "stealth" && a.index === idx);
+ }
+ if (isAddress(fromValue)) {
+ const addr = getAddress(fromValue).toLowerCase();
+ return accounts.find((a) => a.address.toLowerCase() === addr);
+ }
+ return undefined;
+}
+
+async function promptSourceAccount(
+ withBalances: PublicAccountWithBalance[],
+ tokenMeta: ResolvedTokenMeta,
+ message: string
+): Promise {
+ const candidates = withBalances.filter((x) => x.balance > 0n);
+ if (candidates.length === 0) {
+ throw new Error(
+ `No public account has a positive ${tokenMeta.symbol} balance.`
+ );
+ }
+ return select({
+ message,
+ choices: candidates.map((acct) => ({
+ value: acct.address,
+ name: `[${formatAccountSelector(acct)}] ${acct.address} (${formatPublicAccountBalanceLabel(acct, tokenMeta)})`,
+ })),
+ });
+}
+
async function maybeConfirm(
nonInteractive: boolean,
message: string,
@@ -242,9 +296,17 @@ export function registerShieldCommand(program: Command): void {
"--broadcast",
"Sign and submit on-chain (one EOA tx, or one EIP-7702 UserOp when 2+ calls)"
)
+ .option(
+ "--skip-sim",
+ "Skip on-chain simulation and fee estimates (dry-run only; not allowed with --broadcast)"
+ )
.option("--token ", "Token address or symbol (default: eth)")
.option("--amount-wei ", "Raw token amount in wei/base units")
.option("--amount-formatted ", "Decimal amount (converted using token decimals)")
+ .option(
+ "--amount-max",
+ "Shield the maximum spendable amount (ETH: balance minus estimated gas; ERC-20: full token balance; Tornado: floored to the smallest pool denomination)"
+ )
.option("--rpc-url ", cliOptions.rpcUrl)
.option("--base-fee-gwei ", "Base fee (gwei)")
.option("--priority-fee-gwei ", "Priority fee (gwei)")
@@ -263,8 +325,19 @@ export function registerShieldCommand(program: Command): void {
}
const protocol = resolvedProtocol.protocol;
- if (opts.amountWei && opts.amountFormatted) {
- cliError("Provide only one of --amount-wei or --amount-formatted.");
+ const amountFlags = [
+ opts.amountWei,
+ opts.amountFormatted,
+ opts.amountMax,
+ ].filter(Boolean).length;
+ if (amountFlags > 1) {
+ cliError(
+ "Provide only one of --amount-wei, --amount-formatted, or --amount-max."
+ );
+ return;
+ }
+ if (opts.skipSim && opts.broadcast) {
+ cliError("--skip-sim cannot be used with --broadcast.");
return;
}
@@ -372,9 +445,9 @@ export function registerShieldCommand(program: Command): void {
cliError("Missing --from in non-interactive mode.");
return;
}
- if (amount === null && opts.nonInteractive) {
+ if (amount === null && !opts.amountMax && opts.nonInteractive) {
cliError(
- "Missing amount in non-interactive mode. Provide --amount-wei or --amount-formatted."
+ "Missing amount in non-interactive mode. Provide --amount-wei, --amount-formatted, or --amount-max."
);
return;
}
@@ -389,11 +462,93 @@ export function registerShieldCommand(program: Command): void {
tokenMeta
);
+ let usedAmountMax = !!opts.amountMax;
+ let amountMaxEthBalance: bigint | undefined;
+ let amountMaxMinDenom: bigint | undefined;
+
try {
- if (amount === null) {
+ if (
+ fromValue &&
+ parseFromIndex(fromValue) === null &&
+ parseStealthIndex(fromValue) === null &&
+ !isAddress(fromValue)
+ ) {
+ fromValue = await resolveAddressOrName(fromValue, rpcUrl);
+ }
+
+ const resolveMaxForFrom = async (from: string): Promise => {
+ const acct = findAccountWithBalance(from, withBalances);
+ if (!acct) {
+ throw new Error(
+ `Could not find public account balance for --from ${from}.`
+ );
+ }
+ const gasReserveWei = await estimateShieldGasReserveWei(rpcUrl);
+ const minDenom =
+ protocol === "tornado"
+ ? tornadoMinDenomination(chainId, {
+ isEth: tokenMeta.isEth,
+ tokenAddress: tokenMeta.tokenAddress,
+ symbol: tokenMeta.symbol,
+ })
+ : undefined;
+ const { amount: maxAmount } = computeShieldMaxAmount({
+ isEth: tokenMeta.isEth,
+ protocol,
+ tokenBalance: acct.balance,
+ ethBalance: acct.ethBalance,
+ gasReserveWei,
+ minDenom,
+ });
+ if (maxAmount <= 0n) {
+ throw new Error(
+ tokenMeta.isEth
+ ? `Insufficient ETH for --amount-max after reserving ~${formatUnits(gasReserveWei, 18)} ETH for gas.`
+ : acct.ethBalance < gasReserveWei
+ ? `Insufficient ETH to cover estimated shield gas (~${formatUnits(gasReserveWei, 18)} ETH) for --amount-max.`
+ : `No spendable ${tokenMeta.symbol} balance for --amount-max.`
+ );
+ }
+ usedAmountMax = true;
+ amountMaxEthBalance = acct.ethBalance;
+ amountMaxMinDenom = minDenom;
+ fromValue = acct.address;
+ if (!quietNonInteractive(opts.nonInteractive) && gasReserveWei > 0n) {
+ log.info(
+ `Reserving ~${formatUnits(gasReserveWei, 18)} ETH for estimated shield gas.`
+ );
+ }
+ return maxAmount;
+ };
+
+ if (usedAmountMax) {
+ if (withBalances.length === 0) {
+ cliError(
+ "No public accounts found in this wallet. Create one with nextFreshAddress first."
+ );
+ return;
+ }
+ if (!fromValue) {
+ console.log();
+ console.log(
+ chalk.bold(`Available accounts (${tokenMeta.symbol} balances):`)
+ );
+ for (const acct of withBalances) {
+ console.log(
+ ` [${formatAccountSelector(acct)}] ${acct.address} ${formatPublicAccountBalanceLabel(acct, tokenMeta)}`
+ );
+ }
+ fromValue = await promptSourceAccount(
+ withBalances,
+ tokenMeta,
+ `Pick source account for max ${tokenMeta.symbol} shield`
+ );
+ }
+ amount = await resolveMaxForFrom(fromValue);
+ } else if (amount === null) {
if (opts.nonInteractive) {
cliError(
- "Missing amount in non-interactive mode. Provide --amount-wei or --amount-formatted."
+ "Missing amount in non-interactive mode. Provide --amount-wei, --amount-formatted, or --amount-max."
);
return;
}
@@ -411,16 +566,18 @@ export function registerShieldCommand(program: Command): void {
);
for (const acct of withBalances) {
console.log(
- ` [${acct.index}] ${acct.address} ${formatPublicAccountBalanceLabel(acct, tokenMeta)}`
+ ` [${formatAccountSelector(acct)}] ${acct.address} ${formatPublicAccountBalanceLabel(acct, tokenMeta)}`
);
}
const amountFormattedInput = await input({
- message: `Amount to shield (${tokenMeta.symbol}, formatted):`,
+ message: `Amount to shield (${tokenMeta.symbol}, formatted, or "max"):`,
validate: (value) => {
- if (!value.trim()) return "Amount is required.";
+ const trimmed = value.trim();
+ if (!trimmed) return "Amount is required.";
+ if (trimmed.toLowerCase() === "max") return true;
try {
- const parsed = parseUnits(value.trim(), tokenMeta.decimals);
+ const parsed = parseUnits(trimmed, tokenMeta.decimals);
if (parsed <= 0n) return "Amount must be greater than zero.";
if (protocol === "tornado") {
assertTornadoDepositAmount(chainId, parsed, {
@@ -438,18 +595,27 @@ export function registerShieldCommand(program: Command): void {
return true;
},
});
- amount = parseUnits(amountFormattedInput.trim(), tokenMeta.decimals);
- if (protocol === "tornado") {
- try {
+ if (amountFormattedInput.trim().toLowerCase() === "max") {
+ if (!fromValue) {
+ fromValue = await promptSourceAccount(
+ withBalances,
+ tokenMeta,
+ `Pick source account for max ${tokenMeta.symbol} shield`
+ );
+ }
+ amount = await resolveMaxForFrom(fromValue);
+ } else {
+ amount = parseUnits(
+ amountFormattedInput.trim(),
+ tokenMeta.decimals
+ );
+ if (protocol === "tornado") {
assertTornadoDepositAmount(chainId, amount, {
isEth: tokenMeta.isEth,
tokenAddress: tokenMeta.tokenAddress,
symbol: tokenMeta.symbol,
decimals: tokenMeta.decimals,
});
- } catch (e) {
- cliErrorFromCaught(e);
- return;
}
}
}
@@ -477,7 +643,7 @@ export function registerShieldCommand(program: Command): void {
message: `Pick source account (${tokenMeta.symbol})`,
choices: candidates.map((acct) => ({
value: acct.address,
- name: `[${acct.index}] ${acct.address} (${formatPublicAccountBalanceLabel(acct, tokenMeta)}, need ${formatUnits(amount!, tokenMeta.decimals)} ${tokenMeta.symbol})`,
+ name: `[${formatAccountSelector(acct)}] ${acct.address} (${formatPublicAccountBalanceLabel(acct, tokenMeta)}, need ${formatUnits(amount!, tokenMeta.decimals)} ${tokenMeta.symbol})`,
})),
});
fromValue = chosen;
@@ -487,8 +653,18 @@ export function registerShieldCommand(program: Command): void {
return;
}
+ if (amount === null) {
+ cliError("Amount is required.");
+ return;
+ }
+
// Resolve ENS / GNS / WNS names to addresses before the index/address branch.
- if (fromValue && parseFromIndex(fromValue) === null && !isAddress(fromValue)) {
+ if (
+ fromValue &&
+ parseFromIndex(fromValue) === null &&
+ parseStealthIndex(fromValue) === null &&
+ !isAddress(fromValue)
+ ) {
try {
fromValue = await resolveAddressOrName(fromValue, rpcUrl);
} catch (e) {
@@ -570,14 +746,14 @@ export function registerShieldCommand(program: Command): void {
});
const plugin = await createProtocolPlugin(protocol, host, chainId);
- const asset =
- protocol === "railgun"
+ const assetFor = (amt: bigint): AssetAmount =>
+ (protocol === "railgun"
? tokenMeta.isEth
? {
asset: { __type: "native" as const },
- amount,
+ amount: amt,
}
- : railgunErc20AssetAmount(tokenMeta.tokenAddress, amount)
+ : railgunErc20AssetAmount(tokenMeta.tokenAddress, amt)
: {
asset: {
__type: "erc20",
@@ -585,45 +761,111 @@ export function registerShieldCommand(program: Command): void {
? ETH_AS_ERC20
: tokenMeta.tokenAddress) as `0x${string}`,
},
- amount,
- };
- let shieldTxs: Array<{ to: string; data: string; value: bigint }>;
- let approvals: Array<{ to: string; data: string; value: bigint }> = [];
- try {
- const op =
- protocol === "railgun"
- ? await prepareProtocolShield(plugin, protocol, asset as AssetAmount)
- : await runWithSyncProgress(
- {
- source: protocol,
- firstRun: isFirstProtocolSync(walletDir, protocol),
- onUpdate: quiet ? undefined : (message) => txSpinner.start(message),
- },
- async () => {
- await syncPluginWithProgress(plugin, protocol);
- return prepareProtocolShield(
- plugin,
- protocol,
- asset as AssetAmount
- );
- }
- );
- if (protocol !== "railgun" && txSpinner.active) {
- txSpinner.stop("Private state synced.");
- }
+ amount: amt,
+ }) as AssetAmount;
+
+ const prepareShieldCalls = async (amt: bigint) => {
+ const op = await prepareProtocolShield(
+ plugin,
+ protocol,
+ assetFor(amt)
+ );
const rawTxs = toShieldTxs(op);
+ let nextApprovals: Array<{ to: string; data: string; value: bigint }> =
+ [];
+ let nextDeposits: Array<{ to: string; data: string; value: bigint }>;
if (tokenMeta.isEth) {
- shieldTxs = partitionShieldTxs(rawTxs).deposits;
+ nextDeposits = partitionShieldTxs(rawTxs).deposits;
} else {
const resolved = await resolveShieldApprovalCalls({
client: rpcForHost,
tokenAddress: tokenMeta.tokenAddress,
senderAddress,
- amount,
+ amount: amt,
shieldTxs: rawTxs,
});
- approvals = resolved.approvals;
- shieldTxs = resolved.deposits;
+ nextApprovals = resolved.approvals;
+ nextDeposits = resolved.deposits;
+ }
+ return {
+ approvals: nextApprovals,
+ shieldTxs: nextDeposits,
+ calls: buildShieldCallList(nextApprovals, nextDeposits),
+ };
+ };
+
+ let shieldTxs: Array<{ to: string; data: string; value: bigint }>;
+ let approvals: Array<{ to: string; data: string; value: bigint }> = [];
+ let calls: ReturnType;
+ try {
+ if (protocol !== "railgun") {
+ await runWithSyncProgress(
+ {
+ source: protocol,
+ firstRun: isFirstProtocolSync(walletDir, protocol),
+ onUpdate: quiet ? undefined : (message) => txSpinner.start(message),
+ },
+ async () => {
+ await syncPluginWithProgress(plugin, protocol);
+ }
+ );
+ if (txSpinner.active) txSpinner.stop("Private state synced.");
+ }
+
+ const prepared = await prepareShieldCalls(amount!);
+ approvals = prepared.approvals;
+ shieldTxs = prepared.shieldTxs;
+ calls = prepared.calls;
+
+ if (usedAmountMax && amountMaxEthBalance !== undefined) {
+ for (let i = 0; i < 3; i++) {
+ const batch = calls.length > 1;
+ const feePreview = batch
+ ? await estimateEip7702BatchUserOpFee({
+ client: rpcForHost,
+ chainId,
+ senderAddress,
+ calls,
+ ...eip7702Tor,
+ })
+ : await estimateEoaTxFeePreview(
+ rpcForHost,
+ {
+ to: calls[0]!.to,
+ from: senderAddress,
+ data: calls[0]!.data,
+ value: calls[0]!.value,
+ },
+ 2_000_000n
+ );
+ const estimatedFeeWei = BigInt(feePreview.estimatedMax);
+ const refined = refineShieldMaxAmount({
+ isEth: tokenMeta.isEth,
+ protocol,
+ currentAmount: amount!,
+ ethBalance: amountMaxEthBalance,
+ estimatedFeeWei,
+ minDenom: amountMaxMinDenom,
+ });
+ if (refined === 0n) {
+ throw new Error(
+ tokenMeta.isEth
+ ? `Insufficient ETH for --amount-max after reserving ~${formatUnits(estimatedFeeWei, 18)} ETH for gas.`
+ : `Insufficient ETH to cover estimated shield gas (~${formatUnits(estimatedFeeWei, 18)} ETH) for --amount-max.`
+ );
+ }
+ if (refined >= amount!) break;
+ if (!quiet) {
+ log.info(
+ `Refining --amount-max: ${formatUnits(amount!, tokenMeta.decimals)} → ${formatUnits(refined, tokenMeta.decimals)} ${tokenMeta.symbol}`
+ );
+ }
+ amount = refined;
+ const next = await prepareShieldCalls(amount);
+ approvals = next.approvals;
+ shieldTxs = next.shieldTxs;
+ calls = next.calls;
+ }
}
} catch (e) {
const msg = e instanceof Error ? e.message : JSON.stringify(e);
@@ -631,49 +873,57 @@ export function registerShieldCommand(program: Command): void {
return;
}
- const calls = buildShieldCallList(approvals, shieldTxs);
const batchAsUserOp = calls.length > 1;
- const amountPreview = `${formatUnits(amount, tokenMeta.decimals)} ${tokenMeta.symbol}`;
-
- // Single EOA tx: eth_call is fine. Multi-call UserOp: do NOT eth_call each
- // payload alone (approve→deposit etc. false-positive). Batch validation is
- // bundler prepareUserOperation / estimateUserOperationGas below.
- if (!batchAsUserOp) {
- const call = calls[0]!;
- await simulateTransactionOrThrow(
- rpcForHost,
- {
- to: call.to,
- from: senderAddress,
- data: call.data,
- value: call.value,
- },
- "Shield transaction"
- );
- }
+ const amountPreview = `${formatUnits(amount!, tokenMeta.decimals)} ${tokenMeta.symbol}`;
let fees: FeePreview;
- if (batchAsUserOp) {
- fees = await estimateEip7702BatchUserOpFee({
- client: rpcForHost,
- chainId,
- senderAddress,
- calls,
- privateKey: senderPrivateKey,
- ...eip7702Tor,
+ if (opts.skipSim) {
+ fees = buildFeePreview({
+ kind: batchAsUserOp ? "eip7702-userop" : "network-gas",
+ amount: 0n,
+ decimals: 18,
+ asset: "ETH",
});
} else {
- const call = calls[0]!;
- fees = await estimateEoaTxFeePreview(
- rpcForHost,
- {
- to: call.to,
- from: senderAddress,
- data: call.data,
- value: call.value,
- },
- 2_000_000n
- );
+ // Single EOA tx: eth_call is fine. Multi-call UserOp: do NOT eth_call each
+ // payload alone (approve→deposit etc. false-positive). Batch validation is
+ // bundler prepareUserOperation / estimateUserOperationGas below.
+ if (!batchAsUserOp) {
+ const call = calls[0]!;
+ await simulateTransactionOrThrow(
+ rpcForHost,
+ {
+ to: call.to,
+ from: senderAddress,
+ data: call.data,
+ value: call.value,
+ },
+ "Shield transaction"
+ );
+ }
+
+ if (batchAsUserOp) {
+ fees = await estimateEip7702BatchUserOpFee({
+ client: rpcForHost,
+ chainId,
+ senderAddress,
+ calls,
+ privateKey: senderPrivateKey,
+ ...eip7702Tor,
+ });
+ } else {
+ const call = calls[0]!;
+ fees = await estimateEoaTxFeePreview(
+ rpcForHost,
+ {
+ to: call.to,
+ from: senderAddress,
+ data: call.data,
+ value: call.value,
+ },
+ 2_000_000n
+ );
+ }
}
const transactions: TxPayloadJson[] = calls.map((call) => ({
@@ -731,6 +981,13 @@ export function registerShieldCommand(program: Command): void {
}
}
printFeePreview(fees);
+ if (opts.skipSim) {
+ console.log(
+ chalk.dim(
+ "Simulation skipped (--skip-sim); payloads are not validated on-chain."
+ )
+ );
+ }
console.log(chalk.green("✔ Shield dry run complete."));
}
return;
@@ -801,7 +1058,7 @@ export function registerShieldCommand(program: Command): void {
!!opts.nonInteractive,
shieldTransactionConfirmMessage({
step: "1/1",
- txValue: call.value > 0n ? call.value : amount,
+ txValue: call.value > 0n ? call.value : amount!,
shieldTxs,
tokenMeta,
senderAddress,
diff --git a/src/utils/shield-max.ts b/src/utils/shield-max.ts
new file mode 100644
index 0000000..b6b5a8d
--- /dev/null
+++ b/src/utils/shield-max.ts
@@ -0,0 +1,108 @@
+import { makePublicClient } from "./rpc.js";
+
+/** Same gas cap shield uses when broadcasting a single EOA deposit. */
+export const SHIELD_GAS_LIMIT = 2_000_000n;
+
+/**
+ * Conservative wei reserve for a shield (gas × fee × 1.2).
+ * Uses ~110% of latest base fee, same pattern as transfer --amount-max.
+ */
+export async function estimateShieldGasReserveWei(
+ rpcUrl: string
+): Promise {
+ const client = await makePublicClient(rpcUrl);
+ const latest = await client.getBlock({ blockTag: "latest" });
+ const base = latest.baseFeePerGas ?? 0n;
+ let maxFeePerGas = (base * 110n) / 100n;
+
+ const feeData = await client.estimateFeesPerGas();
+ if (feeData.maxFeePerGas != null && feeData.maxFeePerGas > maxFeePerGas) {
+ maxFeePerGas = feeData.maxFeePerGas;
+ }
+ if (maxFeePerGas === 0n && feeData.gasPrice != null) {
+ maxFeePerGas = feeData.gasPrice;
+ }
+ if (maxFeePerGas === 0n) {
+ throw new Error(
+ "Could not determine gas price to compute shield --amount-max."
+ );
+ }
+ return (SHIELD_GAS_LIMIT * maxFeePerGas * 12n) / 10n;
+}
+
+/** Floor `amount` down to a multiple of `minDenom` (0 if amount < min). */
+export function tornadoFloorToMinDenom(
+ amount: bigint,
+ minDenom: bigint
+): bigint {
+ if (minDenom <= 0n || amount < minDenom) return 0n;
+ return amount - (amount % minDenom);
+}
+
+export type ComputeShieldMaxAmountOpts = {
+ isEth: boolean;
+ protocol: string;
+ tokenBalance: bigint;
+ ethBalance: bigint;
+ gasReserveWei: bigint;
+ minDenom?: bigint;
+};
+
+/**
+ * First-pass max shield amount: reserve gas, then (Tornado) floor to min denom.
+ * ERC-20 uses the token balance; ETH uses ethBalance - reserve.
+ * Returns 0 when the account cannot cover gas, or Tornado leftover is below min denom.
+ */
+export function computeShieldMaxAmount(
+ opts: ComputeShieldMaxAmountOpts
+): { amount: bigint; gasReserveWei: bigint } {
+ const isTornado = opts.protocol === "tornado";
+ const minDenom = opts.minDenom ?? 0n;
+ const floor = (raw: bigint): bigint =>
+ isTornado && minDenom > 0n ? tornadoFloorToMinDenom(raw, minDenom) : raw;
+
+ if (opts.ethBalance < opts.gasReserveWei) {
+ return { amount: 0n, gasReserveWei: opts.gasReserveWei };
+ }
+
+ if (!opts.isEth) {
+ const raw = opts.tokenBalance > 0n ? opts.tokenBalance : 0n;
+ return { amount: floor(raw), gasReserveWei: opts.gasReserveWei };
+ }
+
+ const spendable = opts.ethBalance - opts.gasReserveWei;
+ return { amount: floor(spendable), gasReserveWei: opts.gasReserveWei };
+}
+
+export type RefineShieldMaxAmountOpts = {
+ isEth: boolean;
+ protocol: string;
+ currentAmount: bigint;
+ ethBalance: bigint;
+ estimatedFeeWei: bigint;
+ minDenom?: bigint;
+};
+
+/**
+ * After a real fee estimate: shrink ETH amount if value + fee exceeds balance.
+ * ERC-20 never shrinks the token amount; returns 0 when ETH cannot cover the fee.
+ */
+export function refineShieldMaxAmount(opts: RefineShieldMaxAmountOpts): bigint {
+ const isTornado = opts.protocol === "tornado";
+ const minDenom = opts.minDenom ?? 0n;
+ const floor = (raw: bigint): bigint =>
+ isTornado && minDenom > 0n ? tornadoFloorToMinDenom(raw, minDenom) : raw;
+
+ if (!opts.isEth) {
+ return opts.ethBalance < opts.estimatedFeeWei ? 0n : opts.currentAmount;
+ }
+
+ if (opts.currentAmount + opts.estimatedFeeWei <= opts.ethBalance) {
+ return opts.currentAmount;
+ }
+ const spendable =
+ opts.ethBalance > opts.estimatedFeeWei
+ ? opts.ethBalance - opts.estimatedFeeWei
+ : 0n;
+ return floor(spendable);
+}
diff --git a/src/utils/tornado-pools.ts b/src/utils/tornado-pools.ts
index 7ceda0d..f6419a0 100644
--- a/src/utils/tornado-pools.ts
+++ b/src/utils/tornado-pools.ts
@@ -223,6 +223,18 @@ export function assertTornadoTokenSupported(
return pools;
}
+/** Smallest pool denomination for this asset (deposit strategizer step size). */
+export function tornadoMinDenomination(
+ chainId: bigint,
+ opts: { isEth: boolean; tokenAddress: string; symbol: string }
+): bigint {
+ const pools = assertTornadoTokenSupported(chainId, opts);
+ return pools.reduce(
+ (m, p) => (p.denomination < m ? p.denomination : m),
+ pools[0]!.denomination
+ );
+}
+
/**
* Pool addresses the on-chain Tornado paymaster can sponsor (FeeAdapter map).
* ERC-20 unshields require the withdrawn asset to be quoteable as `feeToken`.
@@ -286,11 +298,7 @@ export function assertTornadoDepositAmount(
if (amount <= 0n) {
throw new Error("Amount must be greater than zero.");
}
- const pools = assertTornadoTokenSupported(chainId, opts);
- const min = pools.reduce(
- (m, p) => (p.denomination < m ? p.denomination : m),
- pools[0]!.denomination
- );
+ const min = tornadoMinDenomination(chainId, opts);
if (amount % min !== 0n) {
const minFmt = formatUnits(min, opts.decimals);
throw new Error(
diff --git a/tests/shield-max.test.ts b/tests/shield-max.test.ts
new file mode 100644
index 0000000..4933ea1
--- /dev/null
+++ b/tests/shield-max.test.ts
@@ -0,0 +1,183 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+
+import {
+ computeShieldMaxAmount,
+ refineShieldMaxAmount,
+ tornadoFloorToMinDenom,
+} from "../src/utils/shield-max.js";
+
+const ETH_01 = 100_000_000_000_000_000n; // 0.1
+const ETH_02 = 200_000_000_000_000_000n; // 0.2
+const ETH_03 = 300_000_000_000_000_000n; // 0.3
+const ETH_035 = 350_000_000_000_000_000n; // 0.35
+const ETH_002 = 20_000_000_000_000_000n; // 0.02
+const ETH_006 = 60_000_000_000_000_000n; // 0.06
+const DAI_100 = 100_000_000_000_000_000_000n;
+const DAI_200 = 200_000_000_000_000_000_000n;
+const DAI_250 = 250_000_000_000_000_000_000n;
+
+describe("tornadoFloorToMinDenom", () => {
+ it("floors down to a multiple of minDenom", () => {
+ assert.equal(tornadoFloorToMinDenom(DAI_250, DAI_100), DAI_200);
+ assert.equal(tornadoFloorToMinDenom(ETH_035, ETH_01), ETH_03);
+ });
+
+ it("returns 0 when amount is below minDenom", () => {
+ assert.equal(tornadoFloorToMinDenom(50n, DAI_100), 0n);
+ assert.equal(tornadoFloorToMinDenom(0n, ETH_01), 0n);
+ });
+});
+
+describe("computeShieldMaxAmount", () => {
+ it("returns the full ERC-20 balance for railgun / privacy-pools", () => {
+ const { amount } = computeShieldMaxAmount({
+ isEth: false,
+ protocol: "railgun",
+ tokenBalance: DAI_250,
+ ethBalance: ETH_01,
+ gasReserveWei: ETH_002,
+ });
+ assert.equal(amount, DAI_250);
+ });
+
+ it("floors Tornado ERC-20 to the smallest denomination", () => {
+ const { amount } = computeShieldMaxAmount({
+ isEth: false,
+ protocol: "tornado",
+ tokenBalance: DAI_250,
+ ethBalance: ETH_01,
+ gasReserveWei: ETH_002,
+ minDenom: DAI_100,
+ });
+ assert.equal(amount, DAI_200);
+ });
+
+ it("subtracts the gas reserve from ETH for railgun / privacy-pools", () => {
+ const { amount } = computeShieldMaxAmount({
+ isEth: true,
+ protocol: "railgun",
+ tokenBalance: ETH_035,
+ ethBalance: ETH_035,
+ gasReserveWei: ETH_002,
+ });
+ assert.equal(amount, ETH_035 - ETH_002);
+ });
+
+ it("floors Tornado ETH after reserving gas (0.35 - 0.02 → 0.3)", () => {
+ const { amount } = computeShieldMaxAmount({
+ isEth: true,
+ protocol: "tornado",
+ tokenBalance: ETH_035,
+ ethBalance: ETH_035,
+ gasReserveWei: ETH_002,
+ minDenom: ETH_01,
+ });
+ assert.equal(amount, ETH_03);
+ });
+
+ it("returns 0 when the ETH reserve covers the whole balance", () => {
+ const { amount } = computeShieldMaxAmount({
+ isEth: true,
+ protocol: "railgun",
+ tokenBalance: ETH_002,
+ ethBalance: ETH_002,
+ gasReserveWei: ETH_002,
+ });
+ assert.equal(amount, 0n);
+ });
+
+ it("returns 0 when Tornado leftover is below min denom", () => {
+ const { amount } = computeShieldMaxAmount({
+ isEth: true,
+ protocol: "tornado",
+ tokenBalance: 50_000_000_000_000_000n,
+ ethBalance: 50_000_000_000_000_000n,
+ gasReserveWei: ETH_002,
+ minDenom: ETH_01,
+ });
+ assert.equal(amount, 0n);
+ });
+
+ it("returns 0 for ERC-20 when ETH cannot cover the gas reserve", () => {
+ const { amount } = computeShieldMaxAmount({
+ isEth: false,
+ protocol: "railgun",
+ tokenBalance: DAI_250,
+ ethBalance: 1n,
+ gasReserveWei: ETH_002,
+ });
+ assert.equal(amount, 0n);
+ });
+});
+
+describe("refineShieldMaxAmount", () => {
+ it("keeps the amount when ETH value + fee fits", () => {
+ assert.equal(
+ refineShieldMaxAmount({
+ isEth: true,
+ protocol: "tornado",
+ currentAmount: ETH_03,
+ ethBalance: ETH_035,
+ estimatedFeeWei: ETH_002,
+ minDenom: ETH_01,
+ }),
+ ETH_03
+ );
+ });
+
+ it("drops a Tornado ETH step when fee pushes leftover under 0.3 → 0.2", () => {
+ assert.equal(
+ refineShieldMaxAmount({
+ isEth: true,
+ protocol: "tornado",
+ currentAmount: ETH_03,
+ ethBalance: ETH_035,
+ estimatedFeeWei: ETH_006,
+ minDenom: ETH_01,
+ }),
+ ETH_02
+ );
+ });
+
+ it("does not shrink ERC-20 when ETH covers the fee", () => {
+ assert.equal(
+ refineShieldMaxAmount({
+ isEth: false,
+ protocol: "tornado",
+ currentAmount: DAI_200,
+ ethBalance: ETH_01,
+ estimatedFeeWei: ETH_002,
+ minDenom: DAI_100,
+ }),
+ DAI_200
+ );
+ });
+
+ it("returns 0 for ERC-20 when ETH cannot cover the fee", () => {
+ assert.equal(
+ refineShieldMaxAmount({
+ isEth: false,
+ protocol: "railgun",
+ currentAmount: DAI_200,
+ ethBalance: 1n,
+ estimatedFeeWei: ETH_002,
+ }),
+ 0n
+ );
+ });
+
+ it("returns 0 when leftover after fee is below Tornado min denom", () => {
+ assert.equal(
+ refineShieldMaxAmount({
+ isEth: true,
+ protocol: "tornado",
+ currentAmount: ETH_01,
+ ethBalance: ETH_01,
+ estimatedFeeWei: ETH_002,
+ minDenom: ETH_01,
+ }),
+ 0n
+ );
+ });
+});
diff --git a/tests/tornado-pools.test.ts b/tests/tornado-pools.test.ts
index 977c686..d972e5d 100644
--- a/tests/tornado-pools.test.ts
+++ b/tests/tornado-pools.test.ts
@@ -6,6 +6,7 @@ import {
assertTornadoExactPoolDenomination,
assertTornadoTokenSupported,
assertTornadoUnshieldAmountForToken,
+ tornadoMinDenomination,
tornadoPaymasterPoolsForAsset,
tornadoPoolsForAsset,
tornadoPoolsForChain,
@@ -84,6 +85,13 @@ describe("assertTornadoTokenSupported", () => {
});
});
+describe("tornadoMinDenomination", () => {
+ it("returns the smallest catalog denomination for ETH and USDC", () => {
+ assert.equal(tornadoMinDenomination(1n, ETH), ETH_01);
+ assert.equal(tornadoMinDenomination(1n, USDC), USDC_100);
+ });
+});
+
describe("assertTornadoDepositAmount", () => {
it("accepts a positive multiple of the smallest ETH denomination", () => {
assert.doesNotThrow(() => assertTornadoDepositAmount(1n, ETH_01, ETH));
From ae73938caff3936f7c26fae30f981d2bdd0edc6a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=CE=BA=CE=B1=CF=83=CF=83=CE=AC=CE=BD=CE=B4=CF=81=CE=B1=2Ee?=
=?UTF-8?q?th?= <0xDADA@protonmail.com>
Date: Sat, 29 Aug 2026 14:25:04 +0200
Subject: [PATCH 2/3] fix: progress logs
---
CHANGELOG.md | 9 +++
src/commands/shield.ts | 28 +++----
src/commands/unshield.ts | 1 +
src/lib/balances-snapshot.ts | 148 ++++++++++++++++-------------------
src/lib/shield-flow.ts | 25 +++---
src/lib/stealth/scan.ts | 4 +-
src/utils/sync-progress.ts | 24 +++++-
tests/sync-progress.test.ts | 35 +++++++--
8 files changed, 156 insertions(+), 118 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9502a92..b522ccc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `--skip-sim` on `shield` dry-runs so counterfactual (unfunded) senders still print call payloads. Cannot be combined with `--broadcast`. `--non-interactive` JSON schema is unchanged; `fees` are zeroed.
- `--amount-max` on `shield`: spend the account's maximum (ETH minus estimated gas; ERC-20 full balance). Tornado amounts are floored to the smallest pool denomination, then refined if gas would push the deposit below a step.
+### Changed
+
+- Privacy-protocol sync progress omits request counts (timer + phase only; `still working` when WASM blocks). Stealth scan keeps the durable `from block · N blocks` prelude and shows `scanned/total` blocks on the animated line (not request counts).
+- `shield` and `unshield` keep a live progress timer during protocol sync (including Railgun WASM), matching `balances`.
+
+### Fixed
+
+- `balances --verbose` no longer re-syncs each privacy protocol to load note details.
+
## [0.0.4] — 2026-08-25
### Added
diff --git a/src/commands/shield.ts b/src/commands/shield.ts
index 79a1f08..2f18bca 100644
--- a/src/commands/shield.ts
+++ b/src/commands/shield.ts
@@ -798,21 +798,19 @@ export function registerShieldCommand(program: Command): void {
let approvals: Array<{ to: string; data: string; value: bigint }> = [];
let calls: ReturnType;
try {
- if (protocol !== "railgun") {
- await runWithSyncProgress(
- {
- source: protocol,
- firstRun: isFirstProtocolSync(walletDir, protocol),
- onUpdate: quiet ? undefined : (message) => txSpinner.start(message),
- },
- async () => {
- await syncPluginWithProgress(plugin, protocol);
- }
- );
- if (txSpinner.active) txSpinner.stop("Private state synced.");
- }
-
- const prepared = await prepareShieldCalls(amount!);
+ if (!quiet) txSpinner.start("Syncing private state...");
+ const prepared = await runWithSyncProgress(
+ {
+ source: protocol,
+ firstRun: isFirstProtocolSync(walletDir, protocol),
+ onUpdate: quiet ? undefined : (message) => txSpinner.start(message),
+ },
+ async () => {
+ await syncPluginWithProgress(plugin, protocol);
+ return prepareShieldCalls(amount!);
+ }
+ );
+ if (txSpinner.active) txSpinner.stop("Private state synced.");
approvals = prepared.approvals;
shieldTxs = prepared.shieldTxs;
calls = prepared.calls;
diff --git a/src/commands/unshield.ts b/src/commands/unshield.ts
index 9a29c8b..852bba6 100644
--- a/src/commands/unshield.ts
+++ b/src/commands/unshield.ts
@@ -528,6 +528,7 @@ export function registerUnshieldCommand(program: Command): void {
);
}
+ if (!quiet) spin.start("Syncing private state...");
const {
cap: maxAmountHint,
privacyPoolsLargestNote,
diff --git a/src/lib/balances-snapshot.ts b/src/lib/balances-snapshot.ts
index 7102ddc..92eb600 100644
--- a/src/lib/balances-snapshot.ts
+++ b/src/lib/balances-snapshot.ts
@@ -131,17 +131,23 @@ function mapProtocolNotes(
return mapRailgunNotes(notes, tokenMeta);
}
-async function loadProtocolNotes(
+type ProtocolPrivateLoad = {
+ balances: AssetAmount[];
+ rawNotes?: unknown[];
+ notesError?: unknown;
+};
+
+async function loadPrivateProtocolData(
protocol: SupportedProtocol,
rpcUrl: string,
walletDir: string,
password: string,
mnemonic: string,
chainId: bigint,
- tokenMeta: Map,
+ includeNotes: boolean,
onSyncProgress?: (message: string) => void
-): Promise {
- const notes = await runWithSyncProgress(
+): Promise {
+ return runWithSyncProgress(
{
source: protocol,
firstRun: isFirstProtocolSync(walletDir, protocol),
@@ -151,39 +157,24 @@ async function loadProtocolNotes(
withProtocolRuntime(
{ protocol, rpcUrl, walletDir, password, mnemonic, chainId },
async (_host, plugin) => {
- const notesFn = (plugin as AnyPlugin).notes;
- if (!notesFn) {
- throw new Error(`${protocol} plugin does not expose notes()`);
+ const balances = await plugin.balance(undefined);
+ if (!includeNotes) return { balances };
+ try {
+ const notesFn = (plugin as AnyPlugin).notes;
+ if (!notesFn) {
+ throw new Error(`${protocol} plugin does not expose notes()`);
+ }
+ const rawNotes = await (plugin as AnyPlugin).notes!(
+ undefined,
+ false
+ );
+ return { balances, rawNotes };
+ } catch (e) {
+ return { balances, notesError: e };
}
- // Preserve method `this` binding for class-based plugin implementations.
- return (plugin as AnyPlugin).notes!(undefined, false);
}
)
);
- return mapProtocolNotes(protocol, notes, tokenMeta);
-}
-
-async function loadPrivateBalancesForProtocol(
- protocol: SupportedProtocol,
- rpcUrl: string,
- walletDir: string,
- password: string,
- mnemonic: string,
- chainId: bigint,
- onSyncProgress?: (message: string) => void
-): Promise {
- return runWithSyncProgress(
- {
- source: protocol,
- firstRun: isFirstProtocolSync(walletDir, protocol),
- onUpdate: onSyncProgress,
- },
- async () =>
- withProtocolRuntime(
- { protocol, rpcUrl, walletDir, password, mnemonic, chainId },
- async (_host, plugin) => plugin.balance(undefined)
- )
- );
}
async function loadErc20Meta(
@@ -258,6 +249,8 @@ export type PrivateBalancesSnapshot = {
type ResolvedPrivateBalances = PrivateBalancesSnapshot & {
erc20FromPrivate: `0x${string}`[];
protocolAvailable: Partial>;
+ rawNotesByProtocol: Partial>;
+ notesErrors: Partial>;
};
function willSyncPrivateProtocols(
@@ -276,6 +269,7 @@ async function resolvePrivateBalanceItems(
| "mnemonic"
| "chainId"
| "includeProtocols"
+ | "verbose"
| "onWarning"
| "onSyncProgress"
>
@@ -287,6 +281,7 @@ async function resolvePrivateBalanceItems(
mnemonic,
chainId,
includeProtocols = null,
+ verbose = false,
onWarning,
onSyncProgress,
} = opts;
@@ -296,18 +291,28 @@ async function resolvePrivateBalanceItems(
let ppRows: AssetAmount[] = [];
let tcRows: AssetAmount[] = [];
const protocolAvailable: Partial> = {};
+ const rawNotesByProtocol: Partial> = {};
+ const notesErrors: Partial> = {};
+
+ const loadOne = async (protocol: SupportedProtocol) => {
+ const loaded = await loadPrivateProtocolData(
+ protocol,
+ rpcUrl,
+ walletDir,
+ password,
+ mnemonic,
+ chainId,
+ verbose,
+ onSyncProgress
+ );
+ if (loaded.rawNotes) rawNotesByProtocol[protocol] = loaded.rawNotes;
+ if (loaded.notesError) notesErrors[protocol] = loaded.notesError;
+ return loaded.balances;
+ };
if (shouldIncludeProtocol("railgun", includeProtocols)) {
try {
- rgRows = await loadPrivateBalancesForProtocol(
- "railgun",
- rpcUrl,
- walletDir,
- password,
- mnemonic,
- chainId,
- onSyncProgress
- );
+ rgRows = await loadOne("railgun");
protocolAvailable.railgun = true;
} catch (e) {
onWarning?.(
@@ -319,15 +324,7 @@ async function resolvePrivateBalanceItems(
if (shouldIncludeProtocol("privacy-pools", includeProtocols)) {
try {
- ppRows = await loadPrivateBalancesForProtocol(
- "privacy-pools",
- rpcUrl,
- walletDir,
- password,
- mnemonic,
- chainId,
- onSyncProgress
- );
+ ppRows = await loadOne("privacy-pools");
protocolAvailable["privacy-pools"] = true;
} catch (e) {
onWarning?.(
@@ -339,15 +336,7 @@ async function resolvePrivateBalanceItems(
if (shouldIncludeProtocol("tornado", includeProtocols)) {
try {
- tcRows = await loadPrivateBalancesForProtocol(
- "tornado",
- rpcUrl,
- walletDir,
- password,
- mnemonic,
- chainId,
- onSyncProgress
- );
+ tcRows = await loadOne("tornado");
protocolAvailable.tornado = true;
} catch (e) {
onWarning?.(
@@ -390,6 +379,8 @@ async function resolvePrivateBalanceItems(
),
erc20FromPrivate,
protocolAvailable,
+ rawNotesByProtocol,
+ notesErrors,
};
} finally {
disposePublicClient(client);
@@ -469,6 +460,8 @@ async function loadBalancesSnapshotInner(
privateTornado,
erc20FromPrivate,
protocolAvailable,
+ rawNotesByProtocol,
+ notesErrors,
} = await withTor(
useTor,
{ rpcUrl, onStatus: onTorStatus, walletDir },
@@ -480,6 +473,7 @@ async function loadBalancesSnapshotInner(
mnemonic,
chainId,
includeProtocols,
+ verbose,
onWarning,
onSyncProgress,
})
@@ -590,30 +584,24 @@ async function loadBalancesSnapshotInner(
privateNotes[protocol] = [];
continue;
}
- try {
- const rows = await loadProtocolNotes(
- protocol,
- rpcUrl,
- walletDir,
- password,
- mnemonic,
- chainId,
- tokenMeta,
- onSyncProgress
- );
- privateNotes[protocol] = filterNonZeroNotes(rows);
- } catch (e) {
- const label =
- protocol === "privacy-pools"
- ? "Privacy pools"
- : protocol === "tornado"
- ? "Tornado Cash"
- : "Railgun";
+ const label =
+ protocol === "privacy-pools"
+ ? "Privacy pools"
+ : protocol === "tornado"
+ ? "Tornado Cash"
+ : "Railgun";
+ const notesError = notesErrors[protocol];
+ if (notesError) {
onWarning?.(
- `${label} notes unavailable: ${formatCaughtError(e)}`
+ `${label} notes unavailable: ${formatCaughtError(notesError)}`
);
privateNotes[protocol] = [];
+ continue;
}
+ const raw = rawNotesByProtocol[protocol] ?? [];
+ privateNotes[protocol] = filterNonZeroNotes(
+ mapProtocolNotes(protocol, raw, tokenMeta)
+ );
}
}
diff --git a/src/lib/shield-flow.ts b/src/lib/shield-flow.ts
index e067ffa..085d116 100644
--- a/src/lib/shield-flow.ts
+++ b/src/lib/shield-flow.ts
@@ -535,20 +535,17 @@ export async function prepareShieldPlan(opts: {
amount,
};
- const op =
- protocol === "railgun"
- ? await prepareProtocolShield(plugin, protocol, asset as AssetAmount)
- : await runWithSyncProgress(
- {
- source: protocol,
- firstRun: isFirstProtocolSync(walletDir, protocol),
- onUpdate: onSyncProgress,
- },
- async () => {
- await syncPluginWithProgress(plugin, protocol);
- return prepareProtocolShield(plugin, protocol, asset as AssetAmount);
- }
- );
+ const op = await runWithSyncProgress(
+ {
+ source: protocol,
+ firstRun: isFirstProtocolSync(walletDir, protocol),
+ onUpdate: onSyncProgress,
+ },
+ async () => {
+ await syncPluginWithProgress(plugin, protocol);
+ return prepareProtocolShield(plugin, protocol, asset as AssetAmount);
+ }
+ );
const rawTxs = toShieldTxs(op);
let approvals: ShieldCall[] = [];
diff --git a/src/lib/stealth/scan.ts b/src/lib/stealth/scan.ts
index 9c82984..049d785 100644
--- a/src/lib/stealth/scan.ts
+++ b/src/lib/stealth/scan.ts
@@ -7,7 +7,7 @@ import { privateKeyToAccount } from "viem/accounts";
import { resolveGetLogsMaxBlockSpan } from "../../host/chunked-get-logs.js";
import type { KohakuPublicClient } from "../../utils/rpc.js";
-import { countSyncRequest, noteSyncFirstRun } from "../../utils/sync-progress.js";
+import { countSyncRequest, noteSyncFirstRun, reportSyncBlockProgress } from "../../utils/sync-progress.js";
import {
STEALTH_ANNOUNCER_ADDRESS,
defaultStealthImportStartBlock,
@@ -220,6 +220,7 @@ export async function scanAndImportStealthAnnouncements(opts: {
let announcementsChecked = 0;
let alreadyKnown = 0;
const name = store.name;
+ const totalBlocks = latest >= fromBlock ? latest - fromBlock + 1n : 0n;
let cursor = fromBlock;
while (cursor <= latest) {
@@ -260,6 +261,7 @@ export async function scanAndImportStealthAnnouncements(opts: {
// Persist cursor after each chunk so a mid-scan interrupt can resume.
storage.setLastScannedBlock(to);
+ reportSyncBlockProgress(to - fromBlock + 1n, totalBlocks);
cursor = to + 1n;
}
diff --git a/src/utils/sync-progress.ts b/src/utils/sync-progress.ts
index ac660f0..f16eb1a 100644
--- a/src/utils/sync-progress.ts
+++ b/src/utils/sync-progress.ts
@@ -20,6 +20,9 @@ type SyncProgressStore = {
onUpdate?: (message: string) => void;
phase?: SyncProgressPhase;
counts: Partial>;
+ /** Inclusive blocks scanned / total for stealth getLogs windows. */
+ blockScanned?: bigint;
+ blockTotal?: bigint;
lastEmit: number;
lastMessage: string;
emitTimer?: ReturnType;
@@ -66,8 +69,13 @@ function formatPrefix(store: SyncProgressStore): string {
const verb = store.source === "stealth" ? "scan" : "sync";
const label = store.firstRun ? `${name} first ${verb}` : `${name} ${verb}`;
const phase = store.phase ? PHASE_LABEL[store.phase] : "starting";
- const count = store.phase ? store.counts[store.phase] : undefined;
- return `${label} · ${phase}${count ? ` · ${count} req` : ""}`;
+ const blocks =
+ store.source === "stealth" &&
+ store.blockScanned != null &&
+ store.blockTotal != null
+ ? ` · ${store.blockScanned.toString()}/${store.blockTotal.toString()} blocks`
+ : "";
+ return `${label} · ${phase}${blocks}`;
}
function formatMessage(store: SyncProgressStore): string {
@@ -140,6 +148,18 @@ export function noteSyncFirstRun(firstRun: boolean): void {
flush(store);
}
+/** Inclusive stealth scan window progress (`scanned/total` blocks). */
+export function reportSyncBlockProgress(
+ scanned: bigint,
+ total: bigint
+): void {
+ const store = als.getStore();
+ if (!store) return;
+ store.blockScanned = scanned;
+ store.blockTotal = total;
+ flush(store);
+}
+
export async function runWithSyncProgress(
opts: {
source: SyncProgressSource;
diff --git a/tests/sync-progress.test.ts b/tests/sync-progress.test.ts
index 976ec7d..37af46e 100644
--- a/tests/sync-progress.test.ts
+++ b/tests/sync-progress.test.ts
@@ -4,6 +4,7 @@ import { describe, it } from "node:test";
import {
countSyncRequest,
noteSyncFirstRun,
+ reportSyncBlockProgress,
reportSyncPhase,
runWithSyncProgress,
} from "../src/utils/sync-progress.js";
@@ -34,27 +35,34 @@ describe("sync progress phases", () => {
assert.doesNotMatch(last, /RPC logs/);
});
- it("keeps a separate count per phase and never resurrects another phase's", async () => {
+ it("omits request counts for privacy protocols", async () => {
const messages = await capture({ source: "privacy-pools" }, () => {
for (let i = 0; i < 7; i++) countSyncRequest("rpc");
for (let i = 0; i < 2; i++) countSyncRequest("asp");
});
- assert.match(messages.at(-1)!, /ASP · 2 req/, messages.join("\n"));
+ const last = messages.at(-1)!;
+ assert.match(last, /ASP/, messages.join("\n"));
+ assert.doesNotMatch(last, /req/, messages.join("\n"));
+ assert.doesNotMatch(last, /RPC logs/, messages.join("\n"));
const backToRpc = await capture({ source: "privacy-pools" }, () => {
countSyncRequest("rpc");
countSyncRequest("saga");
countSyncRequest("rpc");
});
- assert.match(backToRpc.at(-1)!, /RPC logs · 2 req/, backToRpc.join("\n"));
+ assert.match(backToRpc.at(-1)!, /RPC logs/, backToRpc.join("\n"));
+ assert.doesNotMatch(backToRpc.at(-1)!, /req/, backToRpc.join("\n"));
+ assert.doesNotMatch(backToRpc.at(-1)!, /saga CDN/, backToRpc.join("\n"));
});
- it("counts cumulatively across separate getLogs ranges", async () => {
+ it("still switches phase without showing a count", async () => {
const messages = await capture({ source: "privacy-pools" }, () => {
for (let i = 0; i < 11; i++) countSyncRequest("rpc");
for (let i = 0; i < 11; i++) countSyncRequest("rpc");
});
- assert.match(messages.at(-1)!, /RPC logs · 22 req/, messages.join("\n"));
+ const last = messages.at(-1)!;
+ assert.match(last, /RPC logs/, messages.join("\n"));
+ assert.doesNotMatch(last, /req/, messages.join("\n"));
});
it("switches phase without a count via reportSyncPhase", async () => {
@@ -85,16 +93,31 @@ describe("sync progress labels", () => {
assert.match(incremental.at(-1)!, /^Railgun sync · /, incremental.join("\n"));
});
- it("calls the stealth source a scan", async () => {
+ it("calls the stealth source a scan and omits request counts", async () => {
const first = await capture({ source: "stealth", firstRun: true }, () => {
countSyncRequest("rpc");
});
assert.match(first.at(-1)!, /^Stealth first scan · /, first.join("\n"));
+ assert.doesNotMatch(first.at(-1)!, /req/, first.join("\n"));
const incremental = await capture({ source: "stealth" }, () => {
countSyncRequest("rpc");
});
assert.match(incremental.at(-1)!, /^Stealth scan · /, incremental.join("\n"));
+ assert.doesNotMatch(incremental.at(-1)!, /req/, incremental.join("\n"));
+ });
+
+ it("shows stealth block progress as scanned/total", async () => {
+ const messages = await capture({ source: "stealth" }, () => {
+ countSyncRequest("rpc");
+ reportSyncBlockProgress(500n, 1000n);
+ });
+ assert.match(
+ messages.at(-1)!,
+ /RPC logs · 500\/1000 blocks/,
+ messages.join("\n")
+ );
+ assert.doesNotMatch(messages.at(-1)!, /req/, messages.join("\n"));
});
it("lets an inner scope refine the first-run label", async () => {
From 776cbdd0fec1d6b11f085f8cb635325fe8498f09 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=CE=BA=CE=B1=CF=83=CF=83=CE=AC=CE=BD=CE=B4=CF=81=CE=B1=2Ee?=
=?UTF-8?q?th?= <0xDADA@protonmail.com>
Date: Sat, 29 Aug 2026 14:49:10 +0200
Subject: [PATCH 3/3] chore: changelog
---
CHANGELOG.md | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b522ccc..6622d79 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,17 +8,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
-- `--skip-sim` on `shield` dry-runs so counterfactual (unfunded) senders still print call payloads. Cannot be combined with `--broadcast`. `--non-interactive` JSON schema is unchanged; `fees` are zeroed.
-- `--amount-max` on `shield`: spend the account's maximum (ETH minus estimated gas; ERC-20 full balance). Tornado amounts are floored to the smallest pool denomination, then refined if gas would push the deposit below a step.
+- add `--skip-sim` flag on `shield` dry-runs so counterfactual (unfunded) senders still print call payloads. Cannot be combined with `--broadcast` (we always do simulation on broadcast).
+- add `--amount-max` flag on `shield`: spend the account's maximum (ETH minus estimated gas; ERC-20 full balance).
### Changed
-- Privacy-protocol sync progress omits request counts (timer + phase only; `still working` when WASM blocks). Stealth scan keeps the durable `from block · N blocks` prelude and shows `scanned/total` blocks on the animated line (not request counts).
-- `shield` and `unshield` keep a live progress timer during protocol sync (including Railgun WASM), matching `balances`.
+- Privacy-protocol sync progress logs: omit confusing request counts (timer + phase only). Stealth scan now shows `scanned/total` blocks on the progress log.
### Fixed
-- `balances --verbose` no longer re-syncs each privacy protocol to load note details.
+- fix `balances --verbose` to no longer sync privacy protocols twice unnecessarily.
+- `shield` and `unshield` keep a live progress timer during protocol sync (including Railgun WASM), matching `balances`.
## [0.0.4] — 2026-08-25