diff --git a/src/app/api/config/gas-estimate/route.test.ts b/src/app/api/config/gas-estimate/route.test.ts index 5bcfd6f9..43676765 100644 --- a/src/app/api/config/gas-estimate/route.test.ts +++ b/src/app/api/config/gas-estimate/route.test.ts @@ -16,6 +16,11 @@ const DEFAULT_GAS_LIMIT = 450_000 const DEFAULT_GAS_USED = 180_000 const DEFAULT_SURPLUS_RATE = 0.0056 const DEFAULT_MILES_CALC_MAX_SLIPPAGE = 50 +/** Mirrors `DEFAULT_SWEEP_OVERHEAD_FALLBACK` in route.ts and the backend's + * `costEstimateLastResort` (cost_estimator.go). */ +const DEFAULT_SWEEP_OVERHEAD: Record = { default: 0.001 } +/** Mirrors `DEFAULT_BID_COST_ETH` in route.ts — p75 of post-Apr-8 realized. */ +const DEFAULT_BID_COST_ETH = 0.00004 /** * Build a `mockGet` implementation that returns the values we want for each @@ -40,15 +45,24 @@ describe("GET /api/config/gas-estimate", () => { gasEstimate: DEFAULT_GAS_LIMIT, gasUsedEstimate: DEFAULT_GAS_USED, surplusRate: DEFAULT_SURPLUS_RATE, + sweepOverheadByToken: DEFAULT_SWEEP_OVERHEAD, + bidCostEth: DEFAULT_BID_COST_ETH, milesCalcMaxSlippagePct: DEFAULT_MILES_CALC_MAX_SLIPPAGE, }) }) it("passes through valid operator-set values", async () => { + const sweepMap = { + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": 0.00004, + "0xdac17f958d2ee523a2206206994597c13d831ec7": 0.00005, + default: 0.00008, + } mockKeys({ miles_estimate_gas_limit_average: 500_000, miles_estimate_gas_used_average: 200_000, miles_estimate_surplus_rate: 0.012, + miles_estimate_sweep_overhead_eth_by_token: sweepMap, + miles_estimate_bid_cost_eth: 0.000038, miles_calc_max_slippage_pct: 25, }) const res = await GET() @@ -58,10 +72,29 @@ describe("GET /api/config/gas-estimate", () => { gasEstimate: 500_000, gasUsedEstimate: 200_000, surplusRate: 0.012, + sweepOverheadByToken: sweepMap, + bidCostEth: 0.000038, milesCalcMaxSlippagePct: 25, }) }) + it("falls back to default sweep overhead when the map has a bad value", async () => { + mockKeys({ + // negative overhead is nonsensical — the route should reject and fall back + miles_estimate_sweep_overhead_eth_by_token: { "0xfoo": -1 }, + }) + const res = await GET() + const json = await res.json() + expect(json.sweepOverheadByToken).toEqual(DEFAULT_SWEEP_OVERHEAD) + }) + + it("falls back to default sweep overhead when the map is non-object", async () => { + mockKeys({ miles_estimate_sweep_overhead_eth_by_token: "not a map" }) + const res = await GET() + const json = await res.json() + expect(json.sweepOverheadByToken).toEqual(DEFAULT_SWEEP_OVERHEAD) + }) + it("clamps milesCalcMaxSlippagePct above the 50% ceiling", async () => { mockKeys({ miles_calc_max_slippage_pct: 75 }) const res = await GET() @@ -116,6 +149,8 @@ describe("GET /api/config/gas-estimate", () => { gasEstimate: DEFAULT_GAS_LIMIT, gasUsedEstimate: DEFAULT_GAS_USED, surplusRate: DEFAULT_SURPLUS_RATE, + sweepOverheadByToken: DEFAULT_SWEEP_OVERHEAD, + bidCostEth: DEFAULT_BID_COST_ETH, milesCalcMaxSlippagePct: DEFAULT_MILES_CALC_MAX_SLIPPAGE, }) }) @@ -127,9 +162,11 @@ describe("GET /api/config/gas-estimate", () => { expect(fetchedKeys).toEqual( [ "miles_calc_max_slippage_pct", + "miles_estimate_bid_cost_eth", "miles_estimate_gas_limit_average", "miles_estimate_gas_used_average", "miles_estimate_surplus_rate", + "miles_estimate_sweep_overhead_eth_by_token", ].sort() ) }) diff --git a/src/app/api/config/gas-estimate/route.ts b/src/app/api/config/gas-estimate/route.ts index 868d5567..8bfd2510 100644 --- a/src/app/api/config/gas-estimate/route.ts +++ b/src/app/api/config/gas-estimate/route.ts @@ -6,6 +6,22 @@ export const runtime = "edge" const DEFAULT_GAS_LIMIT = 450_000 const DEFAULT_GAS_USED = 180_000 const DEFAULT_SURPLUS_RATE = 0.0056 +/** + * Cold-load fallback for the sweep-overhead map. Mirrors the backend's + * `costEstimateLastResort` in `fastswap-miles/cost_estimator.go`. Used + * only when Edge Config has no `miles_estimate_sweep_overhead_eth_by_token` + * entry at all (e.g. the hourly cron hasn't run for the first time yet); + * once populated, the cron writes a `default` key alongside per-token + * values and clients read that instead. + */ +const DEFAULT_SWEEP_OVERHEAD_FALLBACK: Record = { default: 0.001 } +/** + * Cold-load fallback for the dashboard MilesCell bid-cost proxy. Same + * value the cron writes when no rows are sampled. Tracks p75 of realized + * bid_cost since 2026-04-08; tight enough post-fix that a single + * scalar is fine. + */ +const DEFAULT_BID_COST_ETH = 0.00004 /** Default upper bound the miles calculator will plan against, in percent. */ const DEFAULT_MILES_CALC_MAX_SLIPPAGE = 50 /** Hard floors and ceilings for the calc cap so a bad Edge Config value can't @@ -19,14 +35,25 @@ function clampMaxSlippage(value: number): number { return Math.min(MILES_CALC_MAX_SLIPPAGE_CEILING, Math.max(MILES_CALC_MAX_SLIPPAGE_FLOOR, value)) } +function isSweepOverheadMap(value: unknown): value is Record { + if (value == null || typeof value !== "object") return false + for (const v of Object.values(value as Record)) { + if (typeof v !== "number" || !Number.isFinite(v) || v < 0) return false + } + return true +} + export async function GET() { try { - const [gasLimit, gasUsed, surplusRate, milesCalcMaxSlippage] = await Promise.all([ - get("miles_estimate_gas_limit_average"), - get("miles_estimate_gas_used_average"), - get("miles_estimate_surplus_rate"), - get("miles_calc_max_slippage_pct"), - ]) + const [gasLimit, gasUsed, surplusRate, sweepOverheadByToken, bidCostEth, milesCalcMaxSlippage] = + await Promise.all([ + get("miles_estimate_gas_limit_average"), + get("miles_estimate_gas_used_average"), + get("miles_estimate_surplus_rate"), + get>("miles_estimate_sweep_overhead_eth_by_token"), + get("miles_estimate_bid_cost_eth"), + get("miles_calc_max_slippage_pct"), + ]) return NextResponse.json( { @@ -34,6 +61,11 @@ export async function GET() { gasUsedEstimate: typeof gasUsed === "number" && gasUsed > 0 ? gasUsed : DEFAULT_GAS_USED, surplusRate: typeof surplusRate === "number" && surplusRate > 0 ? surplusRate : DEFAULT_SURPLUS_RATE, + sweepOverheadByToken: isSweepOverheadMap(sweepOverheadByToken) + ? sweepOverheadByToken + : DEFAULT_SWEEP_OVERHEAD_FALLBACK, + bidCostEth: + typeof bidCostEth === "number" && bidCostEth > 0 ? bidCostEth : DEFAULT_BID_COST_ETH, milesCalcMaxSlippagePct: typeof milesCalcMaxSlippage === "number" && milesCalcMaxSlippage > 0 ? clampMaxSlippage(milesCalcMaxSlippage) @@ -48,6 +80,8 @@ export async function GET() { gasEstimate: DEFAULT_GAS_LIMIT, gasUsedEstimate: DEFAULT_GAS_USED, surplusRate: DEFAULT_SURPLUS_RATE, + sweepOverheadByToken: DEFAULT_SWEEP_OVERHEAD_FALLBACK, + bidCostEth: DEFAULT_BID_COST_ETH, milesCalcMaxSlippagePct: DEFAULT_MILES_CALC_MAX_SLIPPAGE, }, { headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300" } } 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 a9596041..fce42dba 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 @@ -31,6 +31,31 @@ const FALLBACK_GAS_USED = 180_000 /** Fallback p25 surplus rate (0.56% from 2026-04-14 all-swap sample). */ const FALLBACK_SURPLUS_RATE = 0.0056 +/** + * Mirrors the backend's `costEstimateLastResort` constant in + * `mev-commit/tools/fastswap-miles/cost_estimator.go`. Used as the JSON + * map's hardcoded `"default"` fallback when the cron can't compute a + * network-wide percentile (e.g. zero rows in the 14d window). The + * frontend hook reads this exact key out of the Edge Config map. + */ +const LAST_RESORT_SWEEP_OVERHEAD_ETH = 0.001 + +/** + * Minimum sample size before we trust per-token p25. Below this we fall + * back to that token's p75, exactly mirroring the backend's + * `costEstimateMinSweeps` constant. Keeps both estimators in lockstep + * even on low-volume tokens. + */ +const SWEEP_OVERHEAD_MIN_SAMPLES = 10 + +/** + * Fallback proxy for `bid_cost` on pending rows. Mirrors the hardcoded + * constant in `user-swaps-parts.tsx` and the post-2026-04-08 realized + * distribution's p75 — used when the analytics query can't produce a + * value (zero rows in window). + */ +const FALLBACK_BID_COST_ETH = 0.00004 + // --------------------------------------------------------------------------- // Auth helper // --------------------------------------------------------------------------- @@ -308,6 +333,109 @@ async function computeSurplusRateEstimate(): Promise { return rounded } +// --------------------------------------------------------------------------- +// Bid cost p75 +// --------------------------------------------------------------------------- + +/** + * Computes the p75 of realized bid_cost (in ETH) across processed rows + * since the 2026-04-08 regime change, 30-day window. Used by the + * dashboard's MilesCell proxy in lieu of the historically hardcoded + * `ESTIMATED_BID_COST_ETH` constant. p75 to mirror the surplus-rate + * cron's under-promise philosophy. + */ +async function computeBidCostEstimate(): Promise { + const client = getAnalyticsClient() + + const rows = await client.execute("fastswap/get-bid-costs", {}) + const values = rows + .map((row) => Number(row[0])) + .filter((v) => Number.isFinite(v) && v > 0) + .sort((a, b) => a - b) + + if (values.length === 0) { + console.warn("[cron/miles-estimate-gas] No bid cost samples returned — using fallback") + return FALLBACK_BID_COST_ETH + } + + const p75Index = Math.floor(values.length * 0.75) + const p75 = values[p75Index] + // Round to 8 decimal places (0.01 µETH) — same precision as sweep overhead. + const rounded = Math.round(p75 * 1e8) / 1e8 + + console.log( + `[cron/miles-estimate-gas] bidCost — count: ${values.length} ` + + `p25: ${(values[Math.floor(values.length * 0.25)] * 1e6).toFixed(2)} µETH ` + + `p50: ${(values[Math.floor(values.length * 0.5)] * 1e6).toFixed(2)} µETH ` + + `p75: ${(rounded * 1e6).toFixed(2)} µETH (chosen) ` + + `p95: ${(values[Math.floor(values.length * 0.95)] * 1e6).toFixed(2)} µETH` + ) + + return rounded +} + +// --------------------------------------------------------------------------- +// Sweep overhead by token +// --------------------------------------------------------------------------- + +/** + * Per-token sweep overhead map: lowercased L1 token address → ETH overhead. + * + * Mirrors the backend's `costEstimator.Get` semantics 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 + * + * Returned values are ETH (float) so the route handler can ship the map + * straight to Edge Config without extra encoding. + */ +async function computeSweepOverheadByToken(): Promise> { + const client = getAnalyticsClient() + + const rows = await client.execute("fastswap/get-sweep-overhead-by-token", {}) + + // `default` mirrors the backend's `costEstimateLastResort` exactly — for + // tokens the backend has no historical data on, both estimators must + // agree on the same fallback or the frontend will over-promise miles + // (frontend uses a small `default`, backend deducts 0.001 ETH) and the + // user gets fewer miles than the badge said. + const map: Record = { default: LAST_RESORT_SWEEP_OVERHEAD_ETH } + + for (const row of rows) { + const token = String(row[0] ?? "").toLowerCase() + const n = Number(row[1]) + const p25 = Number(row[2]) + const p75 = Number(row[3]) + + if (!token || token === "null") continue + 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 + + // Round to 8 decimal places (0.01 µETH) to keep Edge Config JSON tight. + map[token] = Math.round(overhead * 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( + Object.fromEntries( + Object.entries(map) + .filter(([k]) => k !== "default") + .slice(0, 3) + .map(([k, v]) => [k.slice(0, 8) + "…", `${(v * 1e6).toFixed(1)}µETH`]) + ) + )}` + ) + + return map +} + // --------------------------------------------------------------------------- // Route handler // --------------------------------------------------------------------------- @@ -327,7 +455,7 @@ async function computeSurplusRateEstimate(): Promise { * 4. Returns a JSON summary of the result. * * ### Schedule - * Configured in `vercel.json` to run daily at 00:00 UTC. + * Configured in `vercel.json` to run hourly at minute 0. * Can also be triggered manually from the Vercel Dashboard → Cron Jobs tab. */ export async function GET(request: Request) { @@ -339,14 +467,16 @@ export async function GET(request: Request) { try { // --- Step 2: Fetch the new values ---------------------------------------- - const [gasAverages, surplusRate] = await Promise.all([ + const [gasAverages, surplusRate, sweepOverheadByToken, bidCostEth] = await Promise.all([ computeGasAverages(), computeSurplusRateEstimate(), + computeSweepOverheadByToken(), + computeBidCostEstimate(), ]) const { gasLimitAvg, gasUsedAvg } = gasAverages console.log( - `[cron/miles-estimate-gas] Computed: gasLimit=${gasLimitAvg}, gasUsed=${gasUsedAvg}, surplusRate=${(surplusRate * 100).toFixed(2)}%` + `[cron/miles-estimate-gas] Computed: gasLimit=${gasLimitAvg}, gasUsed=${gasUsedAvg}, surplusRate=${(surplusRate * 100).toFixed(2)}%, bidCostEth=${bidCostEth}, sweepOverheadTokens=${Object.keys(sweepOverheadByToken).length - 1} (default=${sweepOverheadByToken.default})` ) // --- Step 3: Write to Edge Config --------------------------------------- @@ -370,6 +500,16 @@ export async function GET(request: Request) { key: "miles_estimate_surplus_rate", value: surplusRate, }, + { + operation: "upsert", + key: "miles_estimate_sweep_overhead_eth_by_token", + value: sweepOverheadByToken, + }, + { + operation: "upsert", + key: "miles_estimate_bid_cost_eth", + value: bidCostEth, + }, ]) console.log( @@ -384,6 +524,8 @@ export async function GET(request: Request) { gasLimitAverage: gasLimitAvg, gasUsedAverage: gasUsedAvg, surplusRate, + sweepOverheadByToken, + bidCostEth, }, vercelResponse: result, }) diff --git a/src/components/dashboard/UserSwapsModal.tsx b/src/components/dashboard/UserSwapsModal.tsx index 9ae6611d..9caa76c9 100644 --- a/src/components/dashboard/UserSwapsModal.tsx +++ b/src/components/dashboard/UserSwapsModal.tsx @@ -11,7 +11,7 @@ import { } from "@/components/ui/dialog" import { Skeleton } from "@/components/ui/skeleton" import { useUserSwaps } from "@/hooks/use-user-swaps" -import { useSurplusRate } from "@/hooks/use-surplus-rate" +import { useMilesEstimateConfig } from "@/hooks/use-miles-estimate-config" import { SwapsTableBody } from "./user-swaps-parts" type Props = { @@ -82,7 +82,7 @@ function UserSwapsModalBody({ address }: { address: string }) { page, pageSize: MODAL_PAGE_SIZE, }) - const surplusRate = useSurplusRate() + const { surplusRate, bidCostEth } = useMilesEstimateConfig() const { totalPages, total } = pagination @@ -123,7 +123,7 @@ function UserSwapsModalBody({ address }: { address: string }) { No Fast Swaps yet. Make your first swap to start earning miles. ) : ( - + )} diff --git a/src/components/dashboard/UserSwapsTable.tsx b/src/components/dashboard/UserSwapsTable.tsx index e543d518..9bb8c421 100644 --- a/src/components/dashboard/UserSwapsTable.tsx +++ b/src/components/dashboard/UserSwapsTable.tsx @@ -5,7 +5,7 @@ import { Loader2 } from "lucide-react" import { Card } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" import { useUserSwaps } from "@/hooks/use-user-swaps" -import { useSurplusRate } from "@/hooks/use-surplus-rate" +import { useMilesEstimateConfig } from "@/hooks/use-miles-estimate-config" import { SwapsTableBody } from "./user-swaps-parts" import { UserSwapsModal } from "./UserSwapsModal" @@ -36,7 +36,7 @@ export function UserSwapsTable({ address, isConnected }: Props) { page: 1, pageSize: RECENT_LIMIT, }) - const surplusRate = useSurplusRate() + const { surplusRate, bidCostEth } = useMilesEstimateConfig() return ( <> @@ -77,7 +77,7 @@ export function UserSwapsTable({ address, isConnected }: Props) { ) : ( <> - +