|
| 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. |
0 commit comments