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/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) } 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/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 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) => ({