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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 43 additions & 13 deletions src/app/api/cron/update-edge-config/miles-estimate-gas/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -381,22 +382,48 @@ async function computeBidCostEstimate(): Promise<number> {
/**
* 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.
*/
async function computeSweepOverheadByToken(): Promise<Record<string, number>> {
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<string, number>()
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
Expand All @@ -405,7 +432,7 @@ async function computeSweepOverheadByToken(): Promise<Record<string, number>> {
// user gets fewer miles than the badge said.
const map: Record<string, number> = { 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])
Expand All @@ -415,15 +442,18 @@ async function computeSweepOverheadByToken(): Promise<Record<string, number>> {
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")
Expand Down
10 changes: 6 additions & 4 deletions src/components/modals/SwapConfirmationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
})
Expand All @@ -469,7 +472,6 @@ function SwapConfirmationModal({
updateToastHash(pendingPlaceholder, hash)
} else {
addToast(hash, tokenIn, tokenOut, amountIn, amountOut, onConfirm, onCloseAfterSuccess)
onCloseAfterSuccess()
}
onOpenChange(false)
}
Expand Down
20 changes: 13 additions & 7 deletions src/components/swap/SwapForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
11 changes: 6 additions & 5 deletions src/hooks/use-estimated-miles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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` +
Expand Down
56 changes: 50 additions & 6 deletions src/lib/analytics/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/lib/swap-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
35 changes: 26 additions & 9 deletions src/stores/swapToastStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -77,7 +88,7 @@ type Store = {
export const useSwapToastStore = create<Store>((set, get) => ({
toasts: [],
lastTxError: null,
retrySlippage: null,
retryRequest: null,

addToast: (hash, tokenIn, tokenOut, amountIn, amountOut, onConfirm, onPreConfirm) =>
set((s) => ({
Expand Down Expand Up @@ -151,12 +162,18 @@ export const useSwapToastStore = create<Store>((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) => ({
Expand Down
Loading