Skip to content

Commit 7334b0d

Browse files
authored
Merge pull request #138 from primev/auto-slippage
feat(swap): rework slippage UX and amount-too-small guard
2 parents 8541230 + b77f3e9 commit 7334b0d

19 files changed

Lines changed: 1247 additions & 223 deletions

.nvmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
22.19.0

content/learn/what-are-fast-swaps.mdx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,46 @@ Swaps route through existing Ethereum liquidity — the protocol doesn't operate
6565

6666
## Slippage and execution
6767

68-
Because fast swaps confirm before the block, you face less execution uncertainty than standard swaps. The price you see is close to the price you get, since the builder commits to a specific execution within 200ms rather than leaving your transaction exposed to a full block of price movement.
68+
Slippage is the maximum price movement you accept between quote and execution. If the actual output falls below your tolerance, the swap reverts instead of filling at a worse price.
6969

70-
Slippage tolerance still matters for volatile pairs, but the window of exposure is compressed from 12+ seconds to sub-second.
70+
Because fast swaps confirm in ~200ms, your window of price exposure is compressed from a full 12+ second block down to sub-second — so the tolerance you need is typically much smaller than on a standard DEX. Fast Protocol leans into that with an **auto mode** that keeps defaults tight.
71+
72+
### Auto mode (default)
73+
74+
Auto mode picks the smallest tolerance that still lets the swap route reliably:
75+
76+
- **0.5%** when you're selling ETH
77+
- **1%** when you're selling an ERC-20 token (the permit path carries slightly more routing overhead)
78+
79+
If the router reports that the route can't actually deliver within the base tolerance — for example, because gas has spiked or the pair is temporarily thin — auto mode bumps your tolerance to **just above the observed routing shortfall, plus a small buffer**. The buffer is there so a subsequent quote refresh reporting slightly higher shortfall doesn't immediately re-block you.
80+
81+
Concrete example: if the route would deliver 2.7% less than the quote, auto sets your slippage to **3.2%** (2.7% rounded up + 0.5% buffer). If it would deliver 7.1% less, auto sets **7.6%**. Auto will scale up to the 50% ceiling if that's what the route actually needs — the design goal is that auto should unblock the swap, not strand you at a static value that wasn't enough.
82+
83+
When an auto-bump happens, the confirmation screen shows a note: *"Your slippage has been auto-adjusted to cover gas costs."*
84+
85+
### Custom mode
86+
87+
For volatile pairs, large trades, or tokens with transfer taxes, you can switch to **Custom** and set any value from the path floor (0.5% or 1%) up to **50%** — the same ceiling Uniswap uses.
88+
89+
### The >5% warning
90+
91+
Whenever the effective slippage — auto-bumped or custom — crosses **5%**, an amber banner opens in the settings popover:
92+
93+
> Slippage above 5% is unusual. You will earn more miles, but will likely receive less tokens.
94+
95+
That tradeoff is architectural, not advisory. On Fast Protocol, the settlement contract pays you exactly your slippage floor (the minimum you signed for) and retains everything above as **surplus** — which becomes your Miles. So a wider slippage means:
96+
97+
- Your floor (the tokens you actually receive) drops proportionally
98+
- The retained surplus grows by the same amount
99+
- Your Miles balance grows in lockstep with that surplus
100+
101+
At 0.5% slippage on a 1 ETH-equivalent trade, your floor is 0.995 of quote and any extra room becomes a small Miles credit. At 50% slippage on the same trade, your floor is 0.5 of quote — half the trade value flows through as Miles instead of tokens. That's mechanically what the contract does; it's not an estimate or a probability.
102+
103+
For ordinary swaps, stick with auto at its base tier — the goal of normal trading is to receive tokens, not maximize Miles. Wider slippage is for cases where you specifically prefer Miles to tokens, or where market conditions require it.
104+
105+
### Deadline
106+
107+
The swap deadline (default 30 minutes) controls how long the signed order remains valid. If the transaction isn't included within that window, it expires instead of executing at a stale price. Because fast swaps confirm in ~200ms, the deadline is effectively just a safety net for network-level failures.
71108

72109
<TryItCTA href="/" label="Try a fast swap" />
73110

docs/miles-estimation.md

Lines changed: 208 additions & 68 deletions
Large diffs are not rendered by default.
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
# Slippage model & the "Amount too small" guard
2+
3+
How slippage is modeled in the swap UI, why the "Amount too small" gate exists, and how user tolerance interacts with Barter's routing overhead.
4+
5+
---
6+
7+
## Overview
8+
9+
Two numbers run in parallel on every quote:
10+
11+
1. **Uniswap `amountOut`** — idealized on-chain price for the pair. This is what the UI shows in the "Receive" field. Fetched by `useQuote` (`src/hooks/use-swap-quote.ts`).
12+
2. **Barter `/route` `outputAmount`** — what an actually-routed swap would deliver after L1 gas, routing hops, and fill mechanics. Fetched by `useBarterValidation` (`src/hooks/use-barter-validation.ts`).
13+
14+
The gap between them is the **shortfall**:
15+
16+
```
17+
shortfallPct = (uniswapOut - barterOut) / uniswapOut * 100
18+
```
19+
20+
Slippage tolerance is how much of that shortfall the user will absorb before the on-chain swap reverts at its `minAmountOut` check.
21+
22+
---
23+
24+
## Slippage modes
25+
26+
Managed by `useSwapSlippage` (`src/hooks/use-swap-slippage.ts`).
27+
28+
### Auto mode (default)
29+
30+
Picks the smallest tolerance that still lets the swap route reliably:
31+
32+
| Path | Base tolerance |
33+
|------|----------------|
34+
| ETH input (`ZERO_ADDRESS`, non-WETH output) | **0.5%** |
35+
| Permit path (ERC-20 input) | **1%** |
36+
37+
The permit path carries slightly more routing overhead, so its floor is higher.
38+
39+
### Auto-bump for gas
40+
41+
When Barter's observed `shortfallPct` exceeds the auto base, auto mode bumps
42+
the visible slippage to **cover the shortfall plus a small buffer** so the
43+
amount-too-small gate actually clears.
44+
45+
```ts
46+
const autoBumpedForGas = mode === "auto" && barterShortfallPct > autoBase
47+
const autoSlippage = autoBumpedForGas
48+
? formatSlippage(computeAutoBumpValue(barterShortfallPct))
49+
: formatSlippage(autoBase)
50+
51+
// computeAutoBumpValue:
52+
// roundedUp = ceil(shortfall / SLIPPAGE_STEP) * SLIPPAGE_STEP // round up to 0.1%
53+
// return = min(SLIPPAGE_MAX, roundedUp + AUTO_BUMP_BUFFER_PCT) // + 0.5%, capped at 50%
54+
```
55+
56+
**Why the buffer.** Barter's routed output drifts slightly between quotes as
57+
pool state and gas move. Without headroom, a 15-second requote reporting
58+
shortfall 0.1–0.3% higher than the one we just bumped to would immediately
59+
re-block the swap with "Amount too small". `AUTO_BUMP_BUFFER_PCT = 0.5` is the
60+
smallest value that absorbs realistic requote jitter.
61+
62+
**Why the auto mode used to hardcode 2%.** An earlier iteration set
63+
`autoSlippage = "2"` on the assumption that 2% was always enough to route.
64+
For small trade sizes that assumption breaks — routing overhead can easily
65+
reach 5–10% of a tiny trade — and the user ended up with a bumped slippage
66+
that *still* didn't clear the gate, stranding them on "Amount too small" with
67+
no way to proceed short of switching to custom. Using the actual observed
68+
shortfall as the input makes auto reliably unblock the gate for any trade
69+
size within the UI cap.
70+
71+
**Worked examples:**
72+
73+
| Observed shortfall | Rounded up (0.1%) | + buffer | `autoSlippage` |
74+
|--------------------|-------------------|----------|----------------|
75+
| 0.3% ||| `autoBase` (no bump; under threshold) |
76+
| 0.6% | 0.6% | 1.1% | `1.1` |
77+
| 1.4% | 1.4% | 1.9% | `1.9` |
78+
| 2.7% | 2.7% | 3.2% | `3.2` |
79+
| 7.1% | 7.1% | 7.6% | `7.6` (crosses `SLIPPAGE_WARN_THRESHOLD`, warning fires) |
80+
| 49.9% | 49.9% | 50% | `50` (clamped at `SLIPPAGE_MAX`) |
81+
82+
When `autoBumpedForGas` is true, the confirmation modal shows:
83+
84+
> Your slippage has been auto-adjusted to cover gas costs
85+
86+
Rendered in `SwapConfirmationModal.tsx:1030` off the `autoAdjustedForGas` prop
87+
(`= form.autoBumpedForGas`).
88+
89+
### Custom mode
90+
91+
User-entered value, range `[customMin, 50]`:
92+
- `customMin` equals the path's auto base (0.5% or 1%) — the floor can't go below what auto would choose
93+
- Hard cap **50%**, matching Uniswap's UI
94+
95+
### The >5% warning (mode-agnostic)
96+
97+
Whenever the **effective** slippage (whatever `settings.slippage` resolves to
98+
— auto-bumped or custom) crosses `SLIPPAGE_WARN_THRESHOLD = 5`, the amber
99+
accordion banner in the settings popover opens:
100+
101+
> Slippage above 5% is unusual. You will earn more miles, but will likely receive less tokens.
102+
103+
The warning derivation lives in `useSwapSlippage` and reads `slippage`, not
104+
`customSlippage` — so an auto-bump into warning territory is still surfaced
105+
to the user rather than silent. Earlier versions gated the warning on
106+
`mode === "custom"`, which hid unusually-high auto-bumps.
107+
108+
---
109+
110+
## The "Amount too small" guard
111+
112+
### Purpose
113+
114+
The "Receive" number shown in the UI is the Uniswap quote. The on-chain swap actually goes through Barter's router, which carries real-world overhead — gas, hops, LP fills. For small trade sizes, that overhead is a significant fraction of the trade itself.
115+
116+
Without a pre-flight check, the user would sign a swap whose `minAmountOut` (derived from the Uniswap quote at their tight tolerance) is unreachable by the Barter-routed execution. The transaction would revert on-chain at the `minReturn` check — user pays gas for nothing and sees a confusing failure.
117+
118+
The guard catches this before the user signs, and replaces the swap button with `"Amount too small to swap"` (amber, disabled — priority 7 in the `ActionButton` cascade).
119+
120+
### How it's computed
121+
122+
Derived at the return site of `useBarterValidation`:
123+
124+
```ts
125+
const amountTooSmall =
126+
settled && // validation has completed
127+
shortfallPct > 0 && // we measured a real shortfall
128+
shortfallPct > maxSlippagePct // user's current tolerance can't cover it
129+
```
130+
131+
`maxSlippagePct` is piped in from `use-swap-form.ts` as `parseFloat(effectiveSlippage)` — it reflects **whatever the current slippage is right now**, auto or custom.
132+
133+
### Why `amountTooSmall` is derived, not stored
134+
135+
Earlier versions stored `amountTooSmall` in state and set it inside the API effect with a hardcoded `shortfall > 2.0` check. That caused two bugs:
136+
137+
1. **Stale gate after slippage bump** — if the user raised custom slippage to 5%, the gate didn't re-evaluate because there was no new Barter call to drive a new `setAmountTooSmall`.
138+
2. **Flicker loop** — the auto-bump path created a new quote object reference (same `amountOut`, new `slippageLimit`), which bumped `quoteGeneration`, which re-ran validation, which briefly flipped `settled=false` ("Calculating…") before re-settling on the same shortfall.
139+
140+
Deriving `amountTooSmall` from `shortfallPct` + live `maxSlippagePct` makes the gate instantly responsive to slippage changes with zero extra API calls.
141+
142+
---
143+
144+
## How slippage unblocks the guard
145+
146+
`minAmountOut` — the on-chain floor that makes the swap revert if exceeded — is derived from tolerance:
147+
148+
```
149+
minAmountOut = uniswapOut × (1 − slippage/100)
150+
```
151+
152+
So tolerance directly controls how much routing overhead the user absorbs.
153+
154+
**Worked example — sell 0.001 ETH → USDC:**
155+
156+
| Slippage | `minAmountOut` | Barter delivers 2.85 USDC | Result |
157+
|----------|----------------|---------------------------|--------|
158+
| 0.5% | 2.985 USDC | 2.85 < 2.985 | Blocked |
159+
| 2% | 2.94 USDC | 2.85 < 2.94 | Blocked |
160+
| 5% | 2.85 USDC | 2.85 ≥ 2.85 | **Unblocks** |
161+
| 50% | 1.50 USDC | 2.85 ≫ 1.50 | Unblocks (user eats the overhead) |
162+
163+
The guard isn't about Barter being broken — it's about the user's stated tolerance for quote-vs-execution divergence. Raising slippage widens that window so Barter's actual delivery falls inside it.
164+
165+
---
166+
167+
## Auto's upper bound
168+
169+
Auto scales up to `SLIPPAGE_MAX` (50%) if the observed shortfall demands it —
170+
there is no artificial ceiling below that. The expectation is that shortfalls
171+
large enough to push auto above `SLIPPAGE_WARN_THRESHOLD` only occur on
172+
genuinely marginal trade sizes, and for those users an executable path with a
173+
visible warning is more useful than a hard block.
174+
175+
Two backstops keep auto-bump honest:
176+
177+
1. The `"Your slippage has been auto-adjusted to cover gas costs"` note on the
178+
confirmation screen (fires for any `autoBumpedForGas = true`).
179+
2. The mode-agnostic `>5%` warning banner in the settings popover (fires
180+
whenever the numeric slippage crosses the threshold).
181+
182+
---
183+
184+
## Where this lives in code
185+
186+
| Concern | File |
187+
|--------|------|
188+
| Slippage mode, auto/custom state, warning derivation | `src/hooks/use-swap-slippage.ts` |
189+
| Uniswap quote + slippage-only `slippageLimit` recalc | `src/hooks/use-swap-quote.ts` |
190+
| Barter `/route` call, shortfall calc, `amountTooSmall` derivation | `src/hooks/use-barter-validation.ts` |
191+
| Call site: wires slippage → Barter → computedMinAmountOut | `src/hooks/use-swap-form.ts` |
192+
| Settings popover (Auto/Custom toggle, >5% warning banner) | `src/components/swap/TransactionSettings.tsx` |
193+
| Swap button state cascade (`Amount too small to swap`) | `src/components/swap/ActionButton.tsx` |
194+
| Confirmation note ("auto-adjusted to cover gas costs") | `src/components/modals/SwapConfirmationModal.tsx:1030` |
195+
| Post-failure retry recommender (caps at `SLIPPAGE_MAX`) | `src/lib/transaction-errors.ts` |
196+
197+
### Key constants
198+
199+
Defined in `use-swap-slippage.ts`:
200+
201+
| Constant | Value | Meaning |
202+
|----------|-------|---------|
203+
| `SLIPPAGE_MAX` | 50 | UI cap; matches Uniswap |
204+
| `SLIPPAGE_STEP` | 0.1 | Rounding granularity on blur |
205+
| `SLIPPAGE_WARN_THRESHOLD` | 5 | Above this, `slippageWarning = "high"` |
206+
| `AUTO_BASE_ETH` | 0.5 | Auto floor for ETH input |
207+
| `AUTO_BASE_PERMIT` | 1 | Auto floor for ERC-20 input |
208+
209+
---
210+
211+
## Stability invariants to preserve
212+
213+
Changes to any of these files should maintain:
214+
215+
1. **Slippage-only quote updates must NOT trigger Barter re-validation.** `quoteGeneration` in `use-swap-form.ts` is gated on `amountOut`/`amountIn` reference change — **not** on the quote object reference. A slippage change produces a new quote object (via the slippage-only effect in `use-swap-quote.ts`) with the same `amountOut`, and that must not bump `quoteGeneration`.
216+
2. **`amountTooSmall` must remain derived, not stored.** Storing it in state re-introduces the stale-after-bump bug.
217+
3. **Custom floor must not go below auto base.** The `useEffect` in `useSwapSlippage` clamps up when `customMin` rises (ETH → ERC-20 path switch). This effect must **not** depend on `customSlippage` itself — doing so re-runs it on every keystroke and rewrites partial input like `"0."` or `""` before the user can finish typing.

docs/swap-interface.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ The swap button (`ActionButton.tsx`) is a priority cascade — the first matchin
1616
| 4 | No amount entered (empty or "0") | **Enter an amount** | Gray, disabled |
1717
| 5 | Balance < entered amount | **Insufficient balance** | Gray, disabled |
1818
| 6 | No Uniswap pool/route exists | **This trade cannot be completed right now** | Gray, disabled |
19-
| 7 | Barter output shortfall > 2% | **Amount too small to swap** | Amber warning, disabled |
19+
| 7 | Barter output shortfall > user's current slippage tolerance | **Amount too small to swap** | Amber warning, disabled (see [slippage-and-amount-too-small-guard.md](./slippage-and-amount-too-small-guard.md)) |
2020
| 8 | Quote loading or Barter validating | **Calculating...** | Gray, disabled, spinner (1.5s min display to prevent flicker) |
2121
| 9 | Permit2 nonce loading | **Initializing...** | Gray, disabled, spinner |
2222
| 10 | All checks pass, wrap pair | **Wrap** | Gradient, clickable |

src/app/api/barter/route/route.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,22 @@ import { NextRequest, NextResponse } from "next/server"
33
const BARTER_API_BASE = "https://api2.eth.barterswap.xyz"
44

55
export async function POST(request: NextRequest) {
6+
let body: unknown
67
try {
7-
const body = await request.json()
8-
const { source, target, sellAmount, isEthInput } = body
8+
body = await request.json()
9+
} catch {
10+
// Empty or malformed body (typically a probe or stray retry). Respond cleanly
11+
// without polluting logs with a stack trace.
12+
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 })
13+
}
14+
15+
try {
16+
const { source, target, sellAmount, isEthInput } = (body ?? {}) as {
17+
source?: string
18+
target?: string
19+
sellAmount?: string
20+
isEthInput?: boolean
21+
}
922

1023
if (!source || !target || !sellAmount) {
1124
return NextResponse.json(
@@ -42,27 +55,38 @@ export async function POST(request: NextRequest) {
4255
return NextResponse.json({ error: msg }, { status: resp.status })
4356
}
4457

45-
// ETH input: user pays L1 gas from their own wallet, so Barter's raw
46-
// `outputAmount` (pre-gas) is what the user actually receives. For permit
47-
// (ERC-20 in) the relayer pays gas and deducts it from the output, so
48-
// `outputWithGasAmount` is the realized user amount. Using the wrong field
49-
// on ETH input produces a phantom shortfall vs. the Uniswap quote (which
50-
// never subtracts gas) and spuriously trips `amountTooSmall` when L1 gas
51-
// is elevated.
52-
const outputAmount = isEthInput === true ? data?.outputAmount : data?.outputWithGasAmount
58+
// Two distinct fields, two distinct downstream questions:
59+
//
60+
// - `outputAmount` (pre-gas) is what Barter's router would deliver before
61+
// relayer gas is deducted. This is the realized routing output regardless
62+
// of path, so it's the right basis for *MEV / surplus* estimation
63+
// (`barter_pre_gas - userAmtOut` matches what the FastSettlement contract
64+
// retains as surplus, which is what miles are credited from).
65+
//
66+
// - `outputWithGasAmount` (post-gas) is what the user actually receives
67+
// after the relayer deducts L1 gas on the permit path; on the ETH path
68+
// the user pays gas themselves, so it equals `outputAmount`. This is the
69+
// right basis for the *amount-too-small* gate (can the user actually
70+
// receive their floor on this trade?).
71+
//
72+
// Returning both lets the client expose two derived numbers without making
73+
// path-specific decisions in the proxy.
74+
const outputAmount = data?.outputAmount
75+
const outputWithGasAmount = isEthInput === true ? data?.outputAmount : data?.outputWithGasAmount
5376
const gasEstimation = data?.gasEstimation
5477
const transactionFee = data?.transactionFee
5578
const gasPrice = data?.gasPrice
5679

57-
if (outputAmount == null || gasEstimation == null) {
80+
if (outputAmount == null || outputWithGasAmount == null || gasEstimation == null) {
5881
return NextResponse.json(
5982
{ error: "Barter API error. Invalid route response." },
6083
{ status: 500 }
6184
)
6285
}
6386

6487
const response: Record<string, unknown> = {
65-
outputAmount: String(outputAmount),
88+
outputAmount: String(outputWithGasAmount),
89+
outputAmountPreGas: String(outputAmount),
6690
gasEstimation: Number(gasEstimation),
6791
}
6892
if (transactionFee != null) response.transactionFee = String(transactionFee)

src/app/icon.png

-47.6 KB
Binary file not shown.

0 commit comments

Comments
 (0)