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