From f5cbabc4ed6c779255448e76e4a64699fcaaf5ba Mon Sep 17 00:00:00 2001 From: owen-eth Date: Fri, 15 May 2026 14:51:14 -0400 Subject: [PATCH 1/3] feat(miles): fold per-token sweep bid into Edge Config overhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the backend's expanded PerRowOverhead so the upfront miles estimator deducts the sweep tx's own bid cost. The cron now joins per- token executor sweep counts × global fastswap bid p75 / user-row counts and adds the result to the existing gas overhead before writing to miles_estimate_sweep_overhead_eth_by_token. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../miles-estimate-gas/route.ts | 56 ++++++++++++++----- src/hooks/use-estimated-miles.ts | 11 ++-- src/lib/analytics/queries.ts | 56 +++++++++++++++++-- src/lib/swap-constants.ts | 7 +++ 4 files changed, 106 insertions(+), 24 deletions(-) diff --git a/src/app/api/cron/update-edge-config/miles-estimate-gas/route.ts b/src/app/api/cron/update-edge-config/miles-estimate-gas/route.ts index fce42dba..72816033 100644 --- a/src/app/api/cron/update-edge-config/miles-estimate-gas/route.ts +++ b/src/app/api/cron/update-edge-config/miles-estimate-gas/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server" import { patchEdgeConfigItems } from "@/lib/vercel-edge-config" import { getAnalyticsClient } from "@/lib/analytics/client" +import { FAST_SETTLEMENT_EXECUTOR_ADDRESS, WETH_ADDRESS } from "@/lib/swap-constants" // --------------------------------------------------------------------------- // Constants @@ -381,14 +382,19 @@ async function computeBidCostEstimate(): Promise { /** * Per-token sweep overhead map: lowercased L1 token address → ETH overhead. * - * Mirrors the backend's `costEstimator.Get` semantics in + * Mirrors the backend's combined `PerRowOverhead` in * `mev-commit/tools/fastswap-miles/cost_estimator.go`: - * - per-token p25 over the last 14 days when sample size ≥ 10 - * - per-token p75 when sample size < 10 (low-data fallback) - * - `default` is a network-wide p25 across all tokens — used when the - * frontend's selected output token isn't in the map at all - * - falls back to `LAST_RESORT_SWEEP_OVERHEAD_ETH` if the network query - * itself returns nothing usable + * 1. Per-token p25/p75 of pro-rata sweep gas (`get-sweep-overhead-by-token`). + * 2. Plus per-token sweep bid contribution (`get-sweep-bid-by-token`): + * `(n_sweeps × global_bid_p75) / n_user_rows`. The sweep tx is itself a + * fastswap so a global percentile of recent bid_cost is the right proxy. + * + * Both terms scale with batch size — low-volume tokens have a small number + * of user rows per sweep so the per-row contribution is high, and high- + * volume tokens dilute both. + * + * The `default` key falls back to `LAST_RESORT_SWEEP_OVERHEAD_ETH` so the + * frontend's selected output token has a value even when not in the map. * * Returned values are ETH (float) so the route handler can ship the map * straight to Edge Config without extra encoding. @@ -396,7 +402,28 @@ async function computeBidCostEstimate(): Promise { async function computeSweepOverheadByToken(): Promise> { const client = getAnalyticsClient() - const rows = await client.execute("fastswap/get-sweep-overhead-by-token", {}) + const [gasRows, bidRows] = await Promise.all([ + client.execute("fastswap/get-sweep-overhead-by-token", {}), + client.execute("fastswap/get-sweep-bid-by-token", { + executor: FAST_SETTLEMENT_EXECUTOR_ADDRESS.toLowerCase(), + weth: WETH_ADDRESS.toLowerCase(), + fallback_bid_eth: FALLBACK_BID_COST_ETH, + }), + ]) + + // Per-row sweep bid contribution, keyed by lowercased output_token. + const bidByToken = new Map() + for (const row of bidRows) { + const token = String(row[0] ?? "").toLowerCase() + const nSweeps = Number(row[1]) + const nUsers = Number(row[2]) + const bidP75 = Number(row[3]) + if (!token || token === "null") continue + if (!Number.isFinite(nSweeps) || nSweeps <= 0) continue + if (!Number.isFinite(nUsers) || nUsers <= 0) continue + if (!Number.isFinite(bidP75) || bidP75 <= 0) continue + bidByToken.set(token, (nSweeps * bidP75) / nUsers) + } // `default` mirrors the backend's `costEstimateLastResort` exactly — for // tokens the backend has no historical data on, both estimators must @@ -405,7 +432,7 @@ async function computeSweepOverheadByToken(): Promise> { // user gets fewer miles than the badge said. const map: Record = { default: LAST_RESORT_SWEEP_OVERHEAD_ETH } - for (const row of rows) { + for (const row of gasRows) { const token = String(row[0] ?? "").toLowerCase() const n = Number(row[1]) const p25 = Number(row[2]) @@ -415,15 +442,18 @@ async function computeSweepOverheadByToken(): Promise> { if (!Number.isFinite(n) || n <= 0) continue if (!Number.isFinite(p25) || !Number.isFinite(p75)) continue - const overhead = n >= SWEEP_OVERHEAD_MIN_SAMPLES ? p25 : p75 - if (!Number.isFinite(overhead) || overhead < 0) continue + const gasOverhead = n >= SWEEP_OVERHEAD_MIN_SAMPLES ? p25 : p75 + if (!Number.isFinite(gasOverhead) || gasOverhead < 0) continue + + const sweepBid = bidByToken.get(token) ?? 0 + const combined = gasOverhead + sweepBid // Round to 8 decimal places (0.01 µETH) to keep Edge Config JSON tight. - map[token] = Math.round(overhead * 1e8) / 1e8 + map[token] = Math.round(combined * 1e8) / 1e8 } console.log( - `[cron/miles-estimate-gas] sweepOverhead — ${Object.keys(map).length - 1} tokens, default=${(map.default * 1e6).toFixed(0)} µETH (last-resort), sample=${JSON.stringify( + `[cron/miles-estimate-gas] sweepOverhead — ${Object.keys(map).length - 1} tokens, ${bidByToken.size} with sweep bid, default=${(map.default * 1e6).toFixed(0)} µETH (last-resort), sample=${JSON.stringify( Object.fromEntries( Object.entries(map) .filter(([k]) => k !== "default") diff --git a/src/hooks/use-estimated-miles.ts b/src/hooks/use-estimated-miles.ts index 143de295..24cca579 100644 --- a/src/hooks/use-estimated-miles.ts +++ b/src/hooks/use-estimated-miles.ts @@ -542,10 +542,11 @@ export function useEstimatedMiles({ ) 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 - // subtracts it inside `awardUpfrontERC20Miles`. ETH/WETH output bypasses - // sweeping entirely (the `eth_weth` path), so the term is zero there. + // Sweep overhead: per-token estimate of pro-rata sweep gas + pro-rata + // sweep bid, in ETH, from Edge Config. The backend's `costEstimator` + // computes the same combined value and subtracts it inside + // `awardUpfrontERC20Miles`. ETH/WETH output bypasses sweeping entirely + // (the `eth_weth` path), so the term is zero there. const sweepOverheadEth = isEthOutput ? 0 : sweepOverheadForToken(sweepOverheadByTokenRef.current, outputTokenAddress) @@ -586,7 +587,7 @@ export function useEstimatedMiles({ ` 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 (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` + + ? `\n Step 4b: Sweep overhead (non-ETH output, per-token sweep gas + sweep bid from Edge Config)\n` + ` sweepOverheadEth = ${sweepOverheadEth.toFixed(8)} ETH (token=${outputTokenAddress ?? "unknown"})\n` : "") + `\n` + diff --git a/src/lib/analytics/queries.ts b/src/lib/analytics/queries.ts index b33dbeeb..f40ece14 100644 --- a/src/lib/analytics/queries.ts +++ b/src/lib/analytics/queries.ts @@ -582,14 +582,14 @@ WHERE processed = 1 // Per-token sweep overhead samples for the upfront miles estimator. // -// Mirrors the backend's `costEstimator.Refresh` query in -// `mev-commit/tools/fastswap-miles/cost_estimator.go` line-for-line — same -// WHERE clauses, same 14-day lookback, same percentile selection. Keeping -// the SQL identical is what locks the frontend estimate in step with what -// `awardUpfrontERC20Miles` will subtract on the backend; any backend filter +// Mirrors the gas-overhead portion of the backend's `costEstimator.Refresh` +// query in `mev-commit/tools/fastswap-miles/cost_estimator.go` line-for-line. +// The cron pairs this with GET_FASTSWAP_SWEEP_BID_BY_TOKEN below and folds +// the sweep-bid contribution into the chosen percentile so the Edge Config +// value matches the backend's combined `PerRowOverhead`. Any backend filter // change must be mirrored here. // -// Per-row overhead = `surplus_eth − net_profit_eth − bid_cost/1e18`. +// Per-row gas overhead = `surplus_eth − net_profit_eth − bid_cost/1e18`. // Restricted to ETH-input rows because ERC20-input rows have user_gas baked // into (surplus_eth − net_profit_eth), and the miles formula deducts // user_gas separately — including ERC20-input samples here would inflate @@ -620,6 +620,49 @@ FROM ( GROUP BY output_token `.trim() +// Per-token sweep bid contribution (ETH) for the upfront miles estimator. +// +// Mirrors `computePerTokenSweepBidEth` in +// `mev-commit/tools/fastswap-miles/cost_estimator.go`. For each output_token T: +// +// per_row_sweep_bid_eth(T) = (n_sweeps(T) × global_bid_p75_eth) / n_user_rows(T) +// +// The sweep tx is itself a fastswap so global p75 of `bid_cost/1e18` is +// the right reference population (tight distribution, many samples). p75 = +// under-promise. Cron caller adds this per-row value to the chosen gas +// percentile per token, writes the combined value to Edge Config. +// +// Params: `:executor` (lowercased), `:weth` (lowercased), `:fallback_bid_eth` +// — used by COALESCE when the global percentile subquery returns NULL. +export const GET_FASTSWAP_SWEEP_BID_BY_TOKEN = ` +SELECT s.token, s.n_sweeps, u.n_users, COALESCE(b.p, :fallback_bid_eth) AS bid_p75 +FROM ( + SELECT LOWER(input_token) AS token, COUNT(*) AS n_sweeps + FROM mevcommit_57173.fastswap_miles + WHERE LOWER(user_address) = :executor + AND swap_type = 'eth_weth' + AND LOWER(output_token) = :weth + AND block_timestamp >= NOW() - INTERVAL 14 DAY + GROUP BY input_token +) s +JOIN ( + SELECT LOWER(output_token) AS token, COUNT(*) AS n_users + FROM mevcommit_57173.fastswap_miles + WHERE swap_type = 'erc20' + AND LOWER(user_address) != :executor + AND block_timestamp >= NOW() - INTERVAL 14 DAY + GROUP BY output_token +) u ON u.token = s.token +CROSS JOIN ( + SELECT percentile_approx(CAST(bid_cost AS DOUBLE)/1e18, 0.75) AS p + FROM mevcommit_57173.fastswap_miles + WHERE processed = 1 + AND bid_cost IS NOT NULL + AND CAST(bid_cost AS DOUBLE) > 0 + AND block_timestamp >= NOW() - INTERVAL 14 DAY +) b +`.trim() + // Surplus rate samples for miles estimation. // Computes `surplus / user_amt_out` — both columns are in the SAME output-token // units (smallest denomination), so decimals cancel and the ratio is dimensionless. @@ -715,6 +758,7 @@ export const QUERIES = { "fastswap/get-recent-tx-hashes": GET_RECENT_FASTSWAP_TX_HASHES, "fastswap/get-surplus-rates": GET_FASTSWAP_SURPLUS_RATES, "fastswap/get-sweep-overhead-by-token": GET_FASTSWAP_SWEEP_OVERHEAD_BY_TOKEN, + "fastswap/get-sweep-bid-by-token": GET_FASTSWAP_SWEEP_BID_BY_TOKEN, "fastswap/get-bid-costs": GET_FASTSWAP_BID_COSTS, // FastSwap miles domain diff --git a/src/lib/swap-constants.ts b/src/lib/swap-constants.ts index fcbaf38e..055f7a8c 100644 --- a/src/lib/swap-constants.ts +++ b/src/lib/swap-constants.ts @@ -5,6 +5,13 @@ export const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3" as c export const FAST_SETTLEMENT_ADDRESS = "0x084C0EC7f5C0585195c1c713ED9f06272F48cB45" as const export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const export const WETH_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" as const +// Executor wallet that submits permit-path swaps and pays sweep gas + bid. +// Sourced from `FastSettlementV3.executor()` on-chain. Used by the miles +// estimator cron to identify executor sweep rows when pricing sweep bids; +// if this ever changes, mirror the new address in +// `mev-commit/tools/fastswap-miles` via redeploy and update here. +export const FAST_SETTLEMENT_EXECUTOR_ADDRESS = + "0x959dad78d5b68986a43cd270134a2704a990aa68" as const export const INTENT_DEADLINE_MINUTES = 30 From b012e2642dcd8729a70bc2fd93db7e499f6048e8 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Fri, 15 May 2026 14:55:04 -0400 Subject: [PATCH 2/3] fix(swap): preserve amount when retrying after barter slippage error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executeSwap clears form.amount the moment the tx is submitted (via onCloseAfterSuccess), so by the time the failed toast surfaces a Retry button the form is empty — clicking Retry reopened the confirmation modal with amount="0". The toast carries the original amountIn, so the retry request now stashes it alongside the slippage and SwapForm restores the sell side before refetching the quote. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/swap/SwapForm.tsx | 20 +++++++++++------- src/stores/swapToastStore.ts | 35 ++++++++++++++++++++++++-------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/components/swap/SwapForm.tsx b/src/components/swap/SwapForm.tsx index 3afd3ce3..d3e6f51a 100644 --- a/src/components/swap/SwapForm.tsx +++ b/src/components/swap/SwapForm.tsx @@ -85,26 +85,32 @@ export function SwapForm() { const [isConfirmationOpen, setIsConfirmationOpen] = useState(false) const [autoExecuteSwap, setAutoExecuteSwap] = useState(false) const lastTxError = useSwapToastStore((s) => s.lastTxError) - const retrySlippage = useSwapToastStore((s) => s.retrySlippage) - const clearRetrySlippage = useSwapToastStore((s) => s.clearRetrySlippage) + const retryRequest = useSwapToastStore((s) => s.retryRequest) + const clearRetryRequest = useSwapToastStore((s) => s.clearRetryRequest) // Reopen confirmation modal when a tx fails after submit (e.g. status 0x0) useEffect(() => { if (lastTxError) setIsConfirmationOpen(true) }, [lastTxError]) - // Barter slippage retry: update slippage, fetch fresh quote, then auto-execute + // Barter slippage retry: restore the form's sell-side amount (the submit path + // wipes it via `onCloseAfterSuccess` before the retry toast even appears), + // update slippage, refetch the quote, then auto-execute when fresh data arrives. const [pendingRetry, setPendingRetry] = useState(false) useEffect(() => { - if (retrySlippage) { - form.updateSlippage(retrySlippage) - clearRetrySlippage() + if (retryRequest) { + if (retryRequest.amount) { + form.setEditingSide("sell") + form.setAmount(retryRequest.amount) + } + form.updateSlippage(retryRequest.slippage) + clearRetryRequest() setIsConfirmationOpen(false) setPendingRetry(true) form.refetchQuote() } - }, [retrySlippage, clearRetrySlippage, form]) + }, [retryRequest, clearRetryRequest, form]) // Wait for fresh quote to arrive before opening modal with auto-execute useEffect(() => { diff --git a/src/stores/swapToastStore.ts b/src/stores/swapToastStore.ts index 4edb36b3..2819f5d1 100644 --- a/src/stores/swapToastStore.ts +++ b/src/stores/swapToastStore.ts @@ -38,12 +38,22 @@ export type SwapTxError = { occurredAfterPreConfirm?: boolean } +/** Captures everything SwapForm needs to reopen the modal with the same swap intent + * the user just tried. We carry the amount alongside the slippage because the form's + * `amount` state is cleared the moment the tx is submitted (see + * `onCloseAfterSuccess` in `executeSwap`) — by the time the user clicks Retry the + * failed toast is still showing the amounts but the form is empty. */ +export type SwapRetryRequest = { + slippage: string + amount: string +} + type Store = { toasts: SwapToast[] /** Set when a tx fails after submit; SwapConfirmationModal shows error modal. Cleared when modal closes. */ lastTxError: SwapTxError | null /** Set when user clicks "Retry with X%" on a barter slippage toast. SwapForm subscribes and reopens modal. */ - retrySlippage: string | null + retryRequest: SwapRetryRequest | null addToast: ( hash: string, tokenIn?: Token, @@ -64,9 +74,10 @@ type Store = { /** Opens the error modal by setting lastTxError from the toast's stored error data. */ showErrorForToast: (hash: string) => void clearLastTxError: () => void - /** Removes the failed toast and sets retrySlippage so SwapForm can reopen the modal with updated slippage. */ + /** Removes the failed toast and sets retryRequest so SwapForm can restore the form's + * amount, update the slippage, and reopen the modal. */ requestRetryWithSlippage: (hash: string, slippage: string) => void - clearRetrySlippage: () => void + clearRetryRequest: () => void collapse: (hash: string) => void expand: (hash: string) => void removeToast: (hash: string) => void @@ -77,7 +88,7 @@ type Store = { export const useSwapToastStore = create((set, get) => ({ toasts: [], lastTxError: null, - retrySlippage: null, + retryRequest: null, addToast: (hash, tokenIn, tokenOut, amountIn, amountOut, onConfirm, onPreConfirm) => set((s) => ({ @@ -151,12 +162,18 @@ export const useSwapToastStore = create((set, get) => ({ clearLastTxError: () => set({ lastTxError: null }), requestRetryWithSlippage: (hash, slippage) => - set((s) => ({ - toasts: s.toasts.filter((t) => t.hash !== hash), - retrySlippage: slippage, - })), + set((s) => { + const toast = s.toasts.find((t) => t.hash === hash) + return { + toasts: s.toasts.filter((t) => t.hash !== hash), + // Preserve the sell-side amount so SwapForm can refill the input. Buy-side + // edits collapse to a sell-side restore on retry: the refetched quote + // reproduces the buy amount within a few wei of the original. + retryRequest: { slippage, amount: toast?.amountIn ?? "" }, + } + }), - clearRetrySlippage: () => set({ retrySlippage: null }), + clearRetryRequest: () => set({ retryRequest: null }), collapse: (hash) => set((s) => ({ From b179f24f1485a6fa43b6cc9feaf87dfe36bdaf34 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Fri, 15 May 2026 15:37:00 -0400 Subject: [PATCH 3/3] fix(swap): keep form populated on submit so retries see the original amounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executeSwap called onCloseAfterSuccess() immediately after every tx submission, wiping form.amount before the relayer even responded. That broke both retry paths — barter-slippage Retry in the toast AND the modal's Try Again button after a "dropped" / generic failure — because both reopen the confirmation modal, which snapshots from the form. The toast already receives onCloseAfterSuccess as its onPreConfirm callback and fires it when the swap actually preconfirms, so the immediate calls were redundant. Removing them lets the form retain the user's amounts through pending/failed states without changing the post-success flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/modals/SwapConfirmationModal.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/modals/SwapConfirmationModal.tsx b/src/components/modals/SwapConfirmationModal.tsx index 868b7716..d0499e3b 100644 --- a/src/components/modals/SwapConfirmationModal.tsx +++ b/src/components/modals/SwapConfirmationModal.tsx @@ -440,24 +440,27 @@ function SwapConfirmationModal({ if (refreshBalances) setTimeout(() => refreshBalances(), 1000) setTimeout(() => refetchMiles(), 5000) } + // NOTE: don't call onCloseAfterSuccess here. The toast already receives it + // as `onPreConfirm` (7th addToast arg) and will fire it on preconfirmation. + // Wiping the form on submit (the prior behavior) made retries impossible + // — both the barter-slippage retry and the modal's "Try Again" button + // reopen with empty amounts because the form is the source of truth that + // feeds the modal's snapshot. if (isWrap) { const hash = await wrap() notifySwapSubmitted(hash, estimatedMiles) addToast(hash, tokenIn, tokenOut, amountIn, amountOut, onConfirm, onCloseAfterSuccess) - onCloseAfterSuccess() onOpenChange(false) } else if (isUnwrap) { const hash = await unwrap() notifySwapSubmitted(hash, estimatedMiles) addToast(hash, tokenIn, tokenOut, amountIn, amountOut, onConfirm, onCloseAfterSuccess) - onCloseAfterSuccess() onOpenChange(false) } else { const hash = await confirmSwap({ onPendingHash: (ph) => { pendingPlaceholder = ph addToast(ph, tokenIn, tokenOut, amountIn, amountOut, onConfirm, onCloseAfterSuccess) - onCloseAfterSuccess() onOpenChange(false) // Close modal immediately; toast takes over }, }) @@ -469,7 +472,6 @@ function SwapConfirmationModal({ updateToastHash(pendingPlaceholder, hash) } else { addToast(hash, tokenIn, tokenOut, amountIn, amountOut, onConfirm, onCloseAfterSuccess) - onCloseAfterSuccess() } onOpenChange(false) }