Skip to content

fix(swap): cap treasury surplus via Barter-anchored floor on divergent quotes#117

Open
passandscore wants to merge 3 commits intomainfrom
excessive-surplus
Open

fix(swap): cap treasury surplus via Barter-anchored floor on divergent quotes#117
passandscore wants to merge 3 commits intomainfrom
excessive-surplus

Conversation

@passandscore
Copy link
Copy Markdown
Contributor

@passandscore passandscore commented Apr 14, 2026

Summary

Three related fixes to the surplus path. Two address observed bugs on the quote-guard (PR-original scope), and one fixes a miles-estimator methodology issue discovered while investigating inflated miles displays.

1. Quote guard (surplus leak on thin pools)

When Barter's routed output exceeds the Uniswap quote by more than a configurable divergence threshold, the protective floor (userAmtOut in the signed Permit2 intent) is derived from Barter's output minus a configurable treasury margin instead of the Uniswap-anchored slippageLimit.

Reproduction casetx 0x2f19...f507 (67 DAI → WBTC):

  • Before: Uniswap single-hop quote 31,220 sats → user signed 31,063 floor → received ~$23, treasury swept ~$47
  • After: 206% divergence triggers guard → floor = 95,445 × 0.985 = 94,013 sats → user receives ~$69.60, treasury margin capped at ~$1

2. Barter-failure policy

Previously, errors from /route were silently swallowed and swaps proceeded on the Uniswap-anchored floor. On thin pools during a Barter outage this re-introduced the original leak.

Now: one silent retry after 800ms absorbs transient blips; two consecutive failures flip barterUnavailable, which disables the swap button with a "Route unavailable — retrying" state. Clears automatically on the next successful validation (re-triggered by the 15s quote refresh tick).

3. Miles estimator surplus rate — sampling fix (new)

Investigating inflated miles estimates surfaced that the cron populating miles_estimate_surplus_rate in Edge Config had two independent problems:

  • Only sampled ~42% of swaps. The query divided user_amt_out / 1e18, assuming 18-decimal ETH/WETH output. To avoid garbage on erc20 outputs, it filtered swap_type = 'eth_weth' — silently excluding the 1,645 erc20→erc20 swaps out of 2,865 recent processed rows.
  • Used the wrong statistic. Realized surplus is strongly bimodal (a tight cluster near 0.5% for larger swaps, another near 2.1% for smaller swaps, nearly nothing in between). The median sits in the gap at ~0.9% — correct on paper, but no one experiences it.

Fixed by:

  • Switching the query to decimal-agnostic surplus / user_amt_out (both in same output token units → ratio is dimensionless, works for every swap type).
  • Dropping the swap_type = 'eth_weth' filter.
  • Using p25 instead of p50 so the estimate under-promises (users earn more than shown — desired UX direction).
  • Updating the four hardcoded fallbacks from 0.00680.0056 to match.

Configuration

Two new Edge Config keys for the quote guard (with built-in defaults if unset):

Key Default Purpose
quote_guard_divergence_threshold_pct 25 Trigger: Barter > Uniswap × (1 + X%)
quote_guard_treasury_margin_pct 1.5 Haircut applied to Barter output for the floor

Existing miles_estimate_surplus_rate is unchanged at the key level — the cron now writes p25 instead of p50 into the same key.

What's in the diff

Quote guard

  • src/lib/quote-guard.ts — pure helpers (isQuoteGuardTriggered, computeQuoteGuardFloor), 15 unit tests
  • src/app/api/config/quote-guard/route.ts — Edge Config reader endpoint
  • src/hooks/use-quote-guard-config.ts — client hook with defaults fallback
  • src/hooks/use-barter-validation.ts — exposes barterAmountOut; retries a single transient /route failure after 800ms and surfaces barterUnavailable after two consecutive failures
  • src/hooks/use-swap-form.ts — wires the guard into computedMinAmountOut and exposes displayedAmountOutFormatted to prevent Min > Expected inversion when the guard fires
  • src/components/swap/SwapForm.tsx, SwapInterface.tsx, ActionButton.tsx — four display sites swap to displayedAmountOutFormatted; new "Route unavailable — retrying" disabled state when barterUnavailable is set

Miles estimator surplus rate

  • src/lib/analytics/queries.ts — decimal-agnostic query, no swap-type filter
  • src/app/api/cron/update-edge-config/miles-estimate-gas/route.tscomputeMedianSurplusRatecomputeSurplusRateEstimate, picks p25, updated docstring with rationale
  • src/app/api/config/gas-estimate/route.ts, src/hooks/use-estimated-miles.ts, src/hooks/use-surplus-rate.ts, src/components/dashboard/user-swaps-parts.tsx — fallback constants 0.0068 → 0.0056, inline comments refreshed

Test plan

Automatedbunx vitest run (50 tests, all passing). Quote-guard math has 15 dedicated tests covering the reproduction case, routine majors (no false trigger), boundary math, misconfigured-threshold fail-closed behavior.

Manual (before merge)

  • Force-trigger guard via quote_guard_divergence_threshold_pct = 1 in Edge Config or local default — swap ETH → long-tail token, confirm modal shows Barter-derived "Expected" and "Minimum received" (no inversion)
  • Routine majors (ETH → USDC) — confirm guard does NOT fire, treasury still earns on small Barter advantages
  • Break /api/barter/route locally (throw in the handler) — confirm the swap button flips to "Route unavailable — retrying" after ~1.3s and clears on restore
  • Manually trigger the cron via Vercel Dashboard → confirm the log shows p25: X.XX% (chosen) and Edge Config value written matches
  • Confirm the new surplus rate produces sensible miles estimates across small ($25–35) and large ($150+) swaps

Action items for the team

  1. Create quote_guard_divergence_threshold_pct = 25 and quote_guard_treasury_margin_pct = 1.5 in the production Edge Config store (or tune after running historical fastswap data against candidate thresholds)
  2. Product/treasury sign-off on the 1.5% treasury margin figure
  3. Run the cron manually after merge to immediately write the new p25 value (~0.0056 today) rather than waiting for the next scheduled run

Follow-up ticketed

  • Size-bucketed surplus rate (small/mid/large tiers) to properly model the bimodal distribution — single p25 is a stopgap
  • Investigate why small swaps consistently show ~2% surplus while large swaps show ~0.6% (probable fixed per-swap cost)

…t quotes

When Uniswap's single-hop quote diverges from Barter's routed output by more
than a configurable threshold (default 25%), derive `userAmtOut` from
Barter's output minus a configurable treasury margin (default 1.5%) instead
of the Uniswap-anchored slippageLimit. Prevents the surplus leak seen on thin
direct pools (e.g. 67 DAI → WBTC tx 0x2f19...f507: user received ~$23 while
treasury swept ~$47).

Both thresholds live in Edge Config under:
- quote_guard_divergence_threshold_pct
- quote_guard_treasury_margin_pct

Pure guard math split into src/lib/quote-guard.ts with 15 unit tests covering
the reproduction case, boundary/misconfiguration behavior, and treasury-margin
precision.
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
fastprotocolapp Ready Ready Preview, Comment Apr 14, 2026 8:49pm

Request Review

Prior behaviour silently swallowed /route errors, leaving the swap button
active. Without Barter data the quote-guard cannot anchor minAmountOut on
Barter, so thin-pool pairs would fall back to the Uniswap-only floor — the
exact regression the guard was added to prevent.

useBarterValidation now retries a single transient failure after 800ms
(absorbs brief blips with no UI flicker) and flips a new barterUnavailable
flag when two consecutive attempts fail. ActionButton renders a disabled
"Route unavailable — retrying" state while the flag is set. The flag
clears automatically on the next successful validation, re-triggered by
the 15s quote refresh tick.
@passandscore
Copy link
Copy Markdown
Contributor Author

Screenshot 2026-04-14 at 3 45 01 PM

The cron that populates `miles_estimate_surplus_rate` in Edge Config had
two problems producing misleading miles estimates.

1. Swap coverage. The query divided `user_amt_out / 1e18`, which only
   makes sense for ETH/WETH outputs. To avoid garbage rates on erc20
   outputs (USDC has 6 decimals, WBTC has 8), it filtered
   `swap_type = 'eth_weth'` and silently excluded ~58% of production
   volume (1,645 of 2,865 recent processed rows are erc20→erc20).
   Fixed by dividing `surplus / user_amt_out` — both in the same output
   token units, so the ratio is decimal-agnostic and works for every
   swap type.

2. Statistic. The realized distribution is strongly bimodal: a tight
   cluster at 0.5–0.6% for larger swaps (~$130–230 out) and another at
   2–2.1% for smaller swaps (~$25–35 out). The median sits in the gap
   at ~0.9% — mathematically the middle, but very few users experience
   anything close to it. Switched to p25: aligns with the large-swap
   cluster, under-promises for small-swap users (they earn more than
   shown, the desired direction), and keeps the estimator from
   over-promising on either end.

Also updates the four hardcoded fallbacks from 0.0068 → 0.0056 to match
the new p25-of-all-swaps methodology, and refreshes the stale inline
comments that described the old semantics.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant