Skip to content

Commit 7703b69

Browse files
authored
Merge pull request #147 from primev/fastswap-sweep-bid-cost
fix: align sweep bid cost change, fix input reset on retry
2 parents d905fea + b179f24 commit 7703b69

7 files changed

Lines changed: 151 additions & 44 deletions

File tree

src/app/api/cron/update-edge-config/miles-estimate-gas/route.ts

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextResponse } from "next/server"
22
import { patchEdgeConfigItems } from "@/lib/vercel-edge-config"
33
import { getAnalyticsClient } from "@/lib/analytics/client"
4+
import { FAST_SETTLEMENT_EXECUTOR_ADDRESS, WETH_ADDRESS } from "@/lib/swap-constants"
45

56
// ---------------------------------------------------------------------------
67
// Constants
@@ -381,22 +382,48 @@ async function computeBidCostEstimate(): Promise<number> {
381382
/**
382383
* Per-token sweep overhead map: lowercased L1 token address → ETH overhead.
383384
*
384-
* Mirrors the backend's `costEstimator.Get` semantics in
385+
* Mirrors the backend's combined `PerRowOverhead` in
385386
* `mev-commit/tools/fastswap-miles/cost_estimator.go`:
386-
* - per-token p25 over the last 14 days when sample size ≥ 10
387-
* - per-token p75 when sample size < 10 (low-data fallback)
388-
* - `default` is a network-wide p25 across all tokens — used when the
389-
* frontend's selected output token isn't in the map at all
390-
* - falls back to `LAST_RESORT_SWEEP_OVERHEAD_ETH` if the network query
391-
* itself returns nothing usable
387+
* 1. Per-token p25/p75 of pro-rata sweep gas (`get-sweep-overhead-by-token`).
388+
* 2. Plus per-token sweep bid contribution (`get-sweep-bid-by-token`):
389+
* `(n_sweeps × global_bid_p75) / n_user_rows`. The sweep tx is itself a
390+
* fastswap so a global percentile of recent bid_cost is the right proxy.
391+
*
392+
* Both terms scale with batch size — low-volume tokens have a small number
393+
* of user rows per sweep so the per-row contribution is high, and high-
394+
* volume tokens dilute both.
395+
*
396+
* The `default` key falls back to `LAST_RESORT_SWEEP_OVERHEAD_ETH` so the
397+
* frontend's selected output token has a value even when not in the map.
392398
*
393399
* Returned values are ETH (float) so the route handler can ship the map
394400
* straight to Edge Config without extra encoding.
395401
*/
396402
async function computeSweepOverheadByToken(): Promise<Record<string, number>> {
397403
const client = getAnalyticsClient()
398404

399-
const rows = await client.execute("fastswap/get-sweep-overhead-by-token", {})
405+
const [gasRows, bidRows] = await Promise.all([
406+
client.execute("fastswap/get-sweep-overhead-by-token", {}),
407+
client.execute("fastswap/get-sweep-bid-by-token", {
408+
executor: FAST_SETTLEMENT_EXECUTOR_ADDRESS.toLowerCase(),
409+
weth: WETH_ADDRESS.toLowerCase(),
410+
fallback_bid_eth: FALLBACK_BID_COST_ETH,
411+
}),
412+
])
413+
414+
// Per-row sweep bid contribution, keyed by lowercased output_token.
415+
const bidByToken = new Map<string, number>()
416+
for (const row of bidRows) {
417+
const token = String(row[0] ?? "").toLowerCase()
418+
const nSweeps = Number(row[1])
419+
const nUsers = Number(row[2])
420+
const bidP75 = Number(row[3])
421+
if (!token || token === "null") continue
422+
if (!Number.isFinite(nSweeps) || nSweeps <= 0) continue
423+
if (!Number.isFinite(nUsers) || nUsers <= 0) continue
424+
if (!Number.isFinite(bidP75) || bidP75 <= 0) continue
425+
bidByToken.set(token, (nSweeps * bidP75) / nUsers)
426+
}
400427

401428
// `default` mirrors the backend's `costEstimateLastResort` exactly — for
402429
// tokens the backend has no historical data on, both estimators must
@@ -405,7 +432,7 @@ async function computeSweepOverheadByToken(): Promise<Record<string, number>> {
405432
// user gets fewer miles than the badge said.
406433
const map: Record<string, number> = { default: LAST_RESORT_SWEEP_OVERHEAD_ETH }
407434

408-
for (const row of rows) {
435+
for (const row of gasRows) {
409436
const token = String(row[0] ?? "").toLowerCase()
410437
const n = Number(row[1])
411438
const p25 = Number(row[2])
@@ -415,15 +442,18 @@ async function computeSweepOverheadByToken(): Promise<Record<string, number>> {
415442
if (!Number.isFinite(n) || n <= 0) continue
416443
if (!Number.isFinite(p25) || !Number.isFinite(p75)) continue
417444

418-
const overhead = n >= SWEEP_OVERHEAD_MIN_SAMPLES ? p25 : p75
419-
if (!Number.isFinite(overhead) || overhead < 0) continue
445+
const gasOverhead = n >= SWEEP_OVERHEAD_MIN_SAMPLES ? p25 : p75
446+
if (!Number.isFinite(gasOverhead) || gasOverhead < 0) continue
447+
448+
const sweepBid = bidByToken.get(token) ?? 0
449+
const combined = gasOverhead + sweepBid
420450

421451
// Round to 8 decimal places (0.01 µETH) to keep Edge Config JSON tight.
422-
map[token] = Math.round(overhead * 1e8) / 1e8
452+
map[token] = Math.round(combined * 1e8) / 1e8
423453
}
424454

425455
console.log(
426-
`[cron/miles-estimate-gas] sweepOverhead — ${Object.keys(map).length - 1} tokens, default=${(map.default * 1e6).toFixed(0)} µETH (last-resort), sample=${JSON.stringify(
456+
`[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(
427457
Object.fromEntries(
428458
Object.entries(map)
429459
.filter(([k]) => k !== "default")

src/components/modals/SwapConfirmationModal.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -440,24 +440,27 @@ function SwapConfirmationModal({
440440
if (refreshBalances) setTimeout(() => refreshBalances(), 1000)
441441
setTimeout(() => refetchMiles(), 5000)
442442
}
443+
// NOTE: don't call onCloseAfterSuccess here. The toast already receives it
444+
// as `onPreConfirm` (7th addToast arg) and will fire it on preconfirmation.
445+
// Wiping the form on submit (the prior behavior) made retries impossible
446+
// — both the barter-slippage retry and the modal's "Try Again" button
447+
// reopen with empty amounts because the form is the source of truth that
448+
// feeds the modal's snapshot.
443449
if (isWrap) {
444450
const hash = await wrap()
445451
notifySwapSubmitted(hash, estimatedMiles)
446452
addToast(hash, tokenIn, tokenOut, amountIn, amountOut, onConfirm, onCloseAfterSuccess)
447-
onCloseAfterSuccess()
448453
onOpenChange(false)
449454
} else if (isUnwrap) {
450455
const hash = await unwrap()
451456
notifySwapSubmitted(hash, estimatedMiles)
452457
addToast(hash, tokenIn, tokenOut, amountIn, amountOut, onConfirm, onCloseAfterSuccess)
453-
onCloseAfterSuccess()
454458
onOpenChange(false)
455459
} else {
456460
const hash = await confirmSwap({
457461
onPendingHash: (ph) => {
458462
pendingPlaceholder = ph
459463
addToast(ph, tokenIn, tokenOut, amountIn, amountOut, onConfirm, onCloseAfterSuccess)
460-
onCloseAfterSuccess()
461464
onOpenChange(false) // Close modal immediately; toast takes over
462465
},
463466
})
@@ -469,7 +472,6 @@ function SwapConfirmationModal({
469472
updateToastHash(pendingPlaceholder, hash)
470473
} else {
471474
addToast(hash, tokenIn, tokenOut, amountIn, amountOut, onConfirm, onCloseAfterSuccess)
472-
onCloseAfterSuccess()
473475
}
474476
onOpenChange(false)
475477
}

src/components/swap/SwapForm.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,26 +85,32 @@ export function SwapForm() {
8585
const [isConfirmationOpen, setIsConfirmationOpen] = useState(false)
8686
const [autoExecuteSwap, setAutoExecuteSwap] = useState(false)
8787
const lastTxError = useSwapToastStore((s) => s.lastTxError)
88-
const retrySlippage = useSwapToastStore((s) => s.retrySlippage)
89-
const clearRetrySlippage = useSwapToastStore((s) => s.clearRetrySlippage)
88+
const retryRequest = useSwapToastStore((s) => s.retryRequest)
89+
const clearRetryRequest = useSwapToastStore((s) => s.clearRetryRequest)
9090

9191
// Reopen confirmation modal when a tx fails after submit (e.g. status 0x0)
9292
useEffect(() => {
9393
if (lastTxError) setIsConfirmationOpen(true)
9494
}, [lastTxError])
9595

96-
// Barter slippage retry: update slippage, fetch fresh quote, then auto-execute
96+
// Barter slippage retry: restore the form's sell-side amount (the submit path
97+
// wipes it via `onCloseAfterSuccess` before the retry toast even appears),
98+
// update slippage, refetch the quote, then auto-execute when fresh data arrives.
9799
const [pendingRetry, setPendingRetry] = useState(false)
98100

99101
useEffect(() => {
100-
if (retrySlippage) {
101-
form.updateSlippage(retrySlippage)
102-
clearRetrySlippage()
102+
if (retryRequest) {
103+
if (retryRequest.amount) {
104+
form.setEditingSide("sell")
105+
form.setAmount(retryRequest.amount)
106+
}
107+
form.updateSlippage(retryRequest.slippage)
108+
clearRetryRequest()
103109
setIsConfirmationOpen(false)
104110
setPendingRetry(true)
105111
form.refetchQuote()
106112
}
107-
}, [retrySlippage, clearRetrySlippage, form])
113+
}, [retryRequest, clearRetryRequest, form])
108114

109115
// Wait for fresh quote to arrive before opening modal with auto-execute
110116
useEffect(() => {

src/hooks/use-estimated-miles.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -542,10 +542,11 @@ export function useEstimatedMiles({
542542
)
543543
const gasCostEth = isPermitPath ? Number(curBaseFee * predictedGasUsed) / 1e18 : 0
544544

545-
// Sweep overhead: per-token p25 of realized sweep gas, in ETH, from
546-
// Edge Config. The backend's `costEstimator` writes the same value and
547-
// subtracts it inside `awardUpfrontERC20Miles`. ETH/WETH output bypasses
548-
// sweeping entirely (the `eth_weth` path), so the term is zero there.
545+
// Sweep overhead: per-token estimate of pro-rata sweep gas + pro-rata
546+
// sweep bid, in ETH, from Edge Config. The backend's `costEstimator`
547+
// computes the same combined value and subtracts it inside
548+
// `awardUpfrontERC20Miles`. ETH/WETH output bypasses sweeping entirely
549+
// (the `eth_weth` path), so the term is zero there.
549550
const sweepOverheadEth = isEthOutput
550551
? 0
551552
: sweepOverheadForToken(sweepOverheadByTokenRef.current, outputTokenAddress)
@@ -586,7 +587,7 @@ export function useEstimatedMiles({
586587
` Step 4: Gas cost${isPermitPath ? " (relayer pays actual gasUsed on permit path)" : " (user pays on ETH path = 0)"}\n` +
587588
` gasCostEth = ${isPermitPath ? `${curBaseFee.toString()} wei × ${predictedGasUsed.toString()} predictedGasUsed (p75 ratio ${PREDICTED_GAS_USED_RATIO_P75}) / 1e18 = ` : ""}${gasCostEth.toFixed(8)} ETH\n` +
588589
(!isEthOutput
589-
? `\n Step 4b: Sweep overhead (non-ETH output, per-token p25 from Edge Config)\n` +
590+
? `\n Step 4b: Sweep overhead (non-ETH output, per-token sweep gas + sweep bid from Edge Config)\n` +
590591
` sweepOverheadEth = ${sweepOverheadEth.toFixed(8)} ETH (token=${outputTokenAddress ?? "unknown"})\n`
591592
: "") +
592593
`\n` +

src/lib/analytics/queries.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -582,14 +582,14 @@ WHERE processed = 1
582582

583583
// Per-token sweep overhead samples for the upfront miles estimator.
584584
//
585-
// Mirrors the backend's `costEstimator.Refresh` query in
586-
// `mev-commit/tools/fastswap-miles/cost_estimator.go` line-for-line — same
587-
// WHERE clauses, same 14-day lookback, same percentile selection. Keeping
588-
// the SQL identical is what locks the frontend estimate in step with what
589-
// `awardUpfrontERC20Miles` will subtract on the backend; any backend filter
585+
// Mirrors the gas-overhead portion of the backend's `costEstimator.Refresh`
586+
// query in `mev-commit/tools/fastswap-miles/cost_estimator.go` line-for-line.
587+
// The cron pairs this with GET_FASTSWAP_SWEEP_BID_BY_TOKEN below and folds
588+
// the sweep-bid contribution into the chosen percentile so the Edge Config
589+
// value matches the backend's combined `PerRowOverhead`. Any backend filter
590590
// change must be mirrored here.
591591
//
592-
// Per-row overhead = `surplus_eth − net_profit_eth − bid_cost/1e18`.
592+
// Per-row gas overhead = `surplus_eth − net_profit_eth − bid_cost/1e18`.
593593
// Restricted to ETH-input rows because ERC20-input rows have user_gas baked
594594
// into (surplus_eth − net_profit_eth), and the miles formula deducts
595595
// user_gas separately — including ERC20-input samples here would inflate
@@ -620,6 +620,49 @@ FROM (
620620
GROUP BY output_token
621621
`.trim()
622622

623+
// Per-token sweep bid contribution (ETH) for the upfront miles estimator.
624+
//
625+
// Mirrors `computePerTokenSweepBidEth` in
626+
// `mev-commit/tools/fastswap-miles/cost_estimator.go`. For each output_token T:
627+
//
628+
// per_row_sweep_bid_eth(T) = (n_sweeps(T) × global_bid_p75_eth) / n_user_rows(T)
629+
//
630+
// The sweep tx is itself a fastswap so global p75 of `bid_cost/1e18` is
631+
// the right reference population (tight distribution, many samples). p75 =
632+
// under-promise. Cron caller adds this per-row value to the chosen gas
633+
// percentile per token, writes the combined value to Edge Config.
634+
//
635+
// Params: `:executor` (lowercased), `:weth` (lowercased), `:fallback_bid_eth`
636+
// — used by COALESCE when the global percentile subquery returns NULL.
637+
export const GET_FASTSWAP_SWEEP_BID_BY_TOKEN = `
638+
SELECT s.token, s.n_sweeps, u.n_users, COALESCE(b.p, :fallback_bid_eth) AS bid_p75
639+
FROM (
640+
SELECT LOWER(input_token) AS token, COUNT(*) AS n_sweeps
641+
FROM mevcommit_57173.fastswap_miles
642+
WHERE LOWER(user_address) = :executor
643+
AND swap_type = 'eth_weth'
644+
AND LOWER(output_token) = :weth
645+
AND block_timestamp >= NOW() - INTERVAL 14 DAY
646+
GROUP BY input_token
647+
) s
648+
JOIN (
649+
SELECT LOWER(output_token) AS token, COUNT(*) AS n_users
650+
FROM mevcommit_57173.fastswap_miles
651+
WHERE swap_type = 'erc20'
652+
AND LOWER(user_address) != :executor
653+
AND block_timestamp >= NOW() - INTERVAL 14 DAY
654+
GROUP BY output_token
655+
) u ON u.token = s.token
656+
CROSS JOIN (
657+
SELECT percentile_approx(CAST(bid_cost AS DOUBLE)/1e18, 0.75) AS p
658+
FROM mevcommit_57173.fastswap_miles
659+
WHERE processed = 1
660+
AND bid_cost IS NOT NULL
661+
AND CAST(bid_cost AS DOUBLE) > 0
662+
AND block_timestamp >= NOW() - INTERVAL 14 DAY
663+
) b
664+
`.trim()
665+
623666
// Surplus rate samples for miles estimation.
624667
// Computes `surplus / user_amt_out` — both columns are in the SAME output-token
625668
// units (smallest denomination), so decimals cancel and the ratio is dimensionless.
@@ -715,6 +758,7 @@ export const QUERIES = {
715758
"fastswap/get-recent-tx-hashes": GET_RECENT_FASTSWAP_TX_HASHES,
716759
"fastswap/get-surplus-rates": GET_FASTSWAP_SURPLUS_RATES,
717760
"fastswap/get-sweep-overhead-by-token": GET_FASTSWAP_SWEEP_OVERHEAD_BY_TOKEN,
761+
"fastswap/get-sweep-bid-by-token": GET_FASTSWAP_SWEEP_BID_BY_TOKEN,
718762
"fastswap/get-bid-costs": GET_FASTSWAP_BID_COSTS,
719763

720764
// FastSwap miles domain

src/lib/swap-constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ export const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3" as c
55
export const FAST_SETTLEMENT_ADDRESS = "0x084C0EC7f5C0585195c1c713ED9f06272F48cB45" as const
66
export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const
77
export const WETH_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" as const
8+
// Executor wallet that submits permit-path swaps and pays sweep gas + bid.
9+
// Sourced from `FastSettlementV3.executor()` on-chain. Used by the miles
10+
// estimator cron to identify executor sweep rows when pricing sweep bids;
11+
// if this ever changes, mirror the new address in
12+
// `mev-commit/tools/fastswap-miles` via redeploy and update here.
13+
export const FAST_SETTLEMENT_EXECUTOR_ADDRESS =
14+
"0x959dad78d5b68986a43cd270134a2704a990aa68" as const
815

916
export const INTENT_DEADLINE_MINUTES = 30
1017

src/stores/swapToastStore.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,22 @@ export type SwapTxError = {
3838
occurredAfterPreConfirm?: boolean
3939
}
4040

41+
/** Captures everything SwapForm needs to reopen the modal with the same swap intent
42+
* the user just tried. We carry the amount alongside the slippage because the form's
43+
* `amount` state is cleared the moment the tx is submitted (see
44+
* `onCloseAfterSuccess` in `executeSwap`) — by the time the user clicks Retry the
45+
* failed toast is still showing the amounts but the form is empty. */
46+
export type SwapRetryRequest = {
47+
slippage: string
48+
amount: string
49+
}
50+
4151
type Store = {
4252
toasts: SwapToast[]
4353
/** Set when a tx fails after submit; SwapConfirmationModal shows error modal. Cleared when modal closes. */
4454
lastTxError: SwapTxError | null
4555
/** Set when user clicks "Retry with X%" on a barter slippage toast. SwapForm subscribes and reopens modal. */
46-
retrySlippage: string | null
56+
retryRequest: SwapRetryRequest | null
4757
addToast: (
4858
hash: string,
4959
tokenIn?: Token,
@@ -64,9 +74,10 @@ type Store = {
6474
/** Opens the error modal by setting lastTxError from the toast's stored error data. */
6575
showErrorForToast: (hash: string) => void
6676
clearLastTxError: () => void
67-
/** Removes the failed toast and sets retrySlippage so SwapForm can reopen the modal with updated slippage. */
77+
/** Removes the failed toast and sets retryRequest so SwapForm can restore the form's
78+
* amount, update the slippage, and reopen the modal. */
6879
requestRetryWithSlippage: (hash: string, slippage: string) => void
69-
clearRetrySlippage: () => void
80+
clearRetryRequest: () => void
7081
collapse: (hash: string) => void
7182
expand: (hash: string) => void
7283
removeToast: (hash: string) => void
@@ -77,7 +88,7 @@ type Store = {
7788
export const useSwapToastStore = create<Store>((set, get) => ({
7889
toasts: [],
7990
lastTxError: null,
80-
retrySlippage: null,
91+
retryRequest: null,
8192

8293
addToast: (hash, tokenIn, tokenOut, amountIn, amountOut, onConfirm, onPreConfirm) =>
8394
set((s) => ({
@@ -151,12 +162,18 @@ export const useSwapToastStore = create<Store>((set, get) => ({
151162
clearLastTxError: () => set({ lastTxError: null }),
152163

153164
requestRetryWithSlippage: (hash, slippage) =>
154-
set((s) => ({
155-
toasts: s.toasts.filter((t) => t.hash !== hash),
156-
retrySlippage: slippage,
157-
})),
165+
set((s) => {
166+
const toast = s.toasts.find((t) => t.hash === hash)
167+
return {
168+
toasts: s.toasts.filter((t) => t.hash !== hash),
169+
// Preserve the sell-side amount so SwapForm can refill the input. Buy-side
170+
// edits collapse to a sell-side restore on retry: the refetched quote
171+
// reproduces the buy amount within a few wei of the original.
172+
retryRequest: { slippage, amount: toast?.amountIn ?? "" },
173+
}
174+
}),
158175

159-
clearRetrySlippage: () => set({ retrySlippage: null }),
176+
clearRetryRequest: () => set({ retryRequest: null }),
160177

161178
collapse: (hash) =>
162179
set((s) => ({

0 commit comments

Comments
 (0)