From c6dcfce578809faf5a70c8d65d0b6f672199cef4 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Wed, 13 May 2026 15:11:35 -0400 Subject: [PATCH 1/3] feat(miles): per-swap predicted gasLimit from Barter quote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the session-wide Edge Config gas averages with a per-swap predicted gasLimit derived from Barter's `gasEstimation` field, mirroring the executor formula in `mev-commit/tools/preconf-rpc/ fastswap/fastswap.go` line-for-line: `max(400_000, floor(barter × 2.5) + 135_000_or_152_000)`. Drives both the bid cost (priorityFee × limit) and the user L1 gas term on the permit path (baseFee × limit × gasUsedRatio, ratio derived live from Edge Config so it tracks hourly). Falls back to the rolling averages when Barter hasn't returned a quote yet, preserving cold-load behavior. Empirical validation across 59 recent permit-path swaps showed the gasUsed/gasLimit ratio is tight (stddev/p50 = 12.6%, regime-stable across the 2026-05-11 floor change), so a single ratio scaled to the per-swap predicted limit tracks realized consumption better than the flat averages. --- src/components/swap/SwapForm.tsx | 1 + src/hooks/__tests__/miles-math.test.ts | 87 ++++++++++++++++++- src/hooks/use-barter-validation.ts | 20 +++++ src/hooks/use-estimated-miles.ts | 114 ++++++++++++++++++++++--- src/hooks/use-swap-form.ts | 2 + 5 files changed, 209 insertions(+), 15 deletions(-) diff --git a/src/components/swap/SwapForm.tsx b/src/components/swap/SwapForm.tsx index 6bd1016f..3afd3ce3 100644 --- a/src/components/swap/SwapForm.tsx +++ b/src/components/swap/SwapForm.tsx @@ -58,6 +58,7 @@ export function SwapForm() { slippage: form.slippage, toTokenDecimals: form.toToken?.decimals ?? null, barterPreGasOutputAmount: form.barterPreGasOutputAmount, + barterGasEstimation: form.barterGasEstimation, toTokenPrice: form.toPrice, ethPrice: form.ethPrice, isEthOutput, diff --git a/src/hooks/__tests__/miles-math.test.ts b/src/hooks/__tests__/miles-math.test.ts index c8c1dfbd..6a5be9b1 100644 --- a/src/hooks/__tests__/miles-math.test.ts +++ b/src/hooks/__tests__/miles-math.test.ts @@ -12,7 +12,7 @@ */ import { describe, it, expect } from "vitest" -import { computeSurplusEth } from "../use-estimated-miles" +import { computeSurplusEth, predictGasLimit } from "../use-estimated-miles" // ────────────────────────────────────────────────────────────────────────── // Constants — must match use-estimated-miles.ts @@ -562,3 +562,88 @@ describe("operator-tunable slippage cap", () => { } }) }) + +// ────────────────────────────────────────────────────────────────────────── +// predictGasLimit — per-swap gasLimit prediction, mirrors the backend's +// `mev-commit/tools/preconf-rpc/fastswap/fastswap.go` formula. Frontend uses +// the same numbers so the bid the user sees and the bid the executor submits +// match. Floor at 400_000 was added in backend commit b2d13572 to avoid +// EIP-150 OOG on simple routes; the frontend mirrors it. +// ────────────────────────────────────────────────────────────────────────── +describe("predictGasLimit", () => { + const FALLBACK_AVG = 450_000n + const WRAPPER_PERMIT = 135_000n + const WRAPPER_ETH = 152_000n + const FLOOR = 400_000n + + it("permit path with barter present, scaled above floor", () => { + // 200k × 2.5 = 500k + 135k = 635k > 400k → 635k + expect(predictGasLimit(200_000, true, FALLBACK_AVG)).toBe(500_000n + WRAPPER_PERMIT) + }) + + it("ETH path with barter present, scaled above floor", () => { + // 200k × 2.5 = 500k + 152k = 652k > 400k → 652k + expect(predictGasLimit(200_000, false, FALLBACK_AVG)).toBe(500_000n + WRAPPER_ETH) + }) + + it("permit path scaled below floor → returns floor", () => { + // 50k × 2.5 = 125k + 135k = 260k < 400k → 400k + expect(predictGasLimit(50_000, true, FALLBACK_AVG)).toBe(FLOOR) + }) + + it("ETH path scaled below floor → returns floor", () => { + // 50k × 2.5 = 125k + 152k = 277k < 400k → 400k + expect(predictGasLimit(50_000, false, FALLBACK_AVG)).toBe(FLOOR) + }) + + it("permit path right at the floor boundary (260k → 395k → floor)", () => { + // 104k × 2.5 = 260k + 135k = 395k < 400k → 400k + expect(predictGasLimit(104_000, true, FALLBACK_AVG)).toBe(FLOOR) + // 106k × 2.5 = 265k + 135k = 400k = floor → still 400k (>, not >=, so floor) + expect(predictGasLimit(106_000, true, FALLBACK_AVG)).toBe(FLOOR) + // 107k × 2.5 = 267.5k → floor(267500) + 135k = 402_500 > 400_000 → 402_500 + expect(predictGasLimit(107_000, true, FALLBACK_AVG)).toBe(267_500n + WRAPPER_PERMIT) + }) + + it("missing barter (undefined) falls back to avg gas limit", () => { + expect(predictGasLimit(undefined, true, FALLBACK_AVG)).toBe(FALLBACK_AVG) + expect(predictGasLimit(undefined, false, FALLBACK_AVG)).toBe(FALLBACK_AVG) + }) + + it("invalid barter values (NaN, Infinity, 0, negative) fall back to avg", () => { + expect(predictGasLimit(Number.NaN, true, FALLBACK_AVG)).toBe(FALLBACK_AVG) + expect(predictGasLimit(Number.POSITIVE_INFINITY, true, FALLBACK_AVG)).toBe(FALLBACK_AVG) + expect(predictGasLimit(0, true, FALLBACK_AVG)).toBe(FALLBACK_AVG) + expect(predictGasLimit(-100, true, FALLBACK_AVG)).toBe(FALLBACK_AVG) + }) + + it("Math.floor in barter × 2.5 (no rounding up)", () => { + // 100_001 × 2.5 = 250_002.5 → floor = 250_002. + 135k = 385_002 < 400k → 400k + expect(predictGasLimit(100_001, true, FALLBACK_AVG)).toBe(FLOOR) + // 110_001 × 2.5 = 275_002.5 → floor = 275_002. + 135k = 410_002 > 400k → 410_002 + expect(predictGasLimit(110_001, true, FALLBACK_AVG)).toBe(275_002n + WRAPPER_PERMIT) + }) + + it("realistic permit-path swap (barter ~120k → 435k limit)", () => { + // 120_000 × 2.5 = 300_000 + 135_000 = 435_000 + expect(predictGasLimit(120_000, true, FALLBACK_AVG)).toBe(435_000n) + }) + + it("multi-hop swap (barter ~350k → 1.01M limit)", () => { + // 350_000 × 2.5 = 875_000 + 135_000 = 1_010_000 + expect(predictGasLimit(350_000, true, FALLBACK_AVG)).toBe(1_010_000n) + }) + + it("derived gas-used: predictedGasLimit × ratio matches realized envelope", () => { + // Empirical ratio from 59 permit-path swaps (2026-04-13 → 2026-05-13): + // p50 = 0.739, stddev/p50 = 12.6%. + // Using ratio of Edge Config averages: 340k/482k ≈ 0.706. + // Spot-check that ratio applied to predicted limit lands in the realized + // gasUsed envelope (~p25-p75 ≈ 288k-342k for typical swaps). + const ratio = 340_000 / 482_000 + const predictedLimit = predictGasLimit(120_000, true, FALLBACK_AVG) // 435k + const predictedUsed = Math.floor(Number(predictedLimit) * ratio) + expect(predictedUsed).toBeGreaterThan(280_000) + expect(predictedUsed).toBeLessThan(350_000) + }) +}) diff --git a/src/hooks/use-barter-validation.ts b/src/hooks/use-barter-validation.ts index 4da171ba..7ea97926 100644 --- a/src/hooks/use-barter-validation.ts +++ b/src/hooks/use-barter-validation.ts @@ -88,6 +88,14 @@ interface UseBarterValidationReturn { * value (older deployment, or ETH path where they're equal). */ barterPreGasOutputAmount: bigint | undefined + /** + * Barter's raw `gasEstimation` for the current route. Drives the miles + * estimator's per-swap predicted gasLimit (mirrors the backend formula in + * `mev-commit/tools/preconf-rpc/fastswap/fastswap.go`: `max(400_000, + * floor(gasEstimation × 2.5) + wrapper)`). When undefined the estimator + * falls back to the Edge Config rolling average. + */ + barterGasEstimation: number | undefined /** * True when Barter's /route endpoint has failed for the current inputs at least * UNAVAILABLE_ERROR_THRESHOLD times in a row. Callers should block swap submission @@ -125,6 +133,7 @@ export function useBarterValidation({ const [barterPreGasOutputAmount, setBarterPreGasOutputAmount] = useState( undefined ) + const [barterGasEstimation, setBarterGasEstimation] = useState(undefined) const [barterUnavailable, setBarterUnavailable] = useState(false) /** * True when the most recent barter response produced an out-of-band shortfall @@ -157,6 +166,7 @@ export function useBarterValidation({ setSanityGated(false) setBarterAmountOut(undefined) setBarterPreGasOutputAmount(undefined) + setBarterGasEstimation(undefined) setBarterUnavailable(false) setSettled(true) lastSettledKeyRef.current = "" @@ -182,6 +192,7 @@ export function useBarterValidation({ setSanityGated(false) setBarterAmountOut(undefined) setBarterPreGasOutputAmount(undefined) + setBarterGasEstimation(undefined) setBarterUnavailable(false) setSettled(true) lastSettledKeyRef.current = "" @@ -196,6 +207,7 @@ export function useBarterValidation({ setSanityGated(false) setBarterAmountOut(undefined) setBarterPreGasOutputAmount(undefined) + setBarterGasEstimation(undefined) // Do NOT reset barterUnavailable here — if we're in an outage, leaving it true // across input changes avoids "swap button enables for 300ms then blocks again" // flicker. Successful validation below clears it. @@ -238,6 +250,7 @@ export function useBarterValidation({ if (Math.abs(shortfallRaw) > SANITY_GATE_PCT) { setBarterAmountOut(undefined) setBarterPreGasOutputAmount(undefined) + setBarterGasEstimation(undefined) setShortfallPct(0) setSanityGated(true) setBarterUnavailable(false) @@ -248,6 +261,11 @@ export function useBarterValidation({ setBarterAmountOut(barterOut) setBarterPreGasOutputAmount(barterPreGas) + setBarterGasEstimation( + Number.isFinite(route.gasEstimation) && route.gasEstimation > 0 + ? route.gasEstimation + : undefined + ) setShortfallPct(Math.max(0, shortfallRaw)) setSanityGated(false) setBarterUnavailable(false) @@ -273,6 +291,7 @@ export function useBarterValidation({ // and mark settled so the UI stops spinning. setBarterAmountOut(undefined) setBarterPreGasOutputAmount(undefined) + setBarterGasEstimation(undefined) setShortfallPct(0) setBarterUnavailable(true) setSettled(true) @@ -336,6 +355,7 @@ export function useBarterValidation({ isValidating: !isCurrent || !settled, barterAmountOut: isCurrent ? barterAmountOut : undefined, barterPreGasOutputAmount: isCurrent ? barterPreGasOutputAmount : undefined, + barterGasEstimation: isCurrent ? barterGasEstimation : undefined, barterUnavailable: isCurrent && barterUnavailable, } } diff --git a/src/hooks/use-estimated-miles.ts b/src/hooks/use-estimated-miles.ts index 33140fcc..d68a6f5e 100644 --- a/src/hooks/use-estimated-miles.ts +++ b/src/hooks/use-estimated-miles.ts @@ -7,6 +7,47 @@ import { RPC_ENDPOINT } from "@/lib/network-config" const DEFAULT_AVG_GAS_LIMIT = 450_000n /** Fallback average gas used for gas cost calculation on permit path (baseFee × gasUsed) */ const DEFAULT_AVG_GAS_USED = 180_000n +/** + * Wrapper overhead constants mirroring the backend's per-path additive in + * `mev-commit/tools/preconf-rpc/fastswap/fastswap.go`: + * permit path: gasLimit = barterGasEstimation × 2.5 + 135_000 + * ETH path: gasLimit = barterGasEstimation × 2.5 + 152_000 + * Kept in lockstep with the backend so the bid the frontend estimates and + * the bid the executor actually submits match line-for-line. + */ +const WRAPPER_OVERHEAD_PERMIT = 135_000n +const WRAPPER_OVERHEAD_ETH = 152_000n +/** Floor enforced by the backend (commit b2d13572) to avoid EIP-150 OOG on + * simple routes. Per-swap `predictedGasLimit` is clamped to at least this. */ +const MIN_GAS_LIMIT = 400_000n +/** Multiplier applied to Barter's `gasEstimation` to mirror the backend's + * safety headroom on the raw routing estimate. */ +const BARTER_GAS_MULTIPLIER = 2.5 + +/** + * Mirrors the backend's gasLimit formula. When `barterGasEstimation` is + * available, scales it the same way the executor will and clamps to the + * 400k floor. When absent (cold load, in-flight validation, ETH path + * before barter settled), falls back to the rolling Edge Config average. + * + * Exported for testing. + */ +export function predictGasLimit( + barterGasEstimation: number | undefined, + isPermitPath: boolean, + fallbackAvgGasLimit: bigint +): bigint { + if ( + barterGasEstimation == null || + !Number.isFinite(barterGasEstimation) || + barterGasEstimation <= 0 + ) { + return fallbackAvgGasLimit + } + const wrapper = isPermitPath ? WRAPPER_OVERHEAD_PERMIT : WRAPPER_OVERHEAD_ETH + const scaled = BigInt(Math.floor(barterGasEstimation * BARTER_GAS_MULTIPLIER)) + wrapper + return scaled > MIN_GAS_LIMIT ? scaled : MIN_GAS_LIMIT +} /** * Fallback priority fee in wei (≈ 0.06 gwei). Matches the rough median value * `mevcommit_estimateBidPricePerGas` returns under normal conditions. Used as @@ -164,6 +205,14 @@ interface UseEstimatedMilesParams { * so the badge has a value to show. */ barterPreGasOutputAmount: bigint | undefined + /** + * Barter's raw `gasEstimation` for the current route. Drives the per-swap + * predicted gasLimit used for both the bid cost (`priorityFee × gasLimit`) + * and the user L1 gas cost on the permit path (`baseFee × predictedGasLimit + * × gasUsedRatio`). When undefined the hook falls back to the Edge Config + * rolling averages, matching the prior behavior. + */ + barterGasEstimation: number | undefined toTokenPrice: number | null ethPrice: number | null isEthOutput: boolean @@ -238,6 +287,7 @@ export function useEstimatedMiles({ slippage, toTokenDecimals, barterPreGasOutputAmount, + barterGasEstimation, toTokenPrice, ethPrice, isEthOutput, @@ -455,15 +505,28 @@ export function useEstimatedMiles({ formulaSource = "edge-config-fallback" } - // Bid cost: priority fee × avg gas limit (bid = priorityFee × txn.Gas()). - // This is the user's single tx bid — additive, not scaled. - const bidCostEth = Number(curPriorityFee * curAvgGasLimit) / 1e18 + // Bid cost: priority fee × per-swap predicted gasLimit. Mirrors the + // backend's submit formula exactly (`mev-commit/tools/preconf-rpc/ + // fastswap/fastswap.go`): `max(400_000, floor(gasEstimation × 2.5) + + // wrapper)`. Falls back to the Edge Config rolling average when barter + // hasn't returned a quote yet, preserving the prior behavior on cold + // load. Bid is additive (single user tx), not scaled. + const predictedGasLimit = predictGasLimit(barterGasEstimation, isPermitPath, curAvgGasLimit) + const bidCostEth = Number(curPriorityFee * predictedGasLimit) / 1e18 // User L1 gas: only deducted when the relayer paid (permit / ERC20 input). // ETH-input swaps go through `executeWithETH` and the user pays out of // their own wallet, so the miles formula does not subtract it. Mirrors // `userPaysGas` in `fastswap-miles/miles.go` exactly. - const gasCostEth = isPermitPath ? Number(curBaseFee * curAvgGasUsed) / 1e18 : 0 + // + // Per-swap predicted gas used = predictedGasLimit × (avgGasUsed / + // avgGasLimit). The realized ratio is tight (stddev/p50 ≈ 13% across + // recent permit-path swaps), so a single ratio scaled to the per-swap + // predicted limit tracks actual consumption better than the rolling + // gasUsed average alone. The ratio refreshes hourly with the cron. + const gasUsedRatio = curAvgGasLimit > 0n ? Number(curAvgGasUsed) / Number(curAvgGasLimit) : 0 + const predictedGasUsed = BigInt(Math.floor(Number(predictedGasLimit) * gasUsedRatio)) + const gasCostEth = isPermitPath ? Number(curBaseFee * predictedGasUsed) / 1e18 : 0 // Sweep overhead: per-token p25 of realized sweep gas, in ETH, from // Edge Config. The backend's `costEstimator` writes the same value and @@ -501,11 +564,13 @@ export function useEstimatedMiles({ : ` Step 2: MEV pot (Edge Config fallback: surplusRate × output)\n` + ` slippageAmountEth = ${outputInEth.toFixed(6)} × ${curSurplusRate} = ${slippageAmountEth.toFixed(8)} ETH\n`) + `\n` + - ` Step 3: Bid cost (FastRPC bid estimate × avgGasLimit from Edge Config)\n` + - ` bidCostEth = ${curPriorityFee.toString()} wei × ${curAvgGasLimit.toString()} gasLimit / 1e18 = ${bidCostEth.toFixed(8)} ETH\n` + + ` Step 3: Bid cost (FastRPC bid estimate × predictedGasLimit)\n` + + ` predictedGasLimit = ${predictedGasLimit.toString()} ` + + `(${barterGasEstimation != null && barterGasEstimation > 0 ? `barter ${barterGasEstimation} × ${BARTER_GAS_MULTIPLIER} + ${isPermitPath ? WRAPPER_OVERHEAD_PERMIT.toString() : WRAPPER_OVERHEAD_ETH.toString()}, floor ${MIN_GAS_LIMIT.toString()}` : `Edge Config avg fallback`})\n` + + ` bidCostEth = ${curPriorityFee.toString()} wei × ${predictedGasLimit.toString()} / 1e18 = ${bidCostEth.toFixed(8)} ETH\n` + `\n` + ` Step 4: Gas cost${isPermitPath ? " (relayer pays actual gasUsed on permit path)" : " (user pays on ETH path = 0)"}\n` + - ` gasCostEth = ${isPermitPath ? `${curBaseFee.toString()} wei × ${curAvgGasUsed.toString()} gasUsed / 1e18 = ` : ""}${gasCostEth.toFixed(8)} ETH\n` + + ` gasCostEth = ${isPermitPath ? `${curBaseFee.toString()} wei × ${predictedGasUsed.toString()} predictedGasUsed (ratio ${gasUsedRatio.toFixed(3)}) / 1e18 = ` : ""}${gasCostEth.toFixed(8)} ETH\n` + (!isEthOutput ? `\n Step 4b: Sweep overhead (non-ETH output, per-token p25 from Edge Config)\n` + ` sweepOverheadEth = ${sweepOverheadEth.toFixed(8)} ETH (token=${outputTokenAddress ?? "unknown"})\n` @@ -530,6 +595,7 @@ export function useEstimatedMiles({ amountOut, slippage, barterPreGasOutputAmount, + barterGasEstimation, toTokenDecimals, enabled, isBarterValidating, @@ -587,8 +653,11 @@ export function useEstimatedMiles({ : formulaicRate if (!Number.isFinite(effectiveSurplusRate) || effectiveSurplusRate <= 0) return null - const bidCostEth = Number(curPriorityFee * curAvgGasLimit) / 1e18 - const gasCostEth = isPermitPath ? Number(curBaseFee * curAvgGasUsed) / 1e18 : 0 + const predictedGasLimit = predictGasLimit(barterGasEstimation, isPermitPath, curAvgGasLimit) + const gasUsedRatio = curAvgGasLimit > 0n ? Number(curAvgGasUsed) / Number(curAvgGasLimit) : 0 + const predictedGasUsed = BigInt(Math.floor(Number(predictedGasLimit) * gasUsedRatio)) + const bidCostEth = Number(curPriorityFee * predictedGasLimit) / 1e18 + const gasCostEth = isPermitPath ? Number(curBaseFee * predictedGasUsed) / 1e18 : 0 const sweepOverheadEth = isEthOutput ? 0 : sweepOverheadForToken(sweepOverheadByTokenRef.current, outputTokenAddress) @@ -611,6 +680,7 @@ export function useEstimatedMiles({ ethPrice, slippage, barterPreGasOutputAmount, + barterGasEstimation, outputTokenAddress, ] ) @@ -645,8 +715,11 @@ export function useEstimatedMiles({ const curAvgGasLimit = avgGasLimitRef.current const curAvgGasUsed = avgGasUsedRef.current - const bidCostEth = Number(curPriorityFee * curAvgGasLimit) / 1e18 - const gasCostEth = isPermitPath ? Number(curBaseFee * curAvgGasUsed) / 1e18 : 0 + const predictedGasLimit = predictGasLimit(barterGasEstimation, isPermitPath, curAvgGasLimit) + const gasUsedRatio = curAvgGasLimit > 0n ? Number(curAvgGasUsed) / Number(curAvgGasLimit) : 0 + const predictedGasUsed = BigInt(Math.floor(Number(predictedGasLimit) * gasUsedRatio)) + const bidCostEth = Number(curPriorityFee * predictedGasLimit) / 1e18 + const gasCostEth = isPermitPath ? Number(curBaseFee * predictedGasUsed) / 1e18 : 0 const sweepOverheadEth = isEthOutput ? 0 : sweepOverheadForToken(sweepOverheadByTokenRef.current, outputTokenAddress) @@ -721,7 +794,16 @@ export function useEstimatedMiles({ requiresChange, } }, - [amountOut, slippage, isEthOutput, isPermitPath, toTokenPrice, ethPrice, outputTokenAddress] + [ + amountOut, + slippage, + isEthOutput, + isPermitPath, + toTokenPrice, + ethPrice, + outputTokenAddress, + barterGasEstimation, + ] ) // Upper bound: forward-compute miles at the user's CURRENT swap size @@ -747,8 +829,11 @@ export function useEstimatedMiles({ : (parsedAmountOut * (toTokenPrice as number)) / (ethPrice as number) if (!Number.isFinite(outputInEth) || outputInEth <= 0) return null - const bidCostEth = Number(priorityFee * avgGasLimit) / 1e18 - const gasCostEth = isPermitPath ? Number(baseFeePerGas * avgGasUsed) / 1e18 : 0 + const predictedGasLimit = predictGasLimit(barterGasEstimation, isPermitPath, avgGasLimit) + const gasUsedRatio = avgGasLimit > 0n ? Number(avgGasUsed) / Number(avgGasLimit) : 0 + const predictedGasUsed = BigInt(Math.floor(Number(predictedGasLimit) * gasUsedRatio)) + const bidCostEth = Number(priorityFee * predictedGasLimit) / 1e18 + const gasCostEth = isPermitPath ? Number(baseFeePerGas * predictedGasUsed) / 1e18 : 0 const sweepOverheadEth = isEthOutput ? 0 : sweepOverheadForToken(sweepOverheadByToken, outputTokenAddress) @@ -796,6 +881,7 @@ export function useEstimatedMiles({ avgGasLimit, avgGasUsed, barterPreGasOutputAmount, + barterGasEstimation, toTokenDecimals, isBarterValidating, milesCalcMaxSlippagePct, diff --git a/src/hooks/use-swap-form.ts b/src/hooks/use-swap-form.ts index 686a7cd3..ac863003 100644 --- a/src/hooks/use-swap-form.ts +++ b/src/hooks/use-swap-form.ts @@ -319,6 +319,7 @@ export function useSwapForm(allTokens: Token[]) { isValidating: isBarterValidating, barterAmountOut, barterPreGasOutputAmount, + barterGasEstimation, barterUnavailable, } = useBarterValidation({ fromToken, @@ -663,6 +664,7 @@ export function useSwapForm(allTokens: Token[]) { hasNoLiquidity, barterAmountTooSmall, barterPreGasOutputAmount, + barterGasEstimation, barterUnavailable, isBarterValidating: debouncedValidating, gasEstimate: isWrapUnwrap ? wrapUnwrapGasEstimate : (displayQuote?.gasEstimate ?? null), From a62f337f906369ff374c3d0a3325ac75daf67ff1 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Wed, 13 May 2026 15:34:19 -0400 Subject: [PATCH 2/3] feat(miles): use p75 of realized gas-used ratio (under-promise) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the inline `avgGasUsed / avgGasLimit` ratio derivation with a hardcoded p75 (0.77) of the realized `gas_used / gas_limit` distribution. The mean-based ratio under-predicted gas cost, which over-promised miles — users saw a higher badge number than realized miles. Switching to p75 envelopes the upper end of the realized distribution so predicted gasCost rarely undershoots actual, keeping miles estimates conservative. Empirical: 46 post-floor permit-path swaps (2026-05-11 → 2026-05-13), gas_used/gas_limit p75 = 0.768 (stddev/p50 ≈ 13%, tight). Rounded to 0.77 for the constant. --- src/hooks/__tests__/miles-math.test.ts | 21 +++++------ src/hooks/use-estimated-miles.ts | 49 +++++++++++++++++--------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/src/hooks/__tests__/miles-math.test.ts b/src/hooks/__tests__/miles-math.test.ts index 6a5be9b1..806df26f 100644 --- a/src/hooks/__tests__/miles-math.test.ts +++ b/src/hooks/__tests__/miles-math.test.ts @@ -634,16 +634,17 @@ describe("predictGasLimit", () => { expect(predictGasLimit(350_000, true, FALLBACK_AVG)).toBe(1_010_000n) }) - it("derived gas-used: predictedGasLimit × ratio matches realized envelope", () => { - // Empirical ratio from 59 permit-path swaps (2026-04-13 → 2026-05-13): - // p50 = 0.739, stddev/p50 = 12.6%. - // Using ratio of Edge Config averages: 340k/482k ≈ 0.706. - // Spot-check that ratio applied to predicted limit lands in the realized - // gasUsed envelope (~p25-p75 ≈ 288k-342k for typical swaps). - const ratio = 340_000 / 482_000 + it("p75 gas-used envelope: predictedGasLimit × 0.77 stays above realized p50", () => { + // p75 of `gas_used / gas_limit` across 46 post-floor permit-path swaps + // (2026-05-11 → 2026-05-13). Picked over mean/p50 so gas cost is rarely + // under-predicted — realized miles meet or exceed the badge estimate. + // Spot-check: applied to a representative predicted limit (~435k for a + // 120k-gas barter route), p75 lands at the upper realized envelope + // (~330k–340k), comfortably above the p50 realized gasUsed of ~295k. + const P75_RATIO = 0.77 const predictedLimit = predictGasLimit(120_000, true, FALLBACK_AVG) // 435k - const predictedUsed = Math.floor(Number(predictedLimit) * ratio) - expect(predictedUsed).toBeGreaterThan(280_000) - expect(predictedUsed).toBeLessThan(350_000) + const predictedUsed = Math.floor(Number(predictedLimit) * P75_RATIO) + expect(predictedUsed).toBeGreaterThan(295_000) // > realized p50 + expect(predictedUsed).toBeLessThan(360_000) // ≈ realized p75-p80 envelope }) }) diff --git a/src/hooks/use-estimated-miles.ts b/src/hooks/use-estimated-miles.ts index d68a6f5e..143de295 100644 --- a/src/hooks/use-estimated-miles.ts +++ b/src/hooks/use-estimated-miles.ts @@ -23,6 +23,19 @@ const MIN_GAS_LIMIT = 400_000n /** Multiplier applied to Barter's `gasEstimation` to mirror the backend's * safety headroom on the raw routing estimate. */ const BARTER_GAS_MULTIPLIER = 2.5 +/** + * p75 of realized `gas_used / gas_limit` across 46 post-floor permit-path + * swaps (sampled 2026-05-11 → 2026-05-13). Used to scale the per-swap + * predicted gasLimit into a predicted gasUsed for the user L1 gas term. + * + * Why p75 and not the mean: gas deduction is a one-sided cost — if we + * under-predict gas, miles get over-promised and realized < estimate + * (bad UX). p75 envelopes the upper end of the realized distribution so + * predicted gasCost rarely undershoots actual, keeping miles estimates + * conservative. The realized ratio distribution is tight (stddev/p50 ≈ + * 13%) so the gap between mean and p75 is small (~0.07). + */ +const PREDICTED_GAS_USED_RATIO_P75 = 0.77 /** * Mirrors the backend's gasLimit formula. When `barterGasEstimation` is @@ -209,8 +222,8 @@ interface UseEstimatedMilesParams { * Barter's raw `gasEstimation` for the current route. Drives the per-swap * predicted gasLimit used for both the bid cost (`priorityFee × gasLimit`) * and the user L1 gas cost on the permit path (`baseFee × predictedGasLimit - * × gasUsedRatio`). When undefined the hook falls back to the Edge Config - * rolling averages, matching the prior behavior. + * × p75GasUsedRatio`). When undefined the hook falls back to the Edge Config + * rolling gas-limit average, matching the prior behavior. */ barterGasEstimation: number | undefined toTokenPrice: number | null @@ -519,13 +532,14 @@ export function useEstimatedMiles({ // their own wallet, so the miles formula does not subtract it. Mirrors // `userPaysGas` in `fastswap-miles/miles.go` exactly. // - // Per-swap predicted gas used = predictedGasLimit × (avgGasUsed / - // avgGasLimit). The realized ratio is tight (stddev/p50 ≈ 13% across - // recent permit-path swaps), so a single ratio scaled to the per-swap - // predicted limit tracks actual consumption better than the rolling - // gasUsed average alone. The ratio refreshes hourly with the cron. - const gasUsedRatio = curAvgGasLimit > 0n ? Number(curAvgGasUsed) / Number(curAvgGasLimit) : 0 - const predictedGasUsed = BigInt(Math.floor(Number(predictedGasLimit) * gasUsedRatio)) + // Per-swap predicted gas used = predictedGasLimit × p75 of the realized + // `gas_used / gas_limit` distribution. p75 (under-promise) so gasCost + // is rarely under-predicted — realized miles meet or exceed the badge + // estimate. The realized ratio is tight (stddev/p50 ≈ 13% across recent + // permit-path swaps). + const predictedGasUsed = BigInt( + Math.floor(Number(predictedGasLimit) * PREDICTED_GAS_USED_RATIO_P75) + ) const gasCostEth = isPermitPath ? Number(curBaseFee * predictedGasUsed) / 1e18 : 0 // Sweep overhead: per-token p25 of realized sweep gas, in ETH, from @@ -570,7 +584,7 @@ export function useEstimatedMiles({ ` bidCostEth = ${curPriorityFee.toString()} wei × ${predictedGasLimit.toString()} / 1e18 = ${bidCostEth.toFixed(8)} ETH\n` + `\n` + ` Step 4: Gas cost${isPermitPath ? " (relayer pays actual gasUsed on permit path)" : " (user pays on ETH path = 0)"}\n` + - ` gasCostEth = ${isPermitPath ? `${curBaseFee.toString()} wei × ${predictedGasUsed.toString()} predictedGasUsed (ratio ${gasUsedRatio.toFixed(3)}) / 1e18 = ` : ""}${gasCostEth.toFixed(8)} ETH\n` + + ` gasCostEth = ${isPermitPath ? `${curBaseFee.toString()} wei × ${predictedGasUsed.toString()} predictedGasUsed (p75 ratio ${PREDICTED_GAS_USED_RATIO_P75}) / 1e18 = ` : ""}${gasCostEth.toFixed(8)} ETH\n` + (!isEthOutput ? `\n Step 4b: Sweep overhead (non-ETH output, per-token p25 from Edge Config)\n` + ` sweepOverheadEth = ${sweepOverheadEth.toFixed(8)} ETH (token=${outputTokenAddress ?? "unknown"})\n` @@ -654,8 +668,9 @@ export function useEstimatedMiles({ if (!Number.isFinite(effectiveSurplusRate) || effectiveSurplusRate <= 0) return null const predictedGasLimit = predictGasLimit(barterGasEstimation, isPermitPath, curAvgGasLimit) - const gasUsedRatio = curAvgGasLimit > 0n ? Number(curAvgGasUsed) / Number(curAvgGasLimit) : 0 - const predictedGasUsed = BigInt(Math.floor(Number(predictedGasLimit) * gasUsedRatio)) + const predictedGasUsed = BigInt( + Math.floor(Number(predictedGasLimit) * PREDICTED_GAS_USED_RATIO_P75) + ) const bidCostEth = Number(curPriorityFee * predictedGasLimit) / 1e18 const gasCostEth = isPermitPath ? Number(curBaseFee * predictedGasUsed) / 1e18 : 0 const sweepOverheadEth = isEthOutput @@ -716,8 +731,9 @@ export function useEstimatedMiles({ const curAvgGasUsed = avgGasUsedRef.current const predictedGasLimit = predictGasLimit(barterGasEstimation, isPermitPath, curAvgGasLimit) - const gasUsedRatio = curAvgGasLimit > 0n ? Number(curAvgGasUsed) / Number(curAvgGasLimit) : 0 - const predictedGasUsed = BigInt(Math.floor(Number(predictedGasLimit) * gasUsedRatio)) + const predictedGasUsed = BigInt( + Math.floor(Number(predictedGasLimit) * PREDICTED_GAS_USED_RATIO_P75) + ) const bidCostEth = Number(curPriorityFee * predictedGasLimit) / 1e18 const gasCostEth = isPermitPath ? Number(curBaseFee * predictedGasUsed) / 1e18 : 0 const sweepOverheadEth = isEthOutput @@ -830,8 +846,9 @@ export function useEstimatedMiles({ if (!Number.isFinite(outputInEth) || outputInEth <= 0) return null const predictedGasLimit = predictGasLimit(barterGasEstimation, isPermitPath, avgGasLimit) - const gasUsedRatio = avgGasLimit > 0n ? Number(avgGasUsed) / Number(avgGasLimit) : 0 - const predictedGasUsed = BigInt(Math.floor(Number(predictedGasLimit) * gasUsedRatio)) + const predictedGasUsed = BigInt( + Math.floor(Number(predictedGasLimit) * PREDICTED_GAS_USED_RATIO_P75) + ) const bidCostEth = Number(priorityFee * predictedGasLimit) / 1e18 const gasCostEth = isPermitPath ? Number(baseFeePerGas * predictedGasUsed) / 1e18 : 0 const sweepOverheadEth = isEthOutput From fe3be4f872dcd4411cbb693df704e8eaf4b94bcb Mon Sep 17 00:00:00 2001 From: owen-eth Date: Wed, 13 May 2026 16:16:29 -0400 Subject: [PATCH 3/3] fix(prices): reject implausible token prices to prevent miles blow-ups API has been observed returning ~$1 for ETH during transient backend issues, which cascaded into 13x surplus inflation in the miles calculator (one user saw 30,927 miles vs. actual ~17). Add per-symbol plausibility bounds at the fetch boundary: ETH/WETH in [100, 100k], stables in [0.5, 2]. Skip setPrice and warn on out-of-range data so the last good value stays in state, rather than poisoning every downstream consumer. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/use-token-price.ts | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/hooks/use-token-price.ts b/src/hooks/use-token-price.ts index af51b220..20761832 100644 --- a/src/hooks/use-token-price.ts +++ b/src/hooks/use-token-price.ts @@ -8,6 +8,25 @@ interface TokenPriceResult { error: Error | null } +// Per-symbol plausibility bounds. The API has been observed returning ~$1 +// for ETH during transient backend issues, which cascades into miles +// surplus blow-ups (one user saw 30,927 miles instead of ~17). When an +// out-of-range price comes back, skip the update and keep the previous +// good value rather than poisoning every downstream consumer. +const SANE_PRICE_BOUNDS: Record = { + ETH: { min: 100, max: 100_000 }, + WETH: { min: 100, max: 100_000 }, + USDC: { min: 0.5, max: 2 }, + USDT: { min: 0.5, max: 2 }, + DAI: { min: 0.5, max: 2 }, +} + +function isPriceSane(symbol: string, price: number): boolean { + const bounds = SANE_PRICE_BOUNDS[symbol.toUpperCase()] + if (!bounds) return true + return price >= bounds.min && price <= bounds.max +} + /** * Hook to fetch token price(s) from the API * Supports single token or batched fetching for multiple tokens @@ -41,7 +60,13 @@ export function useTokenPrice(symbols: string | string[]): TokenPriceResult { const data = await response.json() if (data.success && data.price) { - setPrice(data.price) + if (isPriceSane(symbolArray[0], data.price)) { + setPrice(data.price) + } else { + console.warn( + `[useTokenPrice] rejected implausible ${symbolArray[0]} price: ${data.price} — keeping previous value` + ) + } } else { setPrice(null) setError(new Error(`Failed to fetch ${symbolArray[0]} price`)) @@ -56,7 +81,13 @@ export function useTokenPrice(symbols: string | string[]): TokenPriceResult { // For now, return the first price (can be extended to return map) const firstResult = results[0] if (firstResult.success && firstResult.price) { - setPrice(firstResult.price) + if (isPriceSane(symbolArray[0], firstResult.price)) { + setPrice(firstResult.price) + } else { + console.warn( + `[useTokenPrice] rejected implausible ${symbolArray[0]} price: ${firstResult.price} — keeping previous value` + ) + } } else { setPrice(null) setError(new Error(`Failed to fetch token prices`))