Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
45 changes: 44 additions & 1 deletion src/commands/shield.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
import {
computeShieldMaxAmount,
estimateShieldGasReserveWei,
eoaShieldSendParams,
refineShieldMaxAmount,
SHIELD_GAS_LIMIT,
shieldMaxFeeReserveWei,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1085,14 +1089,53 @@ 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,
{
to: call.to,
data: call.data,
value: call.value,
gas: SHIELD_GAS_LIMIT,
gas: params.gas,
maxFeePerGas: params.maxFeePerGas,
maxPriorityFeePerGas: params.maxPriorityFeePerGas,
}
);
return { hash };
Expand Down
26 changes: 24 additions & 2 deletions src/lib/shield-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 11 additions & 3 deletions src/utils/fee-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type FeePreview = {
estimatedMaxFormatted: string;
asset: string;
maxFeePerGasWei?: string;
maxPriorityFeePerGasWei?: string;
gasLimit?: string;
relayFeeBps?: string;
components?: Array<{
Expand Down Expand Up @@ -67,7 +68,9 @@ export function printFeePreview(fees: FeePreview): void {
}
}

async function resolveMaxFeePerGas(client: KohakuPublicClient): Promise<bigint> {
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;
Expand All @@ -78,7 +81,11 @@ async function resolveMaxFeePerGas(client: KohakuPublicClient): Promise<bigint>
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. */
Expand All @@ -87,7 +94,7 @@ export async function estimateEoaTxFeePreview(
tx: { to: string; from: string; data?: string; value?: bigint },
gasLimitFallback = 100_000n
): Promise<FeePreview> {
const maxFeePerGas = await resolveMaxFeePerGas(client);
const { maxFeePerGas, maxPriorityFeePerGas } = await resolveEip1559Fees(client);
let gasLimit = gasLimitFallback;
let usedFallback = true;
try {
Expand All @@ -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"
Expand Down
77 changes: 68 additions & 9 deletions src/utils/shield-max.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,98 @@
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;

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.
Expand Down
11 changes: 7 additions & 4 deletions src/utils/tokens-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const MAINNET_KNOWN_TOKENS: KnownErc20[] = [
/** Sepolia ERC-20s resolvable by `--token <symbol>`. */
const SEPOLIA_KNOWN_TOKENS: KnownErc20[] = [
knownToken("USDC", "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", 6),
knownToken("DAI", "0xFF34B3d4Aee8ddCd6F9AFFFB6Fe49bD371b8a357", 18),
knownToken("WETH", "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", 18),
];

Expand Down Expand Up @@ -165,10 +166,6 @@ export type MergedPublicTokenList = {
knownMetaByLower: Map<string, { symbol: string; decimals: number }>;
};

/**
* 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
Expand All @@ -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}`[]
Expand Down
6 changes: 6 additions & 0 deletions src/utils/viem-tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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;
Expand Down
Loading