Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ out/
#Claude
.mcp.json
.claude/settings.local.json
.claude/scheduled_tasks.lock

# error-insights skill runtime output (library + daily reports)
/error-insights/
Expand Down
36 changes: 22 additions & 14 deletions content/learn/miles.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,11 @@ Your swap is sent through Fast RPC, where a **provider** (the block builder who

If a provider hasn't opened the commitment after an extended period, the protocol can step in to resolve it — but this requires manual intervention and doesn't happen instantly.

### Swap too small to generate mev
### Swap too small to generate mev at auto slippage

Very small swaps may not generate enough mev to cover the preconfirmation bid cost (the amount paid to the provider for committing to include the transaction). When the bid cost equals or exceeds the mev generated, the net mev is zero — and zero miles are earned. This isn't a delay; it's the expected outcome for swaps below a certain threshold.
Smaller swaps may not generate enough mev — at auto slippage — to cover the preconfirmation bid cost (the amount paid to the provider for committing to include the transaction). When the bid cost equals or exceeds the mev captured within the slippage tolerance, the default estimate is zero.

This isn't always the final word. Opening the **Calculate Miles** pill below the buy card lets you raise your slippage tolerance to capture surplus that would otherwise be unreachable — see [About the miles estimate](#about-the-miles-estimate). If the calculator shows "Swap too small to earn miles." even at its maximum slippage, the swap genuinely can't generate enough mev — that's the expected outcome below a certain threshold.

### Token sweep profitability

Expand All @@ -77,27 +79,33 @@ This means your miles for small ERC-20 output swaps may appear later — potenti

## About the miles estimate

Before you swap, Fast Protocol shows an estimated miles amount in the swap interface. This estimate is a **conservative lower bound**, not a guarantee or a ceiling.
Before you swap, Fast Protocol shows an estimated miles amount next to the exchange rate. This estimate is a **conservative lower bound**, not a guarantee or a ceiling — actual miles are computed from real on-chain activity once the swap settles, and are often higher.

### What the estimate can show

### Why the estimate can show "TBD"
- **`~N miles`** — the estimator has a confident lower bound at your current swap size and auto slippage. Treat this as a floor, not a forecast; the settled number is usually higher.
- **`—`** (dash) — the swap itself is in an error state (the size is below what the protocol can route). Adjust the amount; this isn't a miles problem.
- **`Apply Miles`** — at your current swap size, the auto-slippage tolerance doesn't leave room for mev surplus to be captured, so the default estimate is zero. Miles aren't disabled for this swap, they're just not reachable without a higher slippage. Open the calculator to apply manually (see below).

The miles an individual swap earns depends on factors the UI can't know in advance — market conditions at execution time, the mev opportunity your swap creates, and gas costs. Because the estimate runs before any of that is known, the UI deliberately errs on the low side to avoid over-promising.
The estimate deliberately errs low. Over-predicting miles would be worse than under-predicting — if the displayed number consistently exceeded the settled number, trust would collapse. The conservative approach means users are sometimes pleasantly surprised, and never disappointed by inflated predictions.

For some swaps, the conservative calculation can't confidently predict a number, so the UI shows **TBD** (to be determined). **This does not mean the swap will earn zero miles.** In practice, many swaps that show TBD go on to earn miles once the swap settles on-chain.
### Applying miles manually with the calculator

### What to trust instead
Below the buy card you'll see a **Calculate Miles** pill. Opening it reveals:

- The **estimate** is a directional signal. Treat it as a floor, not a forecast.
- The **actual miles earned** are computed from real on-chain activity after the swap settles.
- Your **dashboard swap history** shows the finalized miles for each transaction once processing completes. This is the authoritative number.
1. **Earn up to *N* miles** — the upper bound on what's achievable at your current swap size with the maximum slippage the calculator allows. ("Swap too small to earn miles." appears here when even max slippage can't capture any surplus.)
2. **Enable** — switches to an input where you type a target miles number (capped at the upper bound).
3. **Apply** — confirms the trade-off: the calculator raises your slippage tolerance just enough to capture the target miles. **Your minimum received goes down by the same amount.** The swap itself isn't resized; only your slippage is.

If you see TBD or a low estimate and still want to swap, go ahead — the real number is computed post-settlement and may be higher.
Once applied, the buy card's headline minimum received reflects the new slippage. The calculator resets when you switch tokens, edit the swap amount, or complete a swap — so applied slippage never silently carries over to a different trade.

### Why not just show a bigger estimate?
### What to trust as the final number

Over-predicting miles would be worse than under-predicting. If the estimate consistently showed more miles than users actually earned, trust would collapse. The conservative approach means users are sometimes pleasantly surprised, and never disappointed by inflated predictions.
- The **estimate** (or the calculator's "Earn up to") is a pre-trade projection.
- The **actual miles earned** are computed from on-chain activity after the swap settles.
- Your **[dashboard swap history](/dashboard)** shows the finalized miles per transaction. This is the authoritative number.

As we gather more data from real swaps, the estimator becomes more accurate. The tradeoff is intentional: accuracy improves with volume, and the cost of being wrong is paid in caution rather than exaggeration.
As more swaps settle, the estimator becomes more accurate. Accuracy improves with volume; the cost of being wrong is paid in caution rather than exaggeration.

## Miles and mev rewards

Expand Down
1 change: 1 addition & 0 deletions docs/miles-estimation.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ The console log on every recompute reports which path fired:
| `avg_gas_limit` | Edge Config (`gasEstimate`) | `450_000` | Daily, by cron |
| `avg_gas_used` | Edge Config (`gasUsedEstimate`) — used **only** on permit path for the gas-cost term | `180_000` | Daily, by cron |
| `output_amount_in_eth` | If output is ETH/WETH: used directly. Otherwise: `amountOut × toTokenPriceUSD / ethPriceUSD` | — | Per quote |
| `miles_calc_max_slippage_pct` | Edge Config — operator dial for the calculator's slippage ceiling. Read by `useEstimatedMiles`; clamped to [1%, 50%] in the route. Drives both `maxAchievableMiles` and the `milesToSlippage` planner cap. | `50` | On mount |
| `0.9` (`USER_MEV_SHARE`) | Constant — user receives 90% of captured MEV | — | — |
| `100_000` (`MILES_PER_ETH`) | Constant — 1 mile = 0.00001 ETH | — | — |

Expand Down
136 changes: 136 additions & 0 deletions src/app/api/config/gas-estimate/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from "vitest"

// Mock @vercel/edge-config's `get` so we can drive the keys per-test without
// hitting any real config store. The route reads four keys today; this lets
// us return whatever shape we need (number | null | bad type).
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }))

vi.mock("@vercel/edge-config", () => ({
get: mockGet,
}))

// Import AFTER the mock is registered.
import { GET } from "./route"

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

/**
* Build a `mockGet` implementation that returns the values we want for each
* Edge Config key. Any key not present in the map resolves to `undefined`,
* which the route treats as "use default."
*/
function mockKeys(values: Partial<Record<string, unknown>>) {
mockGet.mockImplementation(async (key: string) => values[key])
}

describe("GET /api/config/gas-estimate", () => {
beforeEach(() => {
mockGet.mockReset()
})

it("returns defaults when all Edge Config keys are missing", async () => {
mockKeys({})
const res = await GET()
const json = await res.json()

expect(json).toEqual({
gasEstimate: DEFAULT_GAS_LIMIT,
gasUsedEstimate: DEFAULT_GAS_USED,
surplusRate: DEFAULT_SURPLUS_RATE,
milesCalcMaxSlippagePct: DEFAULT_MILES_CALC_MAX_SLIPPAGE,
})
})

it("passes through valid operator-set values", async () => {
mockKeys({
miles_estimate_gas_limit_average: 500_000,
miles_estimate_gas_used_average: 200_000,
miles_estimate_surplus_rate: 0.012,
miles_calc_max_slippage_pct: 25,
})
const res = await GET()
const json = await res.json()

expect(json).toEqual({
gasEstimate: 500_000,
gasUsedEstimate: 200_000,
surplusRate: 0.012,
milesCalcMaxSlippagePct: 25,
})
})

it("clamps milesCalcMaxSlippagePct above the 50% ceiling", async () => {
mockKeys({ miles_calc_max_slippage_pct: 75 })
const res = await GET()
const json = await res.json()
// The cap is hard-bounded: a typo or out-of-range value can't unlock
// slippage > 50% in the calculator.
expect(json.milesCalcMaxSlippagePct).toBe(50)
})

it("clamps milesCalcMaxSlippagePct below the 1% floor", async () => {
mockKeys({ miles_calc_max_slippage_pct: 0.25 })
const res = await GET()
const json = await res.json()
// A value too small would drop the planner's cap below path autoBase
// (1% on permit), collapsing the inverse range to zero. Clamp to the
// floor instead.
expect(json.milesCalcMaxSlippagePct).toBe(1)
})

it("falls back to default when milesCalcMaxSlippagePct is non-numeric", async () => {
mockKeys({ miles_calc_max_slippage_pct: "twenty-five" })
const res = await GET()
const json = await res.json()
expect(json.milesCalcMaxSlippagePct).toBe(DEFAULT_MILES_CALC_MAX_SLIPPAGE)
})

it("falls back to default when milesCalcMaxSlippagePct is null", async () => {
mockKeys({ miles_calc_max_slippage_pct: null })
const res = await GET()
const json = await res.json()
expect(json.milesCalcMaxSlippagePct).toBe(DEFAULT_MILES_CALC_MAX_SLIPPAGE)
})

it("falls back to default when milesCalcMaxSlippagePct is zero or negative", async () => {
mockKeys({ miles_calc_max_slippage_pct: 0 })
let res = await GET()
let json = await res.json()
expect(json.milesCalcMaxSlippagePct).toBe(DEFAULT_MILES_CALC_MAX_SLIPPAGE)

mockKeys({ miles_calc_max_slippage_pct: -10 })
res = await GET()
json = await res.json()
expect(json.milesCalcMaxSlippagePct).toBe(DEFAULT_MILES_CALC_MAX_SLIPPAGE)
})

it("returns defaults when Edge Config throws", async () => {
mockGet.mockRejectedValue(new Error("edge config offline"))
const res = await GET()
const json = await res.json()

expect(json).toEqual({
gasEstimate: DEFAULT_GAS_LIMIT,
gasUsedEstimate: DEFAULT_GAS_USED,
surplusRate: DEFAULT_SURPLUS_RATE,
milesCalcMaxSlippagePct: DEFAULT_MILES_CALC_MAX_SLIPPAGE,
})
})

it("each Edge Config key is fetched exactly once per request", async () => {
mockKeys({})
await GET()
const fetchedKeys = mockGet.mock.calls.map(([k]) => k as string).sort()
expect(fetchedKeys).toEqual(
[
"miles_calc_max_slippage_pct",
"miles_estimate_gas_limit_average",
"miles_estimate_gas_used_average",
"miles_estimate_surplus_rate",
].sort()
)
})
})
20 changes: 19 additions & 1 deletion src/app/api/config/gas-estimate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,26 @@ export const runtime = "edge"
const DEFAULT_GAS_LIMIT = 450_000
const DEFAULT_GAS_USED = 180_000
const DEFAULT_SURPLUS_RATE = 0.0056
/** 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
* break the inverse planner. The min must stay above the path autoBase
* (1% on permit) so `Math.min(SLIPPAGE_MAX, Math.max(autoBase, …))` doesn't
* collapse the planner's range to zero. */
const MILES_CALC_MAX_SLIPPAGE_FLOOR = 1
const MILES_CALC_MAX_SLIPPAGE_CEILING = 50

function clampMaxSlippage(value: number): number {
return Math.min(MILES_CALC_MAX_SLIPPAGE_CEILING, Math.max(MILES_CALC_MAX_SLIPPAGE_FLOOR, value))
}

export async function GET() {
try {
const [gasLimit, gasUsed, surplusRate] = await Promise.all([
const [gasLimit, gasUsed, surplusRate, milesCalcMaxSlippage] = await Promise.all([
get<number>("miles_estimate_gas_limit_average"),
get<number>("miles_estimate_gas_used_average"),
get<number>("miles_estimate_surplus_rate"),
get<number>("miles_calc_max_slippage_pct"),
])

return NextResponse.json(
Expand All @@ -21,6 +34,10 @@ export async function GET() {
gasUsedEstimate: typeof gasUsed === "number" && gasUsed > 0 ? gasUsed : DEFAULT_GAS_USED,
surplusRate:
typeof surplusRate === "number" && surplusRate > 0 ? surplusRate : DEFAULT_SURPLUS_RATE,
milesCalcMaxSlippagePct:
typeof milesCalcMaxSlippage === "number" && milesCalcMaxSlippage > 0
? clampMaxSlippage(milesCalcMaxSlippage)
: DEFAULT_MILES_CALC_MAX_SLIPPAGE,
},
{ headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300" } }
)
Expand All @@ -31,6 +48,7 @@ export async function GET() {
gasEstimate: DEFAULT_GAS_LIMIT,
gasUsedEstimate: DEFAULT_GAS_USED,
surplusRate: DEFAULT_SURPLUS_RATE,
milesCalcMaxSlippagePct: DEFAULT_MILES_CALC_MAX_SLIPPAGE,
},
{ headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300" } }
)
Expand Down
Loading
Loading