diff --git a/eslint.config.mjs b/eslint.config.mjs index 485b314..1cfdbb8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,7 +22,7 @@ export default [ js.configs.recommended, ...tseslint.configs.recommendedTypeChecked.map((cfg) => ({ ...cfg, - files: ['**/*.ts', '**/*.tsx'], + files: ['**/*.ts', 'src/adapters/**/*.ts'], languageOptions: { ...cfg.languageOptions, parserOptions: { @@ -33,7 +33,7 @@ export default [ }, })), { - files: ['**/*.ts', '**/*.tsx'], + files: ['**/*.ts', 'src/adapters/**/*.ts'], rules: { 'no-console': 'warn', '@typescript-eslint/consistent-type-imports': 'warn', diff --git a/src/adapters/__tests__/adapter-harness.ts b/src/adapters/__tests__/adapter-harness.ts index 1ce0391..71f5232 100644 --- a/src/adapters/__tests__/adapter-harness.ts +++ b/src/adapters/__tests__/adapter-harness.ts @@ -15,6 +15,7 @@ import { L2NativeTokenVaultABI, } from '../../core/internal/abi-registry.ts'; import type { Address } from '../../core/types/primitives'; +import { GasPlanner, DEFAULT_GAS_POLICIES } from '../../core/gas'; const IBridgehub = new Interface(IBridgehubABI as any); const IL1AssetRouter = new Interface(IL1AssetRouterABI as any); @@ -390,6 +391,7 @@ export type DepositTestContext = { gasPrice?: bigint; gasPriceForBaseCost: bigint; }; + gas: GasPlanner; } & Record; export function makeDepositContext( @@ -413,6 +415,7 @@ export function makeDepositContext( refundRecipient: ADAPTER_TEST_ADDRESSES.signer, operatorTip: 7n, fee: baseFee, + gas: new GasPlanner(DEFAULT_GAS_POLICIES), }; const merged = { @@ -441,6 +444,7 @@ export type WithdrawalTestContext = { l2GasLimit: bigint; gasBufferPct: number; fee?: Record; + gas: GasPlanner; } & Record; export function makeWithdrawalContext( @@ -461,6 +465,7 @@ export function makeWithdrawalContext( l2GasLimit: 300_000n, gasBufferPct: 15, fee: { maxFeePerGas: 1n, maxPriorityFeePerGas: 1n }, + gas: new GasPlanner(DEFAULT_GAS_POLICIES), }; return { diff --git a/src/adapters/__tests__/deposits/erc20-nonbase.test.ts b/src/adapters/__tests__/deposits/erc20-nonbase.test.ts index 3bcd1ae..52a6950 100644 --- a/src/adapters/__tests__/deposits/erc20-nonbase.test.ts +++ b/src/adapters/__tests__/deposits/erc20-nonbase.test.ts @@ -26,11 +26,8 @@ const ERC20_TOKEN = '0x3333333333333333333333333333333333333333' as const; const BASE_TOKEN = ADAPTER_TEST_ADDRESSES.baseTokenFor324; const RECEIVER = '0x4444444444444444444444444444444444444444' as const; -const withBuffer = (x: bigint) => (x * 10100n) / 10000n; - -function expectedMint(kind: AdapterKind, baseCost: bigint, operatorTip: bigint) { - const raw = baseCost + operatorTip; - return kind === 'viem' ? withBuffer(raw) : raw; +function expectedMint(baseCost: bigint, operatorTip: bigint) { + return baseCost + operatorTip; } describeForAdapters('adapters/deposits/routeErc20NonBase', (kind, factory) => { @@ -39,7 +36,7 @@ describeForAdapters('adapters/deposits/routeErc20NonBase', (kind, factory) => { const ctx = makeDepositContext(harness, { l2GasLimit: 600_000n }); const amount = 1_000n; const baseCost = 3_000n; - const mintValue = expectedMint(kind, baseCost, ctx.operatorTip); + const mintValue = expectedMint(baseCost, ctx.operatorTip); setBridgehubBaseToken(harness, ctx, FORMAL_ETH_ADDRESS); setBridgehubBaseCost(harness, ctx, baseCost, { l2GasLimit: MIN_L2_GAS_FOR_ERC20 }); @@ -91,7 +88,7 @@ describeForAdapters('adapters/deposits/routeErc20NonBase', (kind, factory) => { const ctx = makeDepositContext(harness); const amount = 5_000n; const baseCost = 4_000n; - const mintValue = expectedMint(kind, baseCost, ctx.operatorTip); + const mintValue = expectedMint(baseCost, ctx.operatorTip); setBridgehubBaseToken(harness, ctx, BASE_TOKEN); setBridgehubBaseCost(harness, ctx, baseCost, { l2GasLimit: MIN_L2_GAS_FOR_ERC20 }); @@ -150,7 +147,7 @@ describeForAdapters('adapters/deposits/routeErc20NonBase', (kind, factory) => { const ctx = makeDepositContext(harness); const amount = 2_000n; const baseCost = 3_000n; - const mintValue = expectedMint('ethers', baseCost, ctx.operatorTip); + const mintValue = expectedMint(baseCost, ctx.operatorTip); setBridgehubBaseToken(harness, ctx, BASE_TOKEN); setBridgehubBaseCost(harness, ctx, baseCost, { l2GasLimit: MIN_L2_GAS_FOR_ERC20 }); diff --git a/src/adapters/ethers/resources/deposits/context.ts b/src/adapters/ethers/resources/deposits/context.ts index 4b69375..c2b0cb2 100644 --- a/src/adapters/ethers/resources/deposits/context.ts +++ b/src/adapters/ethers/resources/deposits/context.ts @@ -6,6 +6,7 @@ import { getFeeOverrides } from '../utils'; import { pickDepositRoute } from '../../../../core/resources/deposits/route'; import type { DepositParams, DepositRoute } from '../../../../core/types/flows/deposits'; import type { CommonCtx } from '../../../../core/types/flows/base'; +import { GasPlanner, DEFAULT_GAS_POLICIES } from '../../../../core/gas'; // Common context for building deposit (L1-L2) transactions export interface BuildCtx extends CommonCtx { @@ -18,6 +19,7 @@ export interface BuildCtx extends CommonCtx { gasPerPubdata: bigint; operatorTip: bigint; refundRecipient: Address; + gas: GasPlanner; } // Prepare a common context for deposit operations @@ -35,6 +37,8 @@ export async function commonCtx(p: DepositParams, client: EthersClient) { const route = await pickDepositRoute(client, BigInt(chainId), p.token); + const gasPlanner = new GasPlanner(DEFAULT_GAS_POLICIES); + return { client, l1AssetRouter, @@ -47,5 +51,6 @@ export async function commonCtx(p: DepositParams, client: EthersClient) { gasPerPubdata, operatorTip, refundRecipient, + gas: gasPlanner, } satisfies BuildCtx & { route: DepositRoute }; } diff --git a/src/adapters/ethers/resources/deposits/index.ts b/src/adapters/ethers/resources/deposits/index.ts index 2bf6ff0..6873d36 100644 --- a/src/adapters/ethers/resources/deposits/index.ts +++ b/src/adapters/ethers/resources/deposits/index.ts @@ -109,7 +109,7 @@ export function createDepositsResource(client: EthersClient): DepositsResource { await ROUTES[route].preflight?.(p, ctx); const { steps, approvals, quoteExtras } = await ROUTES[route].build(p, ctx); - const { baseCost, mintValue } = quoteExtras; + const { baseCost, mintValue, gasPlan, baseToken, baseIsEth } = quoteExtras; return { route: ctx.route, @@ -120,6 +120,9 @@ export function createDepositsResource(client: EthersClient): DepositsResource { mintValue, suggestedL2GasLimit: ctx.l2GasLimit, gasPerPubdata: ctx.gasPerPubdata, + gasPlan, + baseToken, + baseIsEth, }, steps, }; diff --git a/src/adapters/ethers/resources/deposits/routes/erc20-base.ts b/src/adapters/ethers/resources/deposits/routes/erc20-base.ts index 0cbfae8..e06fb54 100644 --- a/src/adapters/ethers/resources/deposits/routes/erc20-base.ts +++ b/src/adapters/ethers/resources/deposits/routes/erc20-base.ts @@ -11,12 +11,6 @@ import { normalizeAddrEq, isETH } from '../../../../../core/utils/addr'; // error handling const { wrapAs } = createErrorHandlers('deposits'); -// TODO: all gas buffers need to be moved to a dedicated resource -// this is getting messy -const BASE_COST_BUFFER_BPS = 100n; // 1% -const BPS = 10_000n; -const withBuffer = (x: bigint) => (x * (BPS + BASE_COST_BUFFER_BPS)) / BPS; - // ERC20 deposit where the deposit token IS the target chain's base token (base ≠ ETH). export function routeErc20Base(): DepositRouteStrategy { return { @@ -91,10 +85,16 @@ export function routeErc20Base(): DepositRouteStrategy { )) as bigint; const baseCost = BigInt(rawBaseCost); - // Direct path: mintValue must cover fee + the L2 msg.value (amount) → plus a small buffer + // Direct path: mintValue must cover fee + the L2 msg.value (amount) const l2Value = p.amount; - const rawMintValue = baseCost + ctx.operatorTip + l2Value; - const mintValue = withBuffer(rawMintValue); + const baseCostQuote = ctx.gas.applyBaseCost( + 'base-cost:bridgehub:erc20-base', + 'deposit.base-cost.erc20-base', + baseCost, + { operatorTip: ctx.operatorTip, extras: l2Value }, + ); + + const mintValue = baseCostQuote.recommended; const approvals: ApprovalNeed[] = []; const steps: PlanStep[] = []; @@ -118,11 +118,32 @@ export function routeErc20Base(): DepositRouteStrategy { ctx.l1AssetRouter, mintValue, ]); + const approveTx: TransactionRequest = { + to: baseToken, + data, + from: ctx.sender, + ...ctx.fee, + }; + const approveGas = await ctx.gas.ensure( + `approve:${baseToken}:${ctx.l1AssetRouter}`, + 'deposit.approval.l1', + approveTx, + { + estimator: (request) => + wrapAs('RPC', OP_DEPOSITS.base.estGas, () => ctx.client.l1.estimateGas(request), { + ctx: { where: 'l1.estimateGas', to: baseToken }, + message: 'Failed to estimate gas for ERC-20 approval.', + }), + }, + ); + if (approveGas.recommended != null) { + approveTx.gasLimit = approveGas.recommended; + } steps.push({ key: `approve:${baseToken}:${ctx.l1AssetRouter}`, kind: 'approve', description: 'Approve base token for mintValue', - tx: { to: baseToken, data, from: ctx.sender, ...ctx.fee }, + tx: approveTx, }); } } @@ -151,19 +172,20 @@ export function routeErc20Base(): DepositRouteStrategy { ...ctx.fee, }; - try { - const est = await wrapAs( - 'RPC', - OP_DEPOSITS.base.estGas, - () => ctx.client.l1.estimateGas(tx), - { - ctx: { where: 'l1.estimateGas', to: ctx.bridgehub }, - message: 'Failed to estimate gas for Bridgehub request.', - }, - ); - tx.gasLimit = (BigInt(est) * 115n) / 100n; - } catch { - // ignore; + const gas = await ctx.gas.ensure( + 'bridgehub:direct:erc20-base', + 'deposit.bridgehub.direct.l1', + tx, + { + estimator: (request) => + wrapAs('RPC', OP_DEPOSITS.base.estGas, () => ctx.client.l1.estimateGas(request), { + ctx: { where: 'l1.estimateGas', to: ctx.bridgehub }, + message: 'Failed to estimate gas for Bridgehub request.', + }), + }, + ); + if (gas.recommended != null) { + tx.gasLimit = gas.recommended; } steps.push({ @@ -173,7 +195,11 @@ export function routeErc20Base(): DepositRouteStrategy { tx, }); - return { steps, approvals, quoteExtras: { baseCost, mintValue } }; + return { + steps, + approvals, + quoteExtras: { baseCost, mintValue, gasPlan: ctx.gas.snapshot() }, + }; }, }; } diff --git a/src/adapters/ethers/resources/deposits/routes/erc20-nonbase.ts b/src/adapters/ethers/resources/deposits/routes/erc20-nonbase.ts index 01b7196..a18149f 100644 --- a/src/adapters/ethers/resources/deposits/routes/erc20-nonbase.ts +++ b/src/adapters/ethers/resources/deposits/routes/erc20-nonbase.ts @@ -74,7 +74,13 @@ export function routeErc20NonBase(): DepositRouteStrategy { )) as bigint; const baseCost = BigInt(rawBaseCost); - const mintValue = baseCost + ctx.operatorTip; + const baseCostQuote = ctx.gas.applyBaseCost( + 'base-cost:bridgehub:erc20-nonbase', + 'deposit.base-cost.erc20-nonbase', + baseCost, + { operatorTip: ctx.operatorTip }, + ); + const mintValue = baseCostQuote.recommended; // Approvals (branch by who pays fees) const approvals: ApprovalNeed[] = []; @@ -101,11 +107,37 @@ export function routeErc20NonBase(): DepositRouteStrategy { assetRouter, p.amount, ]); + const approveTx: TransactionRequest = { + to: p.token, + data, + from: ctx.sender, + ...ctx.fee, + }; + const approveGas = await ctx.gas.ensure( + `approve:${p.token}:${assetRouter}`, + 'deposit.approval.l1', + approveTx, + { + estimator: (request) => + wrapAs( + 'RPC', + OP_DEPOSITS.nonbase.estGas, + () => ctx.client.l1.estimateGas(request), + { + ctx: { where: 'l1.estimateGas', to: p.token }, + message: 'Failed to estimate gas for ERC-20 approval (deposit token).', + }, + ), + }, + ); + if (approveGas.recommended != null) { + approveTx.gasLimit = approveGas.recommended; + } steps.push({ key: `approve:${p.token}:${assetRouter}`, kind: 'approve', description: `Approve ${p.amount} for router (deposit token)`, - tx: { to: p.token, data, from: ctx.sender, ...ctx.fee }, + tx: approveTx, }); } } @@ -127,11 +159,37 @@ export function routeErc20NonBase(): DepositRouteStrategy { if (allowanceBase < mintValue) { approvals.push({ token: baseToken, spender: assetRouter, amount: mintValue }); const data = erc20Base.interface.encodeFunctionData('approve', [assetRouter, mintValue]); + const approveTx: TransactionRequest = { + to: baseToken, + data, + from: ctx.sender, + ...ctx.fee, + }; + const approveGas = await ctx.gas.ensure( + `approve:${baseToken}:${assetRouter}`, + 'deposit.approval.l1', + approveTx, + { + estimator: (request) => + wrapAs( + 'RPC', + OP_DEPOSITS.nonbase.estGas, + () => ctx.client.l1.estimateGas(request), + { + ctx: { where: 'l1.estimateGas', to: baseToken }, + message: 'Failed to estimate gas for ERC-20 approval (base token).', + }, + ), + }, + ); + if (approveGas.recommended != null) { + approveTx.gasLimit = approveGas.recommended; + } steps.push({ key: `approve:${baseToken}:${assetRouter}`, kind: 'approve', description: `Approve base token for mintValue`, - tx: { to: baseToken, data, from: ctx.sender, ...ctx.fee }, + tx: approveTx, }); } } @@ -169,20 +227,20 @@ export function routeErc20NonBase(): DepositRouteStrategy { ...ctx.fee, }; - try { - const est = await wrapAs( - 'RPC', - OP_DEPOSITS.nonbase.estGas, - () => ctx.client.l1.estimateGas(bridgeTx), - { - ctx: { where: 'l1.estimateGas', to: ctx.bridgehub, baseIsEth }, - message: 'Failed to estimate gas for Bridgehub request.', - }, - ); - // TODO: refactor to improve gas estimate / fees - bridgeTx.gasLimit = (BigInt(est) * 125n) / 100n; - } catch { - // ignore; + const gas = await ctx.gas.ensure( + 'bridgehub:two-bridges:nonbase', + 'deposit.bridgehub.two-bridges.erc20.l1', + bridgeTx, + { + estimator: (request) => + wrapAs('RPC', OP_DEPOSITS.nonbase.estGas, () => ctx.client.l1.estimateGas(request), { + ctx: { where: 'l1.estimateGas', to: ctx.bridgehub, baseIsEth }, + message: 'Failed to estimate gas for Bridgehub request.', + }), + }, + ); + if (gas.recommended != null) { + bridgeTx.gasLimit = gas.recommended; } steps.push({ @@ -194,7 +252,17 @@ export function routeErc20NonBase(): DepositRouteStrategy { tx: bridgeTx, }); - return { steps, approvals, quoteExtras: { baseCost, mintValue, baseToken, baseIsEth } }; + return { + steps, + approvals, + quoteExtras: { + baseCost, + mintValue, + baseToken, + baseIsEth, + gasPlan: ctx.gas.snapshot(), + }, + }; }, }; } diff --git a/src/adapters/ethers/resources/deposits/routes/eth-nonbase.ts b/src/adapters/ethers/resources/deposits/routes/eth-nonbase.ts index 9950ff1..dae95fd 100644 --- a/src/adapters/ethers/resources/deposits/routes/eth-nonbase.ts +++ b/src/adapters/ethers/resources/deposits/routes/eth-nonbase.ts @@ -13,12 +13,6 @@ import { isETH } from '../../../../../core/utils/addr'; // error handling const { wrapAs } = createErrorHandlers('deposits'); -// TODO: all gas buffers need to be moved to a dedicated resource -// this is getting messy -const BASE_COST_BUFFER_BPS = 100n; // 1% -const BPS = 10_000n; -const withBuffer = (x: bigint) => (x * (BPS + BASE_COST_BUFFER_BPS)) / BPS; - // ETH deposit to a chain whose base token is NOT ETH. export function routeEthNonBase(): DepositRouteStrategy { return { @@ -113,9 +107,13 @@ export function routeEthNonBase(): DepositRouteStrategy { }, )) as bigint; const baseCost = BigInt(rawBaseCost); - const mintValueRaw = baseCost + ctx.operatorTip; - // TODO: consider making buffer optional / configurable - const mintValue = withBuffer(mintValueRaw); + const baseCostQuote = ctx.gas.applyBaseCost( + 'base-cost:bridgehub:eth-nonbase', + 'deposit.base-cost.eth-nonbase', + baseCost, + { operatorTip: ctx.operatorTip }, + ); + const mintValue = baseCostQuote.recommended; const approvals: ApprovalNeed[] = []; const steps: PlanStep[] = []; @@ -139,11 +137,37 @@ export function routeEthNonBase(): DepositRouteStrategy { ctx.l1AssetRouter, mintValue, ]); + const approveTx: TransactionRequest = { + to: baseToken, + data, + from: ctx.sender, + ...ctx.fee, + }; + const approveGas = await ctx.gas.ensure( + `approve:${baseToken}:${ctx.l1AssetRouter}`, + 'deposit.approval.l1', + approveTx, + { + estimator: (request) => + wrapAs( + 'RPC', + OP_DEPOSITS.ethNonBase.estGas, + () => ctx.client.l1.estimateGas(request), + { + ctx: { where: 'l1.estimateGas', to: baseToken }, + message: 'Failed to estimate gas for ERC-20 approval (base token).', + }, + ), + }, + ); + if (approveGas.recommended != null) { + approveTx.gasLimit = approveGas.recommended; + } steps.push({ key: `approve:${baseToken}:${ctx.l1AssetRouter}`, kind: 'approve', description: `Approve base token for mintValue`, - tx: { to: baseToken, data, from: ctx.sender, ...ctx.fee }, + tx: approveTx, }); } } @@ -188,19 +212,20 @@ export function routeEthNonBase(): DepositRouteStrategy { ...ctx.fee, }; - try { - const est = await wrapAs( - 'RPC', - OP_DEPOSITS.ethNonBase.estGas, - () => ctx.client.l1.estimateGas(bridgeTx), - { - ctx: { where: 'l1.estimateGas', to: ctx.bridgehub }, - message: 'Failed to estimate gas for Bridgehub request.', - }, - ); - bridgeTx.gasLimit = (BigInt(est) * 115n) / 100n; - } catch { - // ignore; + const gas = await ctx.gas.ensure( + 'bridgehub:two-bridges:eth-nonbase', + 'deposit.bridgehub.two-bridges.eth-nonbase.l1', + bridgeTx, + { + estimator: (request) => + wrapAs('RPC', OP_DEPOSITS.ethNonBase.estGas, () => ctx.client.l1.estimateGas(request), { + ctx: { where: 'l1.estimateGas', to: ctx.bridgehub }, + message: 'Failed to estimate gas for Bridgehub request.', + }), + }, + ); + if (gas.recommended != null) { + bridgeTx.gasLimit = gas.recommended; } steps.push({ @@ -211,7 +236,11 @@ export function routeEthNonBase(): DepositRouteStrategy { tx: bridgeTx, }); - return { steps, approvals, quoteExtras: { baseCost, mintValue } }; + return { + steps, + approvals, + quoteExtras: { baseCost, mintValue, gasPlan: ctx.gas.snapshot() }, + }; }, }; } diff --git a/src/adapters/ethers/resources/deposits/routes/eth.ts b/src/adapters/ethers/resources/deposits/routes/eth.ts index 6ff0f32..094077b 100644 --- a/src/adapters/ethers/resources/deposits/routes/eth.ts +++ b/src/adapters/ethers/resources/deposits/routes/eth.ts @@ -39,7 +39,13 @@ export function routeEthDirect(): DepositRouteStrategy { const l2Contract = p.to ?? ctx.sender; const l2Value = p.amount; - const mintValue = baseCost + ctx.operatorTip + l2Value; + const baseCostQuote = ctx.gas.applyBaseCost( + 'base-cost:bridgehub:direct', + 'deposit.base-cost.eth-base', + baseCost, + { operatorTip: ctx.operatorTip, extras: l2Value }, + ); + const mintValue = baseCostQuote.recommended; const req = buildDirectRequestStruct({ chainId: ctx.chainIdL2, @@ -59,19 +65,15 @@ export function routeEthDirect(): DepositRouteStrategy { from: ctx.sender, ...ctx.fee, }; - try { - const est = await wrapAs( - 'RPC', - OP_DEPOSITS.eth.estGas, - () => ctx.client.l1.estimateGas(tx), - { + const gas = await ctx.gas.ensure('bridgehub:direct', 'deposit.bridgehub.direct.l1', tx, { + estimator: (request) => + wrapAs('RPC', OP_DEPOSITS.eth.estGas, () => ctx.client.l1.estimateGas(request), { ctx: { where: 'l1.estimateGas', to: ctx.bridgehub }, message: 'Failed to estimate gas for Bridgehub request.', - }, - ); - tx.gasLimit = (BigInt(est) * 115n) / 100n; - } catch { - // ignore + }), + }); + if (gas.recommended != null) { + tx.gasLimit = gas.recommended; } const steps: PlanStep[] = [ @@ -83,7 +85,11 @@ export function routeEthDirect(): DepositRouteStrategy { }, ]; - return { steps, approvals: [], quoteExtras: { baseCost, mintValue } }; + return { + steps, + approvals: [], + quoteExtras: { baseCost, mintValue, gasPlan: ctx.gas.snapshot() }, + }; }, }; } diff --git a/src/adapters/ethers/resources/deposits/routes/types.ts b/src/adapters/ethers/resources/deposits/routes/types.ts index e7336b4..0e23902 100644 --- a/src/adapters/ethers/resources/deposits/routes/types.ts +++ b/src/adapters/ethers/resources/deposits/routes/types.ts @@ -3,12 +3,16 @@ import type { TransactionRequest } from 'ethers'; import type { DepositParams } from '../../../../../core/types/flows/deposits'; import type { RouteStrategy } from '../../../../../core/types/flows/route'; import type { BuildCtx as DepositBuildCtx } from '../context'; +import type { GasPlannerSnapshot } from '../../../../../core/gas'; // Extra data returned from quote step, passed to build step -export type DepositQuoteExtras = { +export interface DepositQuoteExtras { baseCost: bigint; mintValue: bigint; -}; + gasPlan: GasPlannerSnapshot; + baseToken?: string; + baseIsEth?: boolean; +} // A Deposit route strategy for building a deposit transaction request export type DepositRouteStrategy = RouteStrategy< diff --git a/src/adapters/ethers/resources/withdrawals/context.ts b/src/adapters/ethers/resources/withdrawals/context.ts index 9840420..9befc00 100644 --- a/src/adapters/ethers/resources/withdrawals/context.ts +++ b/src/adapters/ethers/resources/withdrawals/context.ts @@ -7,6 +7,7 @@ import { pickWithdrawRoute } from '../../../../core/resources/withdrawals/route' import type { WithdrawParams, WithdrawRoute } from '../../../../core/types/flows/withdrawals'; import type { CommonCtx } from '../../../../core/types/flows/base'; import { isEthBasedChain } from '../token-info'; +import { GasPlanner, DEFAULT_GAS_POLICIES } from '../../../../core/gas'; // Common context for building withdrawal (L2 -> L1) transactions export interface BuildCtx extends CommonCtx { @@ -28,6 +29,7 @@ export interface BuildCtx extends CommonCtx { // Optional fee overrides for L2 send fee?: Partial; + gas: GasPlanner; } export async function commonCtx( @@ -55,6 +57,7 @@ export async function commonCtx( // TODO: improve gas estimations const l2GasLimit = p.l2GasLimit ?? 300_000n; const gasBufferPct = 15; + const gasPlanner = new GasPlanner(DEFAULT_GAS_POLICIES); return { client, @@ -70,5 +73,6 @@ export async function commonCtx( baseIsEth, l2GasLimit, gasBufferPct, + gas: gasPlanner, } satisfies BuildCtx & { route: WithdrawRoute }; } diff --git a/src/adapters/ethers/resources/withdrawals/index.ts b/src/adapters/ethers/resources/withdrawals/index.ts index 7818b84..a33d84d 100644 --- a/src/adapters/ethers/resources/withdrawals/index.ts +++ b/src/adapters/ethers/resources/withdrawals/index.ts @@ -107,12 +107,13 @@ export function createWithdrawalsResource(client: EthersClient): WithdrawalsReso const ctx = await commonCtx(p, client); await ROUTES[ctx.route].preflight?.(p, ctx); - const { steps, approvals } = await ROUTES[ctx.route].build(p, ctx); + const { steps, approvals, quoteExtras } = await ROUTES[ctx.route].build(p, ctx); const summary: WithdrawQuote = { route: ctx.route, approvalsNeeded: approvals, suggestedL2GasLimit: ctx.l2GasLimit, + gasPlan: quoteExtras.gasPlan, }; return { route: ctx.route, summary, steps }; diff --git a/src/adapters/ethers/resources/withdrawals/routes/erc20-nonbase.ts b/src/adapters/ethers/resources/withdrawals/routes/erc20-nonbase.ts index ed3f04b..13bae8b 100644 --- a/src/adapters/ethers/resources/withdrawals/routes/erc20-nonbase.ts +++ b/src/adapters/ethers/resources/withdrawals/routes/erc20-nonbase.ts @@ -54,11 +54,34 @@ export function routeErc20NonBase(): WithdrawRouteStrategy { p.amount, ]); + const approveTx: TransactionRequest = { + to: p.token, + data, + from: ctx.sender, + ...(ctx.fee ?? {}), + }; + + const approveGas = await ctx.gas.ensure( + `approve:l2:${p.token}:${ctx.l2NativeTokenVault}`, + 'withdraw.approval.l2', + approveTx, + { + estimator: (request) => + wrapAs('RPC', OP_WITHDRAWALS.erc20.estGas, () => ctx.client.l2.estimateGas(request), { + ctx: { where: 'l2.estimateGas', to: p.token }, + message: 'Failed to estimate gas for L2 ERC-20 approval.', + }), + }, + ); + if (approveGas.recommended != null) { + approveTx.gasLimit = approveGas.recommended; + } + steps.push({ key: `approve:l2:${p.token}:${ctx.l2NativeTokenVault}`, kind: 'approve:l2', description: `Approve ${p.amount} to NativeTokenVault`, - tx: { to: p.token, data, from: ctx.sender, ...(ctx.fee ?? {}) }, + tx: approveTx, }); } @@ -110,6 +133,22 @@ export function routeErc20NonBase(): WithdrawRouteStrategy { ...(ctx.fee ?? {}), }; + const withdrawGas = await ctx.gas.ensure( + 'l2-asset-router:withdraw', + 'withdraw.erc20-nonbase.l2', + withdrawTx, + { + estimator: (request) => + wrapAs('RPC', OP_WITHDRAWALS.erc20.estGas, () => ctx.client.l2.estimateGas(request), { + ctx: { where: 'l2.estimateGas', to: ctx.l2AssetRouter }, + message: 'Failed to estimate gas for L2 asset-router withdraw.', + }), + }, + ); + if (withdrawGas.recommended != null) { + withdrawTx.gasLimit = withdrawGas.recommended; + } + steps.push({ key: 'l2-asset-router:withdraw', kind: 'l2-asset-router:withdraw', @@ -117,7 +156,7 @@ export function routeErc20NonBase(): WithdrawRouteStrategy { tx: withdrawTx, }); - return { steps, approvals, quoteExtras: {} }; + return { steps, approvals, quoteExtras: { gasPlan: ctx.gas.snapshot() } }; }, }; } diff --git a/src/adapters/ethers/resources/withdrawals/routes/eth-nonbase.ts b/src/adapters/ethers/resources/withdrawals/routes/eth-nonbase.ts index 6e0ced3..082ff55 100644 --- a/src/adapters/ethers/resources/withdrawals/routes/eth-nonbase.ts +++ b/src/adapters/ethers/resources/withdrawals/routes/eth-nonbase.ts @@ -48,21 +48,15 @@ export function routeEthNonBase(): WithdrawRouteStrategy { ...(ctx.fee ?? {}), }; - // TODO: consider a more robust buffer strategy - // best-effort gas estimate - try { - const est = await wrapAs( - 'RPC', - OP_WITHDRAWALS.eth.estGas, - () => ctx.client.l2.estimateGas(tx), - { + const gas = await ctx.gas.ensure('l2-base-token:withdraw', 'withdraw.eth-nonbase.l2', tx, { + estimator: (request) => + wrapAs('RPC', OP_WITHDRAWALS.eth.estGas, () => ctx.client.l2.estimateGas(request), { ctx: { where: 'l2.estimateGas', to: L2_BASE_TOKEN_ADDRESS }, message: 'Failed to estimate gas for L2 base-token withdraw.', - }, - ); - tx.gasLimit = (BigInt(est) * 115n) / 100n; - } catch { - // ignore + }), + }); + if (gas.recommended != null) { + tx.gasLimit = gas.recommended; } steps.push({ @@ -72,7 +66,7 @@ export function routeEthNonBase(): WithdrawRouteStrategy { tx, }); - return { steps, approvals: [], quoteExtras: {} }; + return { steps, approvals: [], quoteExtras: { gasPlan: ctx.gas.snapshot() } }; }, }; } diff --git a/src/adapters/ethers/resources/withdrawals/routes/eth.ts b/src/adapters/ethers/resources/withdrawals/routes/eth.ts index 37a29ff..f4efce4 100644 --- a/src/adapters/ethers/resources/withdrawals/routes/eth.ts +++ b/src/adapters/ethers/resources/withdrawals/routes/eth.ts @@ -41,20 +41,15 @@ export function routeEthBase(): WithdrawRouteStrategy { value: p.amount, }; - // TODO: improve gas estimations - try { - const est = await wrapAs( - 'RPC', - OP_WITHDRAWALS.eth.estGas, - () => ctx.client.l2.estimateGas(tx), - { + const gas = await ctx.gas.ensure('l2-base-token:withdraw', 'withdraw.eth-base.l2', tx, { + estimator: (request) => + wrapAs('RPC', OP_WITHDRAWALS.eth.estGas, () => ctx.client.l2.estimateGas(request), { ctx: { where: 'l2.estimateGas', to: L2_BASE_TOKEN_ADDRESS }, message: 'Failed to estimate gas for L2 ETH withdraw.', - }, - ); - tx.gasLimit = (BigInt(est) * 115n) / 100n; - } catch { - // ignore + }), + }); + if (gas.recommended != null) { + tx.gasLimit = gas.recommended; } steps.push({ @@ -64,7 +59,7 @@ export function routeEthBase(): WithdrawRouteStrategy { tx, }); - return { steps, approvals: [], quoteExtras: {} }; + return { steps, approvals: [], quoteExtras: { gasPlan: ctx.gas.snapshot() } }; }, }; } diff --git a/src/adapters/ethers/resources/withdrawals/routes/types.ts b/src/adapters/ethers/resources/withdrawals/routes/types.ts index f77996d..ef0886b 100644 --- a/src/adapters/ethers/resources/withdrawals/routes/types.ts +++ b/src/adapters/ethers/resources/withdrawals/routes/types.ts @@ -4,8 +4,11 @@ import type { WithdrawParams } from '../../../../../core/types/flows/withdrawals import type { RouteStrategy } from '../../../../../core/types/flows/route'; import type { BuildCtx as WithdrawBuildCtx } from '../context'; import type { Address, Hex } from '../../../../../core/types'; +import type { GasPlannerSnapshot } from '../../../../../core/gas'; -export type WithdrawQuoteExtras = Record; +export interface WithdrawQuoteExtras { + gasPlan: GasPlannerSnapshot; +} export type WithdrawRouteStrategy = RouteStrategy< WithdrawParams, diff --git a/src/adapters/viem/resources/deposits/context.ts b/src/adapters/viem/resources/deposits/context.ts index 5f2e1b1..74d4bf6 100644 --- a/src/adapters/viem/resources/deposits/context.ts +++ b/src/adapters/viem/resources/deposits/context.ts @@ -1,10 +1,12 @@ // src/adapters/viem/resources/deposits/context.ts import type { ViemClient } from '../../client'; import type { Address } from '../../../../core/types/primitives'; +import { GasPlanner, DEFAULT_GAS_POLICIES } from '../../../../core/gas'; import { getFeeOverrides, type FeeOverrides } from '../utils'; import { pickDepositRoute } from '../../../../core/resources/deposits/route'; import type { DepositParams, DepositRoute } from '../../../../core/types/flows/deposits'; import type { CommonCtx } from '../../../../core/types/flows/base'; +import type { ViemPlanWriteRequest } from './routes/types'; // Common context for building deposit (L1→L2) transactions (Viem) export interface BuildCtx extends CommonCtx { @@ -17,6 +19,7 @@ export interface BuildCtx extends CommonCtx { gasPerPubdata: bigint; operatorTip: bigint; refundRecipient: Address; + gas: GasPlanner; } // Prepare a common context for deposit operations @@ -34,6 +37,8 @@ export async function commonCtx(p: DepositParams, client: ViemClient) { const route = await pickDepositRoute(client, BigInt(chainId), p.token); + const gas = new GasPlanner(DEFAULT_GAS_POLICIES); + return { client, l1AssetRouter, @@ -46,5 +51,6 @@ export async function commonCtx(p: DepositParams, client: ViemClient) { gasPerPubdata, operatorTip, refundRecipient, + gas, } satisfies BuildCtx & { route: DepositRoute }; } diff --git a/src/adapters/viem/resources/deposits/index.ts b/src/adapters/viem/resources/deposits/index.ts index a3facac..315af44 100644 --- a/src/adapters/viem/resources/deposits/index.ts +++ b/src/adapters/viem/resources/deposits/index.ts @@ -117,7 +117,7 @@ export function createDepositsResource(client: ViemClient): DepositsResource { await ROUTES[route].preflight?.(p, ctx); const { steps, approvals, quoteExtras } = await ROUTES[route].build(p, ctx); - const { baseCost, mintValue } = quoteExtras; + const { baseCost, mintValue, gasPlan, baseToken, baseIsEth } = quoteExtras; return { route: ctx.route, @@ -128,6 +128,9 @@ export function createDepositsResource(client: ViemClient): DepositsResource { mintValue, suggestedL2GasLimit: ctx.l2GasLimit, gasPerPubdata: ctx.gasPerPubdata, + gasPlan, + baseToken, + baseIsEth, }, steps, }; diff --git a/src/adapters/viem/resources/deposits/routes/erc20-base.ts b/src/adapters/viem/resources/deposits/routes/erc20-base.ts index e6d4661..ef1cfa2 100644 --- a/src/adapters/viem/resources/deposits/routes/erc20-base.ts +++ b/src/adapters/viem/resources/deposits/routes/erc20-base.ts @@ -11,12 +11,6 @@ import type { Abi } from 'viem'; const { wrapAs } = createErrorHandlers('deposits'); -// TODO: all gas buffers need to be moved to a dedicated resource -// this is getting messy -const BASE_COST_BUFFER_BPS = 100n; // 1% -const BPS = 10_000n; -const withBuffer = (x: bigint) => (x * (BPS + BASE_COST_BUFFER_BPS)) / BPS; - // ERC20 deposit where the deposit token IS the target chain's base token (base ≠ ETH). export function routeErc20Base(): DepositRouteStrategy { return { @@ -100,9 +94,13 @@ export function routeErc20Base(): DepositRouteStrategy { const baseCost = rawBaseCost; const l2Value = p.amount; - // Direct path: mintValue must cover fee + the L2 msg.value (amount) → plus a small buffer - const rawMintValue = baseCost + ctx.operatorTip + l2Value; - const mintValue = withBuffer(rawMintValue); + const baseCostQuote = ctx.gas.applyBaseCost( + 'base-cost:bridgehub:erc20-base', + 'deposit.base-cost.erc20-base', + baseCost, + { operatorTip: ctx.operatorTip, extras: l2Value }, + ); + const mintValue = baseCostQuote.recommended; // Check allowance for base token -> L1AssetRouter const allowance = (await wrapAs( @@ -126,17 +124,18 @@ export function routeErc20Base(): DepositRouteStrategy { const needsApprove = allowance < mintValue; if (needsApprove) { + const approveParams = { + address: baseToken, + abi: IERC20ABI as Abi, + functionName: 'approve', + args: [ctx.l1AssetRouter, mintValue] as const, + account: ctx.client.account, + } as const; + const approveSim = await wrapAs( 'CONTRACT', OP_DEPOSITS.base.estGas, - () => - ctx.client.l1.simulateContract({ - address: baseToken, - abi: IERC20ABI as Abi, - functionName: 'approve', - args: [ctx.l1AssetRouter, mintValue] as const, - account: ctx.client.account, - }), + () => ctx.client.l1.simulateContract(approveParams), { ctx: { where: 'l1.simulateContract', to: baseToken }, message: 'Failed to simulate ERC-20 approve.', @@ -144,11 +143,33 @@ export function routeErc20Base(): DepositRouteStrategy { ); approvals.push({ token: baseToken, spender: ctx.l1AssetRouter, amount: mintValue }); + const approveTx = approveSim.request as ViemPlanWriteRequest; + const approveGas = await ctx.gas.ensure( + `approve:${baseToken}:${ctx.l1AssetRouter}`, + 'deposit.approval.l1', + approveTx, + { + estimator: () => + wrapAs( + 'RPC', + OP_DEPOSITS.base.estGas, + () => ctx.client.l1.estimateContractGas(approveParams), + { + ctx: { where: 'l1.estimateContractGas', to: baseToken }, + message: 'Failed to estimate gas for ERC-20 approve.', + }, + ), + }, + ); + if (approveGas.recommended != null) { + approveTx.gas = approveGas.recommended; + } + steps.push({ key: `approve:${baseToken}:${ctx.l1AssetRouter}`, kind: 'approve', description: 'Approve base token for mintValue', - tx: approveSim.request, + tx: approveTx, }); } @@ -165,48 +186,64 @@ export function routeErc20Base(): DepositRouteStrategy { // viem: if approval needed, don't simulate (would revert due to insufficient allowance). // Just return a write-ready request. Otherwise, simulate to capture gas settings. let bridgeTx: ViemPlanWriteRequest; + const bridgeParamsBase = { + address: ctx.bridgehub, + abi: IBridgehubABI as Abi, + functionName: 'requestL2TransactionDirect', + args: [req], + account: ctx.client.account, + } as const; + + const feeOverrides: Record = {}; + if ('maxFeePerGas' in ctx.fee && ctx.fee.maxFeePerGas != null) { + feeOverrides.maxFeePerGas = ctx.fee.maxFeePerGas; + } + if ('maxPriorityFeePerGas' in ctx.fee && ctx.fee.maxPriorityFeePerGas != null) { + feeOverrides.maxPriorityFeePerGas = ctx.fee.maxPriorityFeePerGas; + } + if ('gasPrice' in ctx.fee && ctx.fee.gasPrice != null) { + feeOverrides.gasPrice = ctx.fee.gasPrice; + } if (needsApprove) { bridgeTx = { - address: ctx.bridgehub, - abi: IBridgehubABI as Abi, - functionName: 'requestL2TransactionDirect', - args: [req], + ...bridgeParamsBase, value: 0n, // base is ERC-20 ⇒ msg.value MUST be 0 - account: ctx.client.account, - } as const; + ...feeOverrides, + } as ViemPlanWriteRequest; } else { - // Optional fee overrides - const feeOverrides: Record = {}; - if ('maxFeePerGas' in ctx.fee && ctx.fee.maxFeePerGas != null) { - feeOverrides.maxFeePerGas = ctx.fee.maxFeePerGas; - } - if ('maxPriorityFeePerGas' in ctx.fee && ctx.fee.maxPriorityFeePerGas != null) { - feeOverrides.maxPriorityFeePerGas = ctx.fee.maxPriorityFeePerGas; - } - if ('gasPrice' in ctx.fee && ctx.fee.gasPrice != null) { - feeOverrides.gasPrice = ctx.fee.gasPrice; - } - const sim = await wrapAs( 'RPC', OP_DEPOSITS.base.estGas, - () => - ctx.client.l1.simulateContract({ - address: ctx.bridgehub, - abi: IBridgehubABI as Abi, - functionName: 'requestL2TransactionDirect', - args: [req], - value: 0n, - account: ctx.client.account, - ...feeOverrides, - }), + () => ctx.client.l1.simulateContract({ ...bridgeParamsBase, value: 0n, ...feeOverrides }), { ctx: { where: 'l1.simulateContract', to: ctx.bridgehub }, message: 'Failed to simulate Bridgehub.requestL2TransactionDirect.', }, ); - bridgeTx = sim.request; + bridgeTx = sim.request as ViemPlanWriteRequest; + } + + const bridgeCallParams = { ...bridgeParamsBase, value: 0n, ...feeOverrides } as const; + const gas = await ctx.gas.ensure( + 'bridgehub:direct:erc20-base', + 'deposit.bridgehub.direct.l1', + bridgeTx, + { + estimator: () => + wrapAs( + 'RPC', + OP_DEPOSITS.base.estGas, + () => ctx.client.l1.estimateContractGas(bridgeCallParams), + { + ctx: { where: 'l1.estimateContractGas', to: ctx.bridgehub }, + message: 'Failed to estimate gas for Bridgehub request.', + }, + ), + }, + ); + if (gas.recommended != null) { + bridgeTx.gas = gas.recommended; } steps.push({ @@ -216,7 +253,11 @@ export function routeErc20Base(): DepositRouteStrategy { tx: bridgeTx, }); - return { steps, approvals, quoteExtras: { baseCost, mintValue } }; + return { + steps, + approvals, + quoteExtras: { baseCost, mintValue, gasPlan: ctx.gas.snapshot() }, + }; }, }; } diff --git a/src/adapters/viem/resources/deposits/routes/erc20-nonbase.ts b/src/adapters/viem/resources/deposits/routes/erc20-nonbase.ts index fb6d63f..92f3bd1 100644 --- a/src/adapters/viem/resources/deposits/routes/erc20-nonbase.ts +++ b/src/adapters/viem/resources/deposits/routes/erc20-nonbase.ts @@ -10,10 +10,6 @@ import type { Abi } from 'viem'; const { wrapAs } = createErrorHandlers('deposits'); -const BASE_COST_BUFFER_BPS = 100n; // 1% -const BPS = 10_000n; -const withBuffer = (x: bigint) => (x * (BPS + BASE_COST_BUFFER_BPS)) / BPS; - export function routeErc20NonBase(): DepositRouteStrategy { return { async preflight(p, ctx) { @@ -95,7 +91,13 @@ export function routeErc20NonBase(): DepositRouteStrategy { )) as bigint; const baseCost = rawBaseCost; - const mintValue = withBuffer(baseCost + ctx.operatorTip); + const baseCostQuote = ctx.gas.applyBaseCost( + 'base-cost:bridgehub:erc20-nonbase', + 'deposit.base-cost.erc20-nonbase', + baseCost, + { operatorTip: ctx.operatorTip }, + ); + const mintValue = baseCostQuote.recommended; // Approvals const approvals: ApprovalNeed[] = []; @@ -119,17 +121,18 @@ export function routeErc20NonBase(): DepositRouteStrategy { const needsDepositApprove = depositAllowance < p.amount; if (needsDepositApprove) { + const approveDepParams = { + address: p.token, + abi: IERC20ABI, + functionName: 'approve', + args: [ctx.l1AssetRouter, p.amount] as const, + account: ctx.client.account, + } as const; + const approveDepReq = await wrapAs( 'CONTRACT', OP_DEPOSITS.nonbase.estGas, - () => - ctx.client.l1.simulateContract({ - address: p.token, - abi: IERC20ABI, - functionName: 'approve', - args: [ctx.l1AssetRouter, p.amount] as const, - account: ctx.client.account, - }), + () => ctx.client.l1.simulateContract(approveDepParams), { ctx: { where: 'l1.simulateContract', to: p.token }, message: 'Failed to simulate deposit token approve.', @@ -137,11 +140,33 @@ export function routeErc20NonBase(): DepositRouteStrategy { ); approvals.push({ token: p.token, spender: ctx.l1AssetRouter, amount: p.amount }); + const approveTx = approveDepReq.request as ViemPlanWriteRequest; + const approveGas = await ctx.gas.ensure( + `approve:${p.token}:${ctx.l1AssetRouter}`, + 'deposit.approval.l1', + approveTx, + { + estimator: () => + wrapAs( + 'RPC', + OP_DEPOSITS.nonbase.estGas, + () => ctx.client.l1.estimateContractGas(approveDepParams), + { + ctx: { where: 'l1.estimateContractGas', to: p.token }, + message: 'Failed to estimate gas for deposit token approve.', + }, + ), + }, + ); + if (approveGas.recommended != null) { + approveTx.gas = approveGas.recommended; + } + steps.push({ key: `approve:${p.token}:${ctx.l1AssetRouter}`, kind: 'approve', description: `Approve deposit token for amount`, - tx: approveDepReq.request, + tx: approveTx, }); } @@ -166,17 +191,18 @@ export function routeErc20NonBase(): DepositRouteStrategy { )) as bigint; if (baseAllowance < mintValue) { + const approveBaseParams = { + address: baseToken, + abi: IERC20ABI, + functionName: 'approve', + args: [ctx.l1AssetRouter, mintValue] as const, + account: ctx.client.account, + } as const; + const approveBaseReq = await wrapAs( 'CONTRACT', OP_DEPOSITS.nonbase.estGas, - () => - ctx.client.l1.simulateContract({ - address: baseToken, - abi: IERC20ABI, - functionName: 'approve', - args: [ctx.l1AssetRouter, mintValue] as const, - account: ctx.client.account, - }), + () => ctx.client.l1.simulateContract(approveBaseParams), { ctx: { where: 'l1.simulateContract', to: baseToken }, message: 'Failed to simulate base-token approve.', @@ -184,11 +210,33 @@ export function routeErc20NonBase(): DepositRouteStrategy { ); approvals.push({ token: baseToken, spender: ctx.l1AssetRouter, amount: mintValue }); + const approveBaseTx = approveBaseReq.request as ViemPlanWriteRequest; + const approveBaseGas = await ctx.gas.ensure( + `approve:${baseToken}:${ctx.l1AssetRouter}`, + 'deposit.approval.l1', + approveBaseTx, + { + estimator: () => + wrapAs( + 'RPC', + OP_DEPOSITS.nonbase.estGas, + () => ctx.client.l1.estimateContractGas(approveBaseParams), + { + ctx: { where: 'l1.estimateContractGas', to: baseToken }, + message: 'Failed to estimate gas for base-token approve.', + }, + ), + }, + ); + if (approveBaseGas.recommended != null) { + approveBaseTx.gas = approveBaseGas.recommended; + } + steps.push({ key: `approve:${baseToken}:${ctx.l1AssetRouter}`, kind: 'approve', description: `Approve base token for mintValue`, - tx: approveBaseReq.request, + tx: approveBaseTx, }); } @@ -227,36 +275,70 @@ export function routeErc20NonBase(): DepositRouteStrategy { // viem simulate/write: // If any approval is required, skip simulate (can revert) and return a raw write. const approvalsNeeded = approvals.length > 0; + const feeOverrides: Record = {}; + if ('maxFeePerGas' in ctx.fee && ctx.fee.maxFeePerGas != null) { + feeOverrides.maxFeePerGas = ctx.fee.maxFeePerGas; + } + if ('maxPriorityFeePerGas' in ctx.fee && ctx.fee.maxPriorityFeePerGas != null) { + feeOverrides.maxPriorityFeePerGas = ctx.fee.maxPriorityFeePerGas; + } + if ('gasPrice' in ctx.fee && ctx.fee.gasPrice != null) { + feeOverrides.gasPrice = ctx.fee.gasPrice; + } + let bridgeTx: ViemPlanWriteRequest; + const bridgeParamsBase = { + address: ctx.bridgehub, + abi: IBridgehubABI, + functionName: 'requestL2TransactionTwoBridges', + args: [outer], + account: ctx.client.account, + } as const; if (approvalsNeeded) { bridgeTx = { - address: ctx.bridgehub, - abi: IBridgehubABI, - functionName: 'requestL2TransactionTwoBridges', - args: [outer], + ...bridgeParamsBase, value: msgValue, - account: ctx.client.account, - } as const; + ...feeOverrides, + } as ViemPlanWriteRequest; } else { const sim = await wrapAs( 'CONTRACT', OP_DEPOSITS.nonbase.estGas, () => ctx.client.l1.simulateContract({ - address: ctx.bridgehub, - abi: IBridgehubABI, - functionName: 'requestL2TransactionTwoBridges', - args: [outer], + ...bridgeParamsBase, value: msgValue, - account: ctx.client.account, + ...feeOverrides, }), { ctx: { where: 'l1.simulateContract', to: ctx.bridgehub }, message: 'Failed to simulate two-bridges request.', }, ); - bridgeTx = sim.request; + bridgeTx = sim.request as ViemPlanWriteRequest; + } + + const bridgeCallParams = { ...bridgeParamsBase, value: msgValue, ...feeOverrides } as const; + const bridgeGas = await ctx.gas.ensure( + 'bridgehub:two-bridges:nonbase', + 'deposit.bridgehub.two-bridges.erc20.l1', + bridgeTx, + { + estimator: () => + wrapAs( + 'RPC', + OP_DEPOSITS.nonbase.estGas, + () => ctx.client.l1.estimateContractGas(bridgeCallParams), + { + ctx: { where: 'l1.estimateContractGas', to: ctx.bridgehub }, + message: 'Failed to estimate gas for two-bridges request.', + }, + ), + }, + ); + if (bridgeGas.recommended != null) { + bridgeTx.gas = bridgeGas.recommended; } steps.push({ @@ -268,7 +350,17 @@ export function routeErc20NonBase(): DepositRouteStrategy { tx: bridgeTx, }); - return { steps, approvals, quoteExtras: { baseCost, mintValue } }; + return { + steps, + approvals, + quoteExtras: { + baseCost, + mintValue, + baseToken, + baseIsEth, + gasPlan: ctx.gas.snapshot(), + }, + }; }, }; } diff --git a/src/adapters/viem/resources/deposits/routes/eth-nonbase.ts b/src/adapters/viem/resources/deposits/routes/eth-nonbase.ts index 68c33a6..a011da6 100644 --- a/src/adapters/viem/resources/deposits/routes/eth-nonbase.ts +++ b/src/adapters/viem/resources/deposits/routes/eth-nonbase.ts @@ -12,12 +12,6 @@ import type { Abi } from 'viem'; // error handling const { wrapAs } = createErrorHandlers('deposits'); -// TODO: all gas buffers need to be moved to a dedicated resource -// this is getting messy -const BASE_COST_BUFFER_BPS = 100n; // 1% -const BPS = 10_000n; -const withBuffer = (x: bigint) => (x * (BPS + BASE_COST_BUFFER_BPS)) / BPS; - // ETH deposit to a chain whose base token is NOT ETH. export function routeEthNonBase(): DepositRouteStrategy { return { @@ -123,8 +117,13 @@ export function routeEthNonBase(): DepositRouteStrategy { )) as bigint; const baseCost = BigInt(rawBaseCost); - const mintValueRaw = baseCost + ctx.operatorTip; - const mintValue = withBuffer(mintValueRaw); + const baseCostQuote = ctx.gas.applyBaseCost( + 'base-cost:bridgehub:eth-nonbase', + 'deposit.base-cost.eth-nonbase', + baseCost, + { operatorTip: ctx.operatorTip }, + ); + const mintValue = baseCostQuote.recommended; const approvals: ApprovalNeed[] = []; const steps: PlanStep[] = []; @@ -148,17 +147,18 @@ export function routeEthNonBase(): DepositRouteStrategy { const needsApprove = allowance < mintValue; if (needsApprove) { + const approveParams = { + address: baseToken, + abi: IERC20ABI, + functionName: 'approve', + args: [ctx.l1AssetRouter, mintValue] as const, + account: ctx.client.account, + } as const; + const approveSim = await wrapAs( 'CONTRACT', OP_DEPOSITS.ethNonBase.estGas, - () => - ctx.client.l1.simulateContract({ - address: baseToken, - abi: IERC20ABI, - functionName: 'approve', - args: [ctx.l1AssetRouter, mintValue] as const, - account: ctx.client.account, - }), + () => ctx.client.l1.simulateContract(approveParams), { ctx: { where: 'l1.simulateContract', to: baseToken }, message: 'Failed to simulate base-token approve.', @@ -166,11 +166,33 @@ export function routeEthNonBase(): DepositRouteStrategy { ); approvals.push({ token: baseToken, spender: ctx.l1AssetRouter, amount: mintValue }); + const approveTx = approveSim.request as ViemPlanWriteRequest; + const approveGas = await ctx.gas.ensure( + `approve:${baseToken}:${ctx.l1AssetRouter}`, + 'deposit.approval.l1', + approveTx, + { + estimator: () => + wrapAs( + 'RPC', + OP_DEPOSITS.ethNonBase.estGas, + () => ctx.client.l1.estimateContractGas(approveParams), + { + ctx: { where: 'l1.estimateContractGas', to: baseToken }, + message: 'Failed to estimate gas for base-token approve.', + }, + ), + }, + ); + if (approveGas.recommended != null) { + approveTx.gas = approveGas.recommended; + } + steps.push({ key: `approve:${baseToken}:${ctx.l1AssetRouter}`, kind: 'approve', description: `Approve base token for mintValue`, - tx: approveSim.request, + tx: approveTx, }); } @@ -202,36 +224,70 @@ export function routeEthNonBase(): DepositRouteStrategy { // viem: if approval needed, don't simulate the bridge call (could revert). // Return a write-ready request with correct `value = p.amount`. + const feeOverrides: Record = {}; + if ('maxFeePerGas' in ctx.fee && ctx.fee.maxFeePerGas != null) { + feeOverrides.maxFeePerGas = ctx.fee.maxFeePerGas; + } + if ('maxPriorityFeePerGas' in ctx.fee && ctx.fee.maxPriorityFeePerGas != null) { + feeOverrides.maxPriorityFeePerGas = ctx.fee.maxPriorityFeePerGas; + } + if ('gasPrice' in ctx.fee && ctx.fee.gasPrice != null) { + feeOverrides.gasPrice = ctx.fee.gasPrice; + } + let bridgeTx: ViemPlanWriteRequest; + const bridgeParamsBase = { + address: ctx.bridgehub, + abi: IBridgehubABI, + functionName: 'requestL2TransactionTwoBridges', + args: [outer], + account: ctx.client.account, + } as const; if (needsApprove) { bridgeTx = { - address: ctx.bridgehub, - abi: IBridgehubABI, - functionName: 'requestL2TransactionTwoBridges', - args: [outer], - value: p.amount, // base ≠ ETH ⇒ msg.value == secondBridgeValue - account: ctx.client.account, - } as const; + ...bridgeParamsBase, + value: p.amount, + ...feeOverrides, + } as ViemPlanWriteRequest; } else { const twoBridgesSim = await wrapAs( 'CONTRACT', OP_DEPOSITS.ethNonBase.estGas, () => ctx.client.l1.simulateContract({ - address: ctx.bridgehub, - abi: IBridgehubABI, - functionName: 'requestL2TransactionTwoBridges', - args: [outer], - value: p.amount, // base ≠ ETH ⇒ msg.value == secondBridgeValue - account: ctx.client.account, + ...bridgeParamsBase, + value: p.amount, + ...feeOverrides, }), { ctx: { where: 'l1.simulateContract', to: ctx.bridgehub }, message: 'Failed to simulate Bridgehub two-bridges request.', }, ); - bridgeTx = twoBridgesSim.request; + bridgeTx = twoBridgesSim.request as ViemPlanWriteRequest; + } + + const bridgeCallParams = { ...bridgeParamsBase, value: p.amount, ...feeOverrides } as const; + const bridgeGas = await ctx.gas.ensure( + 'bridgehub:two-bridges:eth-nonbase', + 'deposit.bridgehub.two-bridges.eth-nonbase.l1', + bridgeTx, + { + estimator: () => + wrapAs( + 'RPC', + OP_DEPOSITS.ethNonBase.estGas, + () => ctx.client.l1.estimateContractGas(bridgeCallParams), + { + ctx: { where: 'l1.estimateContractGas', to: ctx.bridgehub }, + message: 'Failed to estimate gas for two-bridges request.', + }, + ), + }, + ); + if (bridgeGas.recommended != null) { + bridgeTx.gas = bridgeGas.recommended; } steps.push({ @@ -242,7 +298,17 @@ export function routeEthNonBase(): DepositRouteStrategy { tx: bridgeTx, }); - return { steps, approvals, quoteExtras: { baseCost, mintValue } }; + return { + steps, + approvals, + quoteExtras: { + baseCost, + mintValue, + baseToken, + baseIsEth: false, + gasPlan: ctx.gas.snapshot(), + }, + }; }, }; } diff --git a/src/adapters/viem/resources/deposits/routes/eth.ts b/src/adapters/viem/resources/deposits/routes/eth.ts index c50f821..2f380b9 100644 --- a/src/adapters/viem/resources/deposits/routes/eth.ts +++ b/src/adapters/viem/resources/deposits/routes/eth.ts @@ -35,7 +35,13 @@ export function routeEthDirect(): DepositRouteStrategy { const l2Contract = p.to ?? ctx.sender; const l2Value = p.amount; - const mintValue = baseCost + ctx.operatorTip + l2Value; + const baseCostQuote = ctx.gas.applyBaseCost( + 'base-cost:bridgehub:direct', + 'deposit.base-cost.eth-base', + baseCost, + { operatorTip: ctx.operatorTip, extras: l2Value }, + ); + const mintValue = baseCostQuote.recommended; const req = buildDirectRequestStruct({ chainId: ctx.chainIdL2, @@ -61,19 +67,20 @@ export function routeEthDirect(): DepositRouteStrategy { } // Simulate to produce a writeContract-ready request + const callParams = { + address: ctx.bridgehub, + abi: IBridgehubABI, + functionName: 'requestL2TransactionDirect', + args: [req], + value: mintValue, + account: ctx.client.account, + ...feeOverrides, + } as const; + const sim = await wrapAs( 'RPC', OP_DEPOSITS.eth.estGas, - () => - ctx.client.l1.simulateContract({ - address: ctx.bridgehub, - abi: IBridgehubABI, - functionName: 'requestL2TransactionDirect', - args: [req], - value: mintValue, - account: ctx.client.account, - ...feeOverrides, - }), + () => ctx.client.l1.simulateContract(callParams), { ctx: { where: 'l1.simulateContract', to: ctx.bridgehub }, message: 'Failed to simulate Bridgehub.requestL2TransactionDirect.', @@ -81,16 +88,38 @@ export function routeEthDirect(): DepositRouteStrategy { ); // TODO: add preview step // right now it adds too much noise on response + const tx = sim.request as ViemPlanWriteRequest; + + const gas = await ctx.gas.ensure('bridgehub:direct', 'deposit.bridgehub.direct.l1', tx, { + estimator: () => + wrapAs( + 'RPC', + OP_DEPOSITS.eth.estGas, + () => ctx.client.l1.estimateContractGas(callParams), + { + ctx: { where: 'l1.estimateContractGas', to: ctx.bridgehub }, + message: 'Failed to estimate gas for Bridgehub request.', + }, + ), + }); + if (gas.recommended != null) { + tx.gas = gas.recommended; + } + const steps: PlanStep[] = [ { key: 'bridgehub:direct', kind: 'bridgehub:direct', description: 'Bridge ETH via Bridgehub.requestL2TransactionDirect', - tx: sim.request, + tx, }, ]; - return { steps, approvals: [], quoteExtras: { baseCost, mintValue } }; + return { + steps, + approvals: [], + quoteExtras: { baseCost, mintValue, gasPlan: ctx.gas.snapshot() }, + }; }, }; } diff --git a/src/adapters/viem/resources/deposits/routes/types.ts b/src/adapters/viem/resources/deposits/routes/types.ts index 5469aff..85dfd51 100644 --- a/src/adapters/viem/resources/deposits/routes/types.ts +++ b/src/adapters/viem/resources/deposits/routes/types.ts @@ -2,6 +2,7 @@ import type { WalletClient, Transport, Chain, Account } from 'viem'; import type { DepositParams } from '../../../../../core/types/flows/deposits'; import type { RouteStrategy } from '../../../../../core/types/flows/route'; import type { BuildCtx as DepositBuildCtx } from '../context'; +import type { GasPlannerSnapshot } from '../../../../../core/gas'; // Base type from viem: type WriteParams = Parameters['writeContract']>[0]; @@ -14,10 +15,13 @@ type WriteParams = Parameters['writeCont export type ViemPlanWriteRequest = Omit & { value?: bigint }; // Extra data returned from quote step, passed to build step -export type DepositQuoteExtras = { +export interface DepositQuoteExtras { baseCost: bigint; mintValue: bigint; -}; + gasPlan: GasPlannerSnapshot; + baseToken?: string; + baseIsEth?: boolean; +} // Route strategy export type DepositRouteStrategy = RouteStrategy< diff --git a/src/adapters/viem/resources/withdrawals/context.ts b/src/adapters/viem/resources/withdrawals/context.ts index f872473..08bfb38 100644 --- a/src/adapters/viem/resources/withdrawals/context.ts +++ b/src/adapters/viem/resources/withdrawals/context.ts @@ -6,6 +6,8 @@ import { pickWithdrawRoute } from '../../../../core/resources/withdrawals/route' import type { WithdrawParams, WithdrawRoute } from '../../../../core/types/flows/withdrawals'; import type { CommonCtx } from '../../../../core/types/flows/base'; import { isEthBasedChain } from '../token-info'; +import { GasPlanner, DEFAULT_GAS_POLICIES } from '../../../../core/gas'; +import type { ViemPlanWriteRequest } from './routes/types'; // TODO: move all fee and gas items to dedicated resource? export type ViemFeeOverrides = { @@ -37,6 +39,7 @@ export interface BuildCtx extends CommonCtx { // Optional fee overrides for L2 send fee?: ViemFeeOverrides; + gas: GasPlanner; } export async function commonCtx( @@ -67,6 +70,8 @@ export async function commonCtx( const l2GasLimit = p.l2GasLimit ?? 300_000n; const gasBufferPct = 15; + const gas = new GasPlanner(DEFAULT_GAS_POLICIES); + return { client, bridgehub, @@ -81,5 +86,6 @@ export async function commonCtx( baseIsEth, l2GasLimit, gasBufferPct, + gas, } satisfies BuildCtx & { route: WithdrawRoute }; } diff --git a/src/adapters/viem/resources/withdrawals/index.ts b/src/adapters/viem/resources/withdrawals/index.ts index e748f36..2b97be4 100644 --- a/src/adapters/viem/resources/withdrawals/index.ts +++ b/src/adapters/viem/resources/withdrawals/index.ts @@ -118,12 +118,13 @@ export function createWithdrawalsResource(client: ViemClient): WithdrawalsResour const ctx = await commonCtx(p, client); await ROUTES[ctx.route].preflight?.(p, ctx); - const { steps, approvals } = await ROUTES[ctx.route].build(p, ctx); + const { steps, approvals, quoteExtras } = await ROUTES[ctx.route].build(p, ctx); const summary: WithdrawQuote = { route: ctx.route, approvalsNeeded: approvals, suggestedL2GasLimit: ctx.l2GasLimit, + gasPlan: quoteExtras.gasPlan, }; return { route: ctx.route, summary, steps }; } diff --git a/src/adapters/viem/resources/withdrawals/routes/erc20-nonbase.ts b/src/adapters/viem/resources/withdrawals/routes/erc20-nonbase.ts index b62e144..7b263cc 100644 --- a/src/adapters/viem/resources/withdrawals/routes/erc20-nonbase.ts +++ b/src/adapters/viem/resources/withdrawals/routes/erc20-nonbase.ts @@ -47,10 +47,9 @@ export function routeErc20NonBase(): WithdrawRouteStrategy { const needsApprove = current < p.amount; const feeOverrides: Record = {}; - if (ctx.fee?.maxFeePerGas != null && ctx.fee?.maxPriorityFeePerGas != null) { - feeOverrides.maxFeePerGas = ctx.fee.maxFeePerGas; + if (ctx.fee?.maxFeePerGas != null) feeOverrides.maxFeePerGas = ctx.fee.maxFeePerGas; + if (ctx.fee?.maxPriorityFeePerGas != null) feeOverrides.maxPriorityFeePerGas = ctx.fee.maxPriorityFeePerGas; - } const steps: Array> = []; const approvals: ApprovalNeed[] = []; @@ -58,29 +57,52 @@ export function routeErc20NonBase(): WithdrawRouteStrategy { if (needsApprove) { approvals.push({ token: p.token, spender: ctx.l2NativeTokenVault, amount: p.amount }); + const approveParams = { + address: p.token, + abi: IERC20ABI, + functionName: 'approve', + args: [ctx.l2NativeTokenVault, p.amount] as const, + account: ctx.client.account, + ...feeOverrides, + } as const; + const approveSim = await wrapAs( 'CONTRACT', OP_WITHDRAWALS.erc20.estGas, - () => - ctx.client.l2.simulateContract({ - address: p.token, - abi: IERC20ABI, - functionName: 'approve', - args: [ctx.l2NativeTokenVault, p.amount] as const, - account: ctx.client.account, - ...feeOverrides, - }), + () => ctx.client.l2.simulateContract(approveParams), { ctx: { where: 'l2.simulateContract', to: p.token }, message: 'Failed to simulate L2 ERC-20 approve.', }, ); + const approveTx = approveSim.request as ViemPlanWriteRequest; + const approveGas = await ctx.gas.ensure( + `approve:l2:${p.token}:${ctx.l2NativeTokenVault}`, + 'withdraw.approval.l2', + approveTx, + { + estimator: () => + wrapAs( + 'RPC', + OP_WITHDRAWALS.erc20.estGas, + () => ctx.client.l2.estimateContractGas(approveParams), + { + ctx: { where: 'l2.estimateContractGas', to: p.token }, + message: 'Failed to estimate gas for L2 ERC-20 approve.', + }, + ), + }, + ); + if (approveGas.recommended != null) { + approveTx.gas = approveGas.recommended; + } + steps.push({ key: `approve:l2:${p.token}:${ctx.l2NativeTokenVault}`, kind: 'approve:l2', description: `Approve ${p.amount} to NativeTokenVault`, - tx: approveSim.request as ViemPlanWriteRequest, + tx: approveTx, }); } // ensure token is registered in L2NativeTokenVault @@ -111,32 +133,28 @@ export function routeErc20NonBase(): WithdrawRouteStrategy { ); let withdrawTx: ViemPlanWriteRequest; + const withdrawParams = { + address: ctx.l2AssetRouter, + abi: IL2AssetRouterABI, + functionName: 'withdraw', + args: [assetId, assetData] as const, + account: ctx.client.account, + ...feeOverrides, + } as const; if (needsApprove) { // Do NOT simulate (would revert before approve). Return raw write params. // viem specific withdrawTx = { - address: ctx.l2AssetRouter, - abi: IL2AssetRouterABI, - functionName: 'withdraw', - args: [assetId, assetData] as const, - account: ctx.client.account, + ...withdrawParams, ...(ctx.fee ?? {}), - } satisfies ViemPlanWriteRequest; + } as ViemPlanWriteRequest; } else { // L2AssetRouter.withdraw(assetId, assetData) const sim = await wrapAs( 'CONTRACT', OP_WITHDRAWALS.erc20.estGas, - () => - ctx.client.l2.simulateContract({ - address: ctx.l2AssetRouter, - abi: IL2AssetRouterABI, - functionName: 'withdraw', - args: [assetId, assetData] as const, - account: ctx.client.account, - ...feeOverrides, - }), + () => ctx.client.l2.simulateContract(withdrawParams), { ctx: { where: 'l2.simulateContract', to: ctx.l2AssetRouter }, message: 'Failed to simulate L2 ERC-20 withdraw.', @@ -145,6 +163,27 @@ export function routeErc20NonBase(): WithdrawRouteStrategy { withdrawTx = sim.request as ViemPlanWriteRequest; } + const withdrawGas = await ctx.gas.ensure( + 'l2-asset-router:withdraw', + 'withdraw.erc20-nonbase.l2', + withdrawTx, + { + estimator: () => + wrapAs( + 'RPC', + OP_WITHDRAWALS.erc20.estGas, + () => ctx.client.l2.estimateContractGas(withdrawParams), + { + ctx: { where: 'l2.estimateContractGas', to: ctx.l2AssetRouter }, + message: 'Failed to estimate gas for L2 ERC-20 withdraw.', + }, + ), + }, + ); + if (withdrawGas.recommended != null) { + withdrawTx.gas = withdrawGas.recommended; + } + steps.push({ key: 'l2-asset-router:withdraw', kind: 'l2-asset-router:withdraw', @@ -152,7 +191,7 @@ export function routeErc20NonBase(): WithdrawRouteStrategy { tx: withdrawTx, }); - return { steps, approvals, quoteExtras: {} }; + return { steps, approvals, quoteExtras: { gasPlan: ctx.gas.snapshot() } }; }, }; } diff --git a/src/adapters/viem/resources/withdrawals/routes/eth-nonbase.ts b/src/adapters/viem/resources/withdrawals/routes/eth-nonbase.ts index 47a878a..6d7ffec 100644 --- a/src/adapters/viem/resources/withdrawals/routes/eth-nonbase.ts +++ b/src/adapters/viem/resources/withdrawals/routes/eth-nonbase.ts @@ -33,40 +33,57 @@ export function routeEthNonBase(): WithdrawRouteStrategy { const toL1 = p.to ?? ctx.sender; const feeOverrides: Record = {}; - if (ctx.fee?.maxFeePerGas != null && ctx.fee?.maxPriorityFeePerGas != null) { - feeOverrides.maxFeePerGas = ctx.fee.maxFeePerGas; + if (ctx.fee?.maxFeePerGas != null) feeOverrides.maxFeePerGas = ctx.fee.maxFeePerGas; + if (ctx.fee?.maxPriorityFeePerGas != null) feeOverrides.maxPriorityFeePerGas = ctx.fee.maxPriorityFeePerGas; - } + + const callParams = { + address: L2_BASE_TOKEN_ADDRESS, + abi: IBaseTokenABI, + functionName: 'withdraw', + args: [toL1] as const, + value: p.amount, + account: ctx.client.account, + ...feeOverrides, + } as const; const sim = await wrapAs( 'CONTRACT', OP_WITHDRAWALS.ethNonBase.estGas, - () => - ctx.client.l2.simulateContract({ - address: L2_BASE_TOKEN_ADDRESS, - abi: IBaseTokenABI, - functionName: 'withdraw', - args: [toL1] as const, - value: p.amount, - account: ctx.client.account, - ...feeOverrides, - }), + () => ctx.client.l2.simulateContract(callParams), { ctx: { where: 'l2.simulateContract', to: L2_BASE_TOKEN_ADDRESS }, message: 'Failed to simulate L2 base-token withdraw.', }, ); + const tx = sim.request as ViemPlanWriteRequest; + const gas = await ctx.gas.ensure('l2-base-token:withdraw', 'withdraw.eth-nonbase.l2', tx, { + estimator: () => + wrapAs( + 'RPC', + OP_WITHDRAWALS.ethNonBase.estGas, + () => ctx.client.l2.estimateContractGas(callParams), + { + ctx: { where: 'l2.estimateContractGas', to: L2_BASE_TOKEN_ADDRESS }, + message: 'Failed to estimate gas for base-token withdraw.', + }, + ), + }); + if (gas.recommended != null) { + tx.gas = gas.recommended; + } + const steps: Array> = [ { key: 'l2-base-token:withdraw', kind: 'l2-base-token:withdraw', description: 'Withdraw base token via L2 Base Token System (base ≠ ETH)', - tx: sim.request as ViemPlanWriteRequest, + tx, }, ]; - return { steps, approvals: [], quoteExtras: {} }; + return { steps, approvals: [], quoteExtras: { gasPlan: ctx.gas.snapshot() } }; }, }; } diff --git a/src/adapters/viem/resources/withdrawals/routes/eth.ts b/src/adapters/viem/resources/withdrawals/routes/eth.ts index 971611a..79c73cf 100644 --- a/src/adapters/viem/resources/withdrawals/routes/eth.ts +++ b/src/adapters/viem/resources/withdrawals/routes/eth.ts @@ -18,41 +18,57 @@ export function routeEthBase(): WithdrawRouteStrategy { const toL1 = p.to ?? ctx.sender; const feeOverrides: Record = {}; - if (ctx.fee?.maxFeePerGas != null && ctx.fee?.maxPriorityFeePerGas != null) { - feeOverrides.maxFeePerGas = ctx.fee.maxFeePerGas; + if (ctx.fee?.maxFeePerGas != null) feeOverrides.maxFeePerGas = ctx.fee.maxFeePerGas; + if (ctx.fee?.maxPriorityFeePerGas != null) feeOverrides.maxPriorityFeePerGas = ctx.fee.maxPriorityFeePerGas; - } - // Simulate the L2 call to produce a write-ready request + const callParams = { + address: L2_BASE_TOKEN_ADDRESS, + abi: IBaseTokenABI, + functionName: 'withdraw', + args: [toL1] as const, + value: p.amount, + account: ctx.client.account, + ...feeOverrides, + } as const; + const sim = await wrapAs( 'CONTRACT', OP_WITHDRAWALS.eth.estGas, - () => - ctx.client.l2.simulateContract({ - address: L2_BASE_TOKEN_ADDRESS, - abi: IBaseTokenABI, - functionName: 'withdraw', - args: [toL1] as const, - value: p.amount, - account: ctx.client.account, - ...feeOverrides, - }), + () => ctx.client.l2.simulateContract(callParams), { ctx: { where: 'l2.simulateContract', to: L2_BASE_TOKEN_ADDRESS }, message: 'Failed to simulate L2 ETH withdraw.', }, ); + const tx = sim.request as ViemPlanWriteRequest; + const gas = await ctx.gas.ensure('l2-base-token:withdraw', 'withdraw.eth-base.l2', tx, { + estimator: () => + wrapAs( + 'RPC', + OP_WITHDRAWALS.eth.estGas, + () => ctx.client.l2.estimateContractGas(callParams), + { + ctx: { where: 'l2.estimateContractGas', to: L2_BASE_TOKEN_ADDRESS }, + message: 'Failed to estimate gas for L2 ETH withdraw.', + }, + ), + }); + if (gas.recommended != null) { + tx.gas = gas.recommended; + } + const steps: Array> = [ { key: 'l2-base-token:withdraw', kind: 'l2-base-token:withdraw', description: 'Withdraw ETH via L2 Base Token System', - tx: sim.request as unknown as ViemPlanWriteRequest, + tx, }, ]; - return { steps, approvals: [], quoteExtras: {} }; + return { steps, approvals: [], quoteExtras: { gasPlan: ctx.gas.snapshot() } }; }, }; } diff --git a/src/adapters/viem/resources/withdrawals/routes/types.ts b/src/adapters/viem/resources/withdrawals/routes/types.ts index 7d1c716..d3a1abf 100644 --- a/src/adapters/viem/resources/withdrawals/routes/types.ts +++ b/src/adapters/viem/resources/withdrawals/routes/types.ts @@ -5,13 +5,16 @@ import type { WithdrawParams } from '../../../../../core/types/flows/withdrawals import type { RouteStrategy } from '../../../../../core/types/flows/route'; import type { BuildCtx as WithdrawBuildCtx } from '../context'; import type { Address, Hex } from '../../../../../core/types'; +import type { GasPlannerSnapshot } from '../../../../../core/gas'; // viem writeContract() parameter type export type ViemPlanWriteRequest = Parameters< WalletClient['writeContract'] >[0]; -export type WithdrawQuoteExtras = Record; +export interface WithdrawQuoteExtras { + gasPlan: GasPlannerSnapshot; +} export type WithdrawRouteStrategy = RouteStrategy< WithdrawParams, diff --git a/src/core/gas/index.ts b/src/core/gas/index.ts new file mode 100644 index 0000000..c64ce86 --- /dev/null +++ b/src/core/gas/index.ts @@ -0,0 +1,5 @@ +// src/core/gas/index.ts + +export * from './types'; +export * from './policies'; +export * from './planner'; diff --git a/src/core/gas/planner.ts b/src/core/gas/planner.ts new file mode 100644 index 0000000..e2f162e --- /dev/null +++ b/src/core/gas/planner.ts @@ -0,0 +1,162 @@ +// src/core/gas/planner.ts + +import type { + BaseCostQuote, + GasPolicy, + GasPolicyKey, + GasPolicyOverrides, + GasPolicyStore, + GasQuote, + GasQuoteSource, +} from './types'; +import { BPS } from './types'; +import { mergePolicies } from './policies'; + +export interface EnsureOptions { + estimator?: (payload: Tx) => Promise; + overrides?: Partial; +} + +export interface BaseCostOptions { + operatorTip?: bigint; + extras?: bigint; + overrides?: Partial; +} + +export interface GasPlannerSnapshot { + gasQuotes: Record; + baseCosts: Record; +} + +function applyBuffer(value: bigint, bufferBps: bigint): bigint { + if (bufferBps === 0n) return value; + return (value * (BPS + bufferBps)) / BPS; +} + +function mergePolicy( + store: GasPolicyStore, + key: GasPolicyKey, + overrides?: Partial, +): GasPolicy { + const base = store[key]; + if (!base) { + throw new Error(`Missing gas policy for key: ${key}`); + } + return { ...base, ...overrides, key: base.key }; +} + +export class GasPlanner { + private readonly policies: GasPolicyStore; + private readonly quotes = new Map(); + private readonly baseCosts = new Map(); + + constructor(basePolicies: GasPolicyStore, overrides?: GasPolicyOverrides) { + this.policies = mergePolicies(basePolicies, overrides); + } + + async ensure( + stepKey: string, + policyKey: GasPolicyKey, + payload: Tx, + opts: EnsureOptions = {}, + ): Promise { + const policy = mergePolicy(this.policies, policyKey, opts.overrides); + const diagnostics: string[] = []; + + let raw: bigint | undefined; + let source: GasQuoteSource = 'none'; + + if (opts.estimator) { + try { + raw = await opts.estimator(payload); + source = 'estimate'; + } catch (err) { + diagnostics.push(`estimation failed: ${(err as Error).message ?? 'unknown error'}`); + } + } + + let recommended: bigint | undefined; + + if (raw != null) { + recommended = applyBuffer(raw, policy.bufferBps); + } else if (policy.fallback != null) { + recommended = policy.fallback; + if (source === 'none') source = 'fallback'; + } + + if (recommended != null && policy.min != null && recommended < policy.min) { + diagnostics.push(`raised to policy min ${policy.min.toString()}`); + recommended = policy.min; + } + + const quote: GasQuote = { + key: policy.key, + layer: policy.layer, + rawEstimate: raw, + recommended, + source, + policy, + diagnostics, + }; + + this.quotes.set(stepKey, quote); + return quote; + } + + applyBaseCost( + refKey: string, + policyKey: GasPolicyKey, + rawBaseCost: bigint, + opts: BaseCostOptions = {}, + ): BaseCostQuote { + const policy = mergePolicy(this.policies, policyKey, opts.overrides); + const diagnostics: string[] = []; + + const withTip = rawBaseCost + (opts.operatorTip ?? 0n); + const withExtras = withTip + (opts.extras ?? 0n); + + let recommended = withExtras; + + if (policy.baseCostBufferBps != null && policy.baseCostBufferBps > 0n) { + recommended = applyBuffer(recommended, policy.baseCostBufferBps); + } + + if (policy.min != null && recommended < policy.min) { + diagnostics.push(`raised to policy min ${policy.min.toString()}`); + recommended = policy.min; + } + + const quote: BaseCostQuote = { + key: policy.key, + raw: rawBaseCost, + withTip, + withExtras, + recommended, + policy, + diagnostics, + }; + + this.baseCosts.set(refKey, quote); + return quote; + } + + /** Snapshot collected gas/base-cost decisions for inclusion in quotes. */ + snapshot(): GasPlannerSnapshot { + const gasQuotes: Record = {}; + const baseCosts: Record = {}; + + for (const [stepKey, quote] of this.quotes.entries()) { + gasQuotes[stepKey] = quote; + } + for (const [refKey, quote] of this.baseCosts.entries()) { + baseCosts[refKey] = quote; + } + + return { gasQuotes, baseCosts }; + } + + /** Retrieve the suggested gas value for a previously recorded step. */ + get(stepKey: string): GasQuote | undefined { + return this.quotes.get(stepKey); + } +} diff --git a/src/core/gas/policies.ts b/src/core/gas/policies.ts new file mode 100644 index 0000000..93f722e --- /dev/null +++ b/src/core/gas/policies.ts @@ -0,0 +1,99 @@ +// src/core/gas/policies.ts + +import type { GasPolicy, GasPolicyOverrides, GasPolicyStore } from './types'; + +export const DEFAULT_GAS_POLICIES: GasPolicyStore = { + 'withdraw.eth-base.l2': { + key: 'withdraw.eth-base.l2', + layer: 'l2', + bufferBps: 1500n, + }, + 'withdraw.eth-nonbase.l2': { + key: 'withdraw.eth-nonbase.l2', + layer: 'l2', + bufferBps: 1500n, + }, + 'withdraw.erc20-nonbase.l2': { + key: 'withdraw.erc20-nonbase.l2', + layer: 'l2', + bufferBps: 1500n, + }, + 'withdraw.approval.l2': { + key: 'withdraw.approval.l2', + layer: 'l2', + bufferBps: 1000n, + }, + 'deposit.bridgehub.direct.l1': { + key: 'deposit.bridgehub.direct.l1', + layer: 'l1', + bufferBps: 1500n, + }, + 'deposit.bridgehub.two-bridges.eth-nonbase.l1': { + key: 'deposit.bridgehub.two-bridges.eth-nonbase.l1', + layer: 'l1', + bufferBps: 1500n, + }, + 'deposit.bridgehub.two-bridges.erc20.l1': { + key: 'deposit.bridgehub.two-bridges.erc20.l1', + layer: 'l1', + bufferBps: 2500n, + }, + 'deposit.approval.l1': { + key: 'deposit.approval.l1', + layer: 'l1', + bufferBps: 1000n, + }, + 'deposit.l2.call.default': { + key: 'deposit.l2.call.default', + layer: 'l2', + bufferBps: 1500n, + }, + 'deposit.l2.call.erc20-nonbase': { + key: 'deposit.l2.call.erc20-nonbase', + layer: 'l2', + bufferBps: 1500n, + min: 2_500_000n, + }, + 'deposit.base-cost.eth-base': { + key: 'deposit.base-cost.eth-base', + layer: 'l1', + bufferBps: 0n, + }, + 'deposit.base-cost.erc20-base': { + key: 'deposit.base-cost.erc20-base', + layer: 'l1', + bufferBps: 0n, + baseCostBufferBps: 100n, + }, + 'deposit.base-cost.eth-nonbase': { + key: 'deposit.base-cost.eth-nonbase', + layer: 'l1', + bufferBps: 0n, + baseCostBufferBps: 100n, + }, + 'deposit.base-cost.erc20-nonbase': { + key: 'deposit.base-cost.erc20-nonbase', + layer: 'l1', + bufferBps: 0n, + }, +}; + +export function mergePolicies( + base: GasPolicyStore, + overrides?: GasPolicyOverrides, +): GasPolicyStore { + if (!overrides) return base; + const merged: GasPolicyStore = { ...base }; + for (const [key, value] of Object.entries(overrides)) { + const existing = merged[key]; + if (existing) { + merged[key] = { ...existing, ...value, key: existing.key }; + } else if (value.layer != null && value.bufferBps != null) { + merged[key] = { + ...(value as GasPolicy), + key: key, + }; + } + } + return merged; +} diff --git a/src/core/gas/types.ts b/src/core/gas/types.ts new file mode 100644 index 0000000..d81a90b --- /dev/null +++ b/src/core/gas/types.ts @@ -0,0 +1,56 @@ +// src/core/gas/types.ts + +export type GasPolicyKey = string; + +export type GasLayer = 'l1' | 'l2'; + +/** Basis points constant (10_000 == 100%). */ +export const BPS = 10_000n; + +/** Gas estimation and buffering policy for a given step. */ +export interface GasPolicy { + key: GasPolicyKey; + layer: GasLayer; + /** Buffer applied on top of the raw estimate (in basis points). */ + bufferBps: bigint; + /** Optional hard minimum for the resulting gas value. */ + min?: bigint; + /** Fallback value if estimation fails. */ + fallback?: bigint; + /** Optional extra buffer for L1 base-cost calculations (in basis points). */ + baseCostBufferBps?: bigint; +} + +export type GasPolicyStore = Record; + +export type GasPolicyOverrides = Record>; + +export type GasQuoteSource = 'estimate' | 'fallback' | 'none' | 'manual'; + +export interface GasQuote { + key: GasPolicyKey; + layer: GasLayer; + rawEstimate?: bigint; + recommended?: bigint; + source: GasQuoteSource; + policy: GasPolicy; + diagnostics: string[]; +} + +export interface BaseCostQuote { + key: GasPolicyKey; + raw: bigint; + /** Raw value after adding operator tip (if any). */ + withTip: bigint; + /** Intermediate value after adding extras (e.g. L2 msg.value). */ + withExtras: bigint; + /** Buffered amount to be paid on L1. */ + recommended: bigint; + policy: GasPolicy; + diagnostics: string[]; +} + +export interface GasPlannerHooks { + /** Estimate gas for the provided transaction on the requested layer. */ + estimate(layer: GasLayer, payload: Tx): Promise; +} diff --git a/src/core/types/flows/deposits.ts b/src/core/types/flows/deposits.ts index 71261f4..b07ab3d 100644 --- a/src/core/types/flows/deposits.ts +++ b/src/core/types/flows/deposits.ts @@ -1,6 +1,7 @@ // src/types/flows/deposits.ts import type { Address, Hex } from '../primitives'; import type { ApprovalNeed, Plan, Handle } from './base'; +import type { GasPlannerSnapshot } from '../../gas'; /** Input */ export interface DepositParams { @@ -24,6 +25,9 @@ export interface DepositQuote { mintValue: bigint; suggestedL2GasLimit: bigint; gasPerPubdata: bigint; + gasPlan: GasPlannerSnapshot; + baseToken?: Address; + baseIsEth?: boolean; } /** Plan (Tx generic) */ diff --git a/src/core/types/flows/withdrawals.ts b/src/core/types/flows/withdrawals.ts index 13cb136..e6f5a17 100644 --- a/src/core/types/flows/withdrawals.ts +++ b/src/core/types/flows/withdrawals.ts @@ -2,6 +2,7 @@ import type { Address, Hex } from '../primitives'; import type { ApprovalNeed, Plan, Handle } from './base'; +import type { GasPlannerSnapshot } from '../../gas'; /** Input */ export interface WithdrawParams { @@ -19,6 +20,7 @@ export interface WithdrawQuote { route: WithdrawRoute; approvalsNeeded: readonly ApprovalNeed[]; suggestedL2GasLimit: bigint; + gasPlan: GasPlannerSnapshot; } /** Plan (Tx generic) */ diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index a67cf97..282aa2c 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -1,6 +1,6 @@ { "extends": "./tsconfig.base.json", - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "src/adapters/**/**/*.ts"], "exclude": [ "node_modules", "build",