From 92a5889524cedc2524efddb63dfd7961113162c5 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 15:35:44 +0200 Subject: [PATCH] chore: add sepolia DAI, better shield gas est --- CHANGELOG.md | 1 + README.md | 2 +- src/commands/shield.ts | 45 ++++++++++++++++++++++- src/lib/shield-flow.ts | 26 ++++++++++++- src/utils/fee-preview.ts | 14 +++++-- src/utils/shield-max.ts | 77 ++++++++++++++++++++++++++++++++++----- src/utils/tokens-util.ts | 11 ++++-- src/utils/viem-tx.ts | 6 +++ tests/shield-max.test.ts | 67 +++++++++++++++++++++++----------- tests/tokens-util.test.ts | 28 ++++++++++++++ 10 files changed, 236 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6622d79..b888169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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). +- Default Sepolia token list includes DAI (`0xFF34B3d4Aee8ddCd6F9AFFFB6Fe49bD371b8a357`). ### Changed diff --git a/README.md b/README.md index 4621fb5..ddc685c 100644 --- a/README.md +++ b/README.md @@ -330,7 +330,7 @@ By default, private balances are included only for `DEFAULT_PRIVACY_PROTOCOL` (i **Interactive:** wallet picker, password, loading spinner, formatted tables. -Default Sepolia ERC-20s include USDC and WETH; mainnet adds USDC, USDT, DAI, WETH. +Default Sepolia ERC-20s include USDC, DAI, and WETH; mainnet adds USDC, USDT, DAI, WETH. **Examples:** diff --git a/src/commands/shield.ts b/src/commands/shield.ts index 551b9c7..ba841e5 100644 --- a/src/commands/shield.ts +++ b/src/commands/shield.ts @@ -86,6 +86,7 @@ import { import { computeShieldMaxAmount, estimateShieldGasReserveWei, + eoaShieldSendParams, refineShieldMaxAmount, SHIELD_GAS_LIMIT, shieldMaxFeeReserveWei, @@ -844,6 +845,9 @@ export function registerShieldCommand(program: Command): void { maxFeePerGasWei: feePreview.maxFeePerGasWei ? BigInt(feePreview.maxFeePerGasWei) : undefined, + gasLimit: feePreview.gasLimit + ? BigInt(feePreview.gasLimit) + : undefined, }); const refined = refineShieldMaxAmount({ isEth: tokenMeta.isEth, @@ -1085,6 +1089,43 @@ export function registerShieldCommand(program: Command): void { failure: "Shield transaction failed.", }, async () => { + const preview = await estimateEoaTxFeePreview( + rpcForHost, + { + to: call.to, + from: senderAddress, + data: call.data, + value: call.value, + }, + SHIELD_GAS_LIMIT + ); + const params = eoaShieldSendParams({ + estimatedGas: BigInt(preview.gasLimit ?? SHIELD_GAS_LIMIT), + maxFeePerGas: BigInt(preview.maxFeePerGasWei ?? 0n), + maxPriorityFeePerGas: BigInt( + preview.maxPriorityFeePerGasWei ?? 0n + ), + value: call.value, + balance: + usedAmountMax && amountMaxEthBalance !== undefined + ? amountMaxEthBalance + : undefined, + }); + if (params.maxFeePerGas === 0n) { + throw new Error( + "Could not determine gas price for shield broadcast." + ); + } + if ( + usedAmountMax && + amountMaxEthBalance !== undefined && + params.gas * params.maxFeePerGas + call.value > + amountMaxEthBalance + ) { + throw new Error( + `Insufficient ETH for --amount-max after reserving ~${formatUnits(params.gas * params.maxFeePerGas, 18)} ETH for gas.` + ); + } const hash = await sendTransactionAndWait( walletClient, rpcForHost, @@ -1092,7 +1133,9 @@ export function registerShieldCommand(program: Command): void { to: call.to, data: call.data, value: call.value, - gas: SHIELD_GAS_LIMIT, + gas: params.gas, + maxFeePerGas: params.maxFeePerGas, + maxPriorityFeePerGas: params.maxPriorityFeePerGas, } ); return { hash }; diff --git a/src/lib/shield-flow.ts b/src/lib/shield-flow.ts index 562124e..8bd6378 100644 --- a/src/lib/shield-flow.ts +++ b/src/lib/shield-flow.ts @@ -18,7 +18,8 @@ import { import { assertTornadoDepositAmount } from "../utils/tornado-pools.js"; import type { BalancesSnapshot } from "./balances-snapshot.js"; import { makePublicAccountsStorage } from "../utils/public-accounts"; -import { SHIELD_GAS_LIMIT } from "../utils/shield-max.js"; +import { estimateEoaTxFeePreview } from "../utils/fee-preview.js"; +import { eoaShieldSendParams, SHIELD_GAS_LIMIT } from "../utils/shield-max.js"; import { makeStealthAccountsStorage, parseStealthIndex, @@ -686,11 +687,32 @@ export async function broadcastShield(opts: { rpc, opts.rpcUrl ); + const preview = await estimateEoaTxFeePreview( + rpc, + { + to: tx.to, + from: senderAddress, + data: tx.data, + value: tx.value, + }, + SHIELD_GAS_LIMIT + ); + const params = eoaShieldSendParams({ + estimatedGas: BigInt(preview.gasLimit ?? SHIELD_GAS_LIMIT), + maxFeePerGas: BigInt(preview.maxFeePerGasWei ?? 0n), + maxPriorityFeePerGas: BigInt(preview.maxPriorityFeePerGasWei ?? 0n), + value: tx.value, + }); + if (params.maxFeePerGas === 0n) { + throw new Error("Could not determine gas price for shield broadcast."); + } const hash = await sendTransactionAndWait(walletClient, rpc, { to: tx.to, data: tx.data, value: tx.value, - gas: SHIELD_GAS_LIMIT, + gas: params.gas, + maxFeePerGas: params.maxFeePerGas, + maxPriorityFeePerGas: params.maxPriorityFeePerGas, }); return [{ type: "shield", hash }]; } finally { diff --git a/src/utils/fee-preview.ts b/src/utils/fee-preview.ts index 8a054a4..2391e6e 100644 --- a/src/utils/fee-preview.ts +++ b/src/utils/fee-preview.ts @@ -19,6 +19,7 @@ export type FeePreview = { estimatedMaxFormatted: string; asset: string; maxFeePerGasWei?: string; + maxPriorityFeePerGasWei?: string; gasLimit?: string; relayFeeBps?: string; components?: Array<{ @@ -67,7 +68,9 @@ export function printFeePreview(fees: FeePreview): void { } } -async function resolveMaxFeePerGas(client: KohakuPublicClient): Promise { +export async function resolveEip1559Fees( + client: KohakuPublicClient +): Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }> { const latest = await client.getBlock({ blockTag: "latest" }); const base = latest.baseFeePerGas ?? 0n; let maxFeePerGas = (base * 110n) / 100n; @@ -78,7 +81,11 @@ async function resolveMaxFeePerGas(client: KohakuPublicClient): Promise if (maxFeePerGas === 0n && feeData.gasPrice != null) { maxFeePerGas = feeData.gasPrice; } - return maxFeePerGas; + let maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? 0n; + if (maxPriorityFeePerGas > maxFeePerGas) { + maxPriorityFeePerGas = maxFeePerGas; + } + return { maxFeePerGas, maxPriorityFeePerGas }; } /** Estimate max network fee for a single EIP-1559-style transaction. */ @@ -87,7 +94,7 @@ export async function estimateEoaTxFeePreview( tx: { to: string; from: string; data?: string; value?: bigint }, gasLimitFallback = 100_000n ): Promise { - const maxFeePerGas = await resolveMaxFeePerGas(client); + const { maxFeePerGas, maxPriorityFeePerGas } = await resolveEip1559Fees(client); let gasLimit = gasLimitFallback; let usedFallback = true; try { @@ -109,6 +116,7 @@ export async function estimateEoaTxFeePreview( estimatedMaxFormatted: formatFeeAmount(estimatedMax, 18, "ETH"), asset: "ETH", maxFeePerGasWei: maxFeePerGas.toString(), + maxPriorityFeePerGasWei: maxPriorityFeePerGas.toString(), gasLimit: gasLimit.toString(), note: usedFallback ? "gas limit fallback × maxFeePerGas; actual usually lower" diff --git a/src/utils/shield-max.ts b/src/utils/shield-max.ts index 5a30d36..d36d7f8 100644 --- a/src/utils/shield-max.ts +++ b/src/utils/shield-max.ts @@ -1,9 +1,9 @@ import { makePublicClient } from "./rpc.js"; -/** Same gas cap shield uses when broadcasting a single EOA deposit. */ +/** Hard cap if estimateGas fails; EOA send uses padded estimateGas, not this raw. */ export const SHIELD_GAS_LIMIT = 2_000_000n; -/** 1.3× pad on gas × maxFee so --amount-max survives fee ticks before send. */ +/** 1.3× pad on estimated gas (tx gasLimit) and on gas × maxFee (amount-max reserve). */ export const SHIELD_FEE_PAD_NUM = 13n; export const SHIELD_FEE_PAD_DEN = 10n; @@ -11,29 +11,88 @@ export function padShieldFeeWei(feeWei: bigint): bigint { return (feeWei * SHIELD_FEE_PAD_NUM) / SHIELD_FEE_PAD_DEN; } -/** Node checks `gasLimit × maxFee + value`; EOA broadcast pins gas to SHIELD_GAS_LIMIT. */ +/** estimateGas × 1.3, capped at SHIELD_GAS_LIMIT — this is the gasLimit we put on the tx. */ +export function paddedShieldGasLimit(estimatedGas: bigint): bigint { + if (estimatedGas <= 0n) return SHIELD_GAS_LIMIT; + const padded = padShieldFeeWei(estimatedGas); + if (padded > SHIELD_GAS_LIMIT) return SHIELD_GAS_LIMIT; + return padded < estimatedGas ? estimatedGas : padded; +} + +/** First-pass reserve before we have a tx to estimateGas (2M × maxFee × 1.3). */ export function eoaShieldFeeReserveWei(maxFeePerGas: bigint): bigint { return padShieldFeeWei(SHIELD_GAS_LIMIT * maxFeePerGas); } /** * Wei to reserve when refining --amount-max after a live fee preview. - * EOA must not use a tighter `estimateGas` — the signed tx still sets - * `gas: SHIELD_GAS_LIMIT`, and the node charges the full limit at submission. + * Matches the signed tx: padded `estimateGas` × maxFee × 1.3 (not the 2M cap). */ export function shieldMaxFeeReserveWei(opts: { batch: boolean; estimatedMaxWei: bigint; maxFeePerGasWei?: bigint; + /** Raw estimateGas (EOA). When set, reserve uses paddedShieldGasLimit of this. */ + gasLimit?: bigint; }): bigint { - if (!opts.batch && opts.maxFeePerGasWei != null && opts.maxFeePerGasWei > 0n) { - const fromLimit = eoaShieldFeeReserveWei(opts.maxFeePerGasWei); - const fromEstimate = padShieldFeeWei(opts.estimatedMaxWei); - return fromLimit > fromEstimate ? fromLimit : fromEstimate; + if (opts.batch) return padShieldFeeWei(opts.estimatedMaxWei); + const maxFee = opts.maxFeePerGasWei ?? 0n; + if (maxFee > 0n) { + const gas = + opts.gasLimit != null && opts.gasLimit > 0n + ? paddedShieldGasLimit(opts.gasLimit) + : SHIELD_GAS_LIMIT; + return padShieldFeeWei(gas * maxFee); } return padShieldFeeWei(opts.estimatedMaxWei); } +/** Highest maxFeePerGas such that `gasLimit × maxFee + value <= balance`. */ +export function maxFeeFittingBalance(opts: { + gasLimit: bigint; + value: bigint; + balance: bigint; +}): bigint | null { + if (opts.gasLimit === 0n) return null; + if (opts.balance <= opts.value) return null; + return (opts.balance - opts.value) / opts.gasLimit; +} + +/** + * Gas + EIP-1559 fees to put on the signed EOA shield. + * Pins fees (viem must not re-estimate) and, when `balance` is set (--amount-max), + * caps maxFeePerGas so the node's `gas × maxFee + value` check always passes. + */ +export function eoaShieldSendParams(opts: { + estimatedGas: bigint; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; + value: bigint; + balance?: bigint; +}): { + gas: bigint; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; +} { + const gas = paddedShieldGasLimit(opts.estimatedGas); + let maxFeePerGas = opts.maxFeePerGas; + let maxPriorityFeePerGas = opts.maxPriorityFeePerGas; + if (opts.balance != null) { + const fit = maxFeeFittingBalance({ + gasLimit: gas, + value: opts.value, + balance: opts.balance, + }); + if (fit != null && fit > 0n && maxFeePerGas > fit) { + maxFeePerGas = fit; + } + } + if (maxPriorityFeePerGas > maxFeePerGas) { + maxPriorityFeePerGas = maxFeePerGas; + } + return { gas, maxFeePerGas, maxPriorityFeePerGas }; +} + /** * Conservative wei reserve for a shield (gas × fee × pad). * Uses ~110% of latest base fee, same pattern as transfer --amount-max. diff --git a/src/utils/tokens-util.ts b/src/utils/tokens-util.ts index 4132dd5..a04130b 100644 --- a/src/utils/tokens-util.ts +++ b/src/utils/tokens-util.ts @@ -116,6 +116,7 @@ const MAINNET_KNOWN_TOKENS: KnownErc20[] = [ /** Sepolia ERC-20s resolvable by `--token `. */ const SEPOLIA_KNOWN_TOKENS: KnownErc20[] = [ knownToken("USDC", "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", 6), + knownToken("DAI", "0xFF34B3d4Aee8ddCd6F9AFFFB6Fe49bD371b8a357", 18), knownToken("WETH", "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", 18), ]; @@ -165,10 +166,6 @@ export type MergedPublicTokenList = { knownMetaByLower: Map; }; -/** - * Merges chain default tokens with `--tokensList` extras. CLI addresses that duplicate - * a default are skipped; unknown addresses are appended for RPC metadata + balanceOf. - */ /** Canonical WETH for a chain (Railgun shields ETH as this ERC-20). */ export function wethAddressForChain( chainId: string | bigint @@ -178,6 +175,12 @@ export function wethAddressForChain( ?.address; } +/** + * Merges chain default tokens with `--tokensList` extras (and private ERC-20s). + * Addresses that already appear in the default list are skipped; unknown + * addresses are appended for RPC metadata + balanceOf. Comparison is + * case-insensitive, so the merged list is a set of unique contracts. + */ export function mergeDefaultAndExtraErc20s( chainId: string, extraFromCli: `0x${string}`[] diff --git a/src/utils/viem-tx.ts b/src/utils/viem-tx.ts index 9d3a198..a9e9df9 100644 --- a/src/utils/viem-tx.ts +++ b/src/utils/viem-tx.ts @@ -80,6 +80,8 @@ export async function sendTransactionAndWait( data?: Hex | string; value?: bigint; gas?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; } ): Promise<`0x${string}`> { const hash = await walletClient.sendTransaction({ @@ -89,6 +91,10 @@ export async function sendTransactionAndWait( data: (tx.data ?? "0x") as Hex, value: tx.value ?? 0n, gas: tx.gas, + ...(tx.maxFeePerGas != null ? { maxFeePerGas: tx.maxFeePerGas } : {}), + ...(tx.maxPriorityFeePerGas != null + ? { maxPriorityFeePerGas: tx.maxPriorityFeePerGas } + : {}), }); await publicClient.waitForTransactionReceipt({ hash }); return hash; diff --git a/tests/shield-max.test.ts b/tests/shield-max.test.ts index 23b6fa0..263d13a 100644 --- a/tests/shield-max.test.ts +++ b/tests/shield-max.test.ts @@ -4,6 +4,8 @@ import { describe, it } from "node:test"; import { computeShieldMaxAmount, eoaShieldFeeReserveWei, + eoaShieldSendParams, + paddedShieldGasLimit, padShieldFeeWei, refineShieldMaxAmount, SHIELD_FEE_PAD_DEN, @@ -188,47 +190,70 @@ describe("refineShieldMaxAmount", () => { }); }); -describe("padShieldFeeWei / shieldMaxFeeReserveWei", () => { +describe("padShieldFeeWei / eoaShieldSendParams", () => { it("pads 1.3×", () => { assert.equal(SHIELD_FEE_PAD_NUM, 13n); assert.equal(SHIELD_FEE_PAD_DEN, 10n); assert.equal(padShieldFeeWei(1000n), 1300n); + assert.equal(paddedShieldGasLimit(1_000_000n), 1_300_000n); + assert.equal(paddedShieldGasLimit(SHIELD_GAS_LIMIT), SHIELD_GAS_LIMIT); }); - it("EOA reserve uses the 2M broadcast gas cap, not a tighter estimateGas", () => { + it("EOA refine reserve uses padded estimateGas, not the 2M cap", () => { const maxFeePerGas = 117_279_367n; const estimateGas = 1_088_123n; - const estimatedMax = estimateGas * maxFeePerGas; const reserved = shieldMaxFeeReserveWei({ batch: false, - estimatedMaxWei: estimatedMax, + estimatedMaxWei: estimateGas * maxFeePerGas, maxFeePerGasWei: maxFeePerGas, + gasLimit: estimateGas, }); - const nodeCheck = SHIELD_GAS_LIMIT * maxFeePerGas; - assert.equal(reserved, eoaShieldFeeReserveWei(maxFeePerGas)); - assert.ok(reserved >= nodeCheck); - assert.ok(reserved > estimatedMax); + const sendGas = paddedShieldGasLimit(estimateGas); + assert.equal(reserved, padShieldFeeWei(sendGas * maxFeePerGas)); + assert.ok(reserved < eoaShieldFeeReserveWei(maxFeePerGas)); }); - it("leaves enough ETH for the node's gasLimit × maxFee check", () => { + it("first-pass (no estimateGas) still reserves against the 2M cap", () => { const maxFeePerGas = 117_279_367n; - const balance = 16_235_115_418_919_695n; - const estimateGas = 1_088_123n; - const tooTight = estimateGas * maxFeePerGas; const reserved = shieldMaxFeeReserveWei({ batch: false, - estimatedMaxWei: tooTight, + estimatedMaxWei: 1n, maxFeePerGasWei: maxFeePerGas, }); - const amount = refineShieldMaxAmount({ - isEth: true, - protocol: "railgun", - currentAmount: balance - tooTight, - ethBalance: balance, - estimatedFeeWei: reserved, + assert.equal(reserved, eoaShieldFeeReserveWei(maxFeePerGas)); + }); + + it("send gas matches padded estimateGas so a 2M node check is not required", () => { + const balance = 16_235_115_418_919_695n; + const value = 16_012_153_622_719_695n; + const estimateGas = 803_525n; + const sendMaxFee = 156_485_998n; + const params = eoaShieldSendParams({ + estimatedGas: estimateGas, + maxFeePerGas: sendMaxFee, + maxPriorityFeePerGas: 94_426n, + value, + balance, + }); + assert.equal(params.gas, paddedShieldGasLimit(estimateGas)); + assert.equal(params.maxFeePerGas, sendMaxFee); + assert.ok(params.gas * params.maxFeePerGas + value <= balance); + assert.ok(SHIELD_GAS_LIMIT * sendMaxFee + value > balance); + }); + + it("caps maxFeePerGas when even padded gas × fee would exceed leftover ETH", () => { + const balance = 16_235_115_418_919_695n; + const value = 16_012_153_622_719_695n; + const params = eoaShieldSendParams({ + estimatedGas: 803_525n, + maxFeePerGas: 500_000_000n, + maxPriorityFeePerGas: 94_426n, + value, + balance, }); - const nodeCost = SHIELD_GAS_LIMIT * maxFeePerGas + amount; - assert.ok(nodeCost <= balance); + assert.ok(params.maxFeePerGas < 500_000_000n); + assert.ok(params.maxFeePerGas > 0n); + assert.ok(params.gas * params.maxFeePerGas + value <= balance); }); it("batch UserOp reserve pads the bundler estimate", () => { diff --git a/tests/tokens-util.test.ts b/tests/tokens-util.test.ts index b75fc8b..37fbbc3 100644 --- a/tests/tokens-util.test.ts +++ b/tests/tokens-util.test.ts @@ -16,6 +16,7 @@ import { const DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const SEPOLIA_DAI = "0xFF34B3d4Aee8ddCd6F9AFFFB6Fe49bD371b8a357"; describe("isPrivateBalanceNativeEth", () => { it("recognizes the EEE… sentinel and zero-address variants", () => { @@ -161,4 +162,31 @@ describe("mergeDefaultAndExtraErc20s", () => { ); assert.equal(merged.knownMetaByLower.get(USDC.toLowerCase())?.symbol, "USDC"); }); + + it("does not double Sepolia DAI when --tokensList repeats the default", () => { + const merged = mergeDefaultAndExtraErc20s("11155111", [ + SEPOLIA_DAI.toLowerCase() as `0x${string}`, + getAddress(SEPOLIA_DAI) as `0x${string}`, + ]); + const daiRows = merged.erc20Addresses.filter( + (a) => a.toLowerCase() === SEPOLIA_DAI.toLowerCase() + ); + assert.equal(daiRows.length, 1); + assert.equal( + merged.knownMetaByLower.get(SEPOLIA_DAI.toLowerCase())?.symbol, + "DAI" + ); + }); + + it("dedupes repeated extras against each other", () => { + const extra = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const merged = mergeDefaultAndExtraErc20s("11155111", [ + extra, + extra.toLowerCase() as `0x${string}`, + ]); + const extraCount = merged.erc20Addresses.filter( + (a) => a.toLowerCase() === extra.toLowerCase() + ).length; + assert.equal(extraCount, 1); + }); });