From 046e5a603c6b51a622378a8e6fcba42f609f942e Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Tue, 28 Apr 2026 17:30:59 -0300 Subject: [PATCH 01/13] feat(swap): convert miles badge into inline calculator Add milesToAmountOut inverse helper to useEstimatedMiles so a target miles value resolves to the required buy-token amount in closed form (no extra RPC). RewardsBadge expands smoothly to the swap interface width with a single pill that animates max-width and crossfades between the collapsed badge and a target -> required buy amount calculator. Typing live-populates the buy field via setEditingSide "buy" + setAmount; the existing quote pipeline takes over from there and miles re-derive from the real amountOut, so post-quote drift is handled naturally. Also gitignore the .claude scheduled-tasks lock file. --- .gitignore | 1 + src/components/swap/RewardsBadge.tsx | 164 +++++++++++++++++++++++--- src/components/swap/SwapForm.tsx | 14 ++- src/components/swap/SwapInterface.tsx | 8 +- src/hooks/use-estimated-miles.ts | 52 +++++++- 5 files changed, 216 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index f8fe5b0f..f3f5357a 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/src/components/swap/RewardsBadge.tsx b/src/components/swap/RewardsBadge.tsx index d92f179d..c438c1d5 100644 --- a/src/components/swap/RewardsBadge.tsx +++ b/src/components/swap/RewardsBadge.tsx @@ -1,27 +1,157 @@ "use client" -import React from "react" +import React, { useEffect, useMemo, useRef, useState } from "react" import Image from "next/image" +import { ArrowRight, Calculator, X } from "lucide-react" +import { Token } from "@/types/swap" + +interface RewardsBadgeProps { + toToken: Token | null + milesToAmountOut: (targetMiles: number) => number | null + onApply: (amountOut: string) => void +} + +const formatAmount = (n: number): string => { + if (n >= 1) return n.toFixed(Math.min(6, Math.max(2, 6 - Math.floor(Math.log10(n))))) + if (n >= 0.0001) return n.toFixed(6) + return n.toPrecision(3) +} + +const RewardsBadgeComponent = ({ toToken, milesToAmountOut, onApply }: RewardsBadgeProps) => { + const [isOpen, setIsOpen] = useState(false) + const [target, setTarget] = useState("1") + const inputRef = useRef(null) + + const parsed = useMemo(() => { + const n = parseFloat(target.replace(/,/g, "")) + return Number.isFinite(n) && n > 0 ? n : null + }, [target]) + + const requiredAmountOut = useMemo(() => { + if (parsed == null) return null + return milesToAmountOut(parsed) + }, [parsed, milesToAmountOut]) + + // Skip the first effect run after open so we don't overwrite an existing + // buy amount until the user actually edits the calculator. + const lastAppliedRef = useRef(null) + const didMountRef = useRef(false) + useEffect(() => { + if (!isOpen) return + if (!didMountRef.current) { + didMountRef.current = true + return + } + const next = parsed != null && requiredAmountOut != null ? formatAmount(requiredAmountOut) : "" + if (next !== lastAppliedRef.current) { + lastAppliedRef.current = next + onApply(next) + } + }, [isOpen, parsed, requiredAmountOut, onApply]) + + useEffect(() => { + if (isOpen) { + const t = setTimeout(() => { + const el = inputRef.current + if (!el) return + el.focus() + const end = el.value.length + el.setSelectionRange(end, end) + }, 320) + return () => clearTimeout(t) + } + didMountRef.current = false + lastAppliedRef.current = null + setTarget("1") + }, [isOpen]) -const RewardsBadgeComponent = () => { return (
-
-
-
-
+
+ {/* Collapsed and expanded layers stack absolutely so the pill animates + its max-width while the contents crossfade. */} + + +
+ + +
+ setTarget(e.target.value.replace(/[^0-9.,]/g, ""))} + placeholder="0" + aria-label="Target miles" + tabIndex={isOpen ? 0 : -1} + className="w-full min-w-0 bg-transparent text-right text-base font-bold text-foreground outline-none tabular-nums placeholder:text-foreground/30 sm:text-lg" + /> + + miles + +
+ + + +
+ + {requiredAmountOut != null ? formatAmount(requiredAmountOut) : "—"} + + + {toToken?.symbol ?? "buy"} + +
+ +
- - Earning Fast Rewards - - Fast
) diff --git a/src/components/swap/SwapForm.tsx b/src/components/swap/SwapForm.tsx index 2c210c9d..c220bfee 100644 --- a/src/components/swap/SwapForm.tsx +++ b/src/components/swap/SwapForm.tsx @@ -1,6 +1,6 @@ "use client" -import React, { useState, useRef, useEffect, useMemo } from "react" +import React, { useState, useRef, useEffect, useMemo, useCallback } from "react" import dynamic from "next/dynamic" import { useAccount } from "wagmi" import { useSwapToastStore } from "@/stores/swapToastStore" @@ -49,7 +49,7 @@ export function SwapForm() { ? form.displayedAmountOutFormatted : form.amount - const { estimatedMiles: rawEstimatedMiles } = useEstimatedMiles({ + const { estimatedMiles: rawEstimatedMiles, milesToAmountOut } = useEstimatedMiles({ amountOut: milesAmountOut, slippage: form.slippage, toTokenPrice: form.toPrice, @@ -123,6 +123,14 @@ export function SwapForm() { setIsConfirmationOpen(true) } + const handleApplyMilesCalc = useCallback( + (amountOut: string) => { + form.setEditingSide("buy") + form.setAmount(amountOut) + }, + [form] + ) + return (
{/* From Token Selector Modal */} diff --git a/src/components/swap/SwapInterface.tsx b/src/components/swap/SwapInterface.tsx index 1de30ef8..f61d6e5f 100644 --- a/src/components/swap/SwapInterface.tsx +++ b/src/components/swap/SwapInterface.tsx @@ -95,6 +95,8 @@ interface SwapInterfaceProps { barterUnavailable: boolean isBarterValidating: boolean estimatedMiles?: number | null + milesToAmountOut: (targetMiles: number) => number | null + onApplyMilesCalc: (amountOut: string) => void } export const SwapInterface: React.FC = (props) => { @@ -273,7 +275,11 @@ export const SwapInterface: React.FC = (props) => { isNonceLoading={isNonceLoading} /> - +
) } diff --git a/src/hooks/use-estimated-miles.ts b/src/hooks/use-estimated-miles.ts index 424a1c17..02de6e96 100644 --- a/src/hooks/use-estimated-miles.ts +++ b/src/hooks/use-estimated-miles.ts @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useMemo, useRef } from "react" +import { useState, useEffect, useMemo, useRef, useCallback } from "react" import { RPC_ENDPOINT } from "@/lib/network-config" /** Fallback average gas limit for bid cost calculation (priorityFee × gasLimit) */ @@ -33,6 +33,17 @@ interface UseEstimatedMilesParams { enabled: boolean } +export interface UseEstimatedMilesReturn { + estimatedMiles: number | null + /** + * Inverse of the miles formula: given a target miles count, return the + * required output token amount (in display units) needed to earn it. + * Returns null if gas/price data isn't loaded yet or the target is + * below the cost floor (i.e. not earnable at current gas). + */ + milesToAmountOut: (targetMiles: number) => number | null +} + export function useEstimatedMiles({ amountOut, slippage, @@ -42,7 +53,7 @@ export function useEstimatedMiles({ baseFeePerGas, isPermitPath, enabled, -}: UseEstimatedMilesParams): { estimatedMiles: number | null } { +}: UseEstimatedMilesParams): UseEstimatedMilesReturn { const [priorityFee, setPriorityFee] = useState(null) const [avgGasLimit, setAvgGasLimit] = useState(DEFAULT_AVG_GAS_LIMIT) const [avgGasUsed, setAvgGasUsed] = useState(DEFAULT_AVG_GAS_USED) @@ -234,5 +245,40 @@ export function useEstimatedMiles({ if (rawMiles != null) lastMilesRef.current = rawMiles const estimatedMiles = rawMiles ?? lastMilesRef.current - return { estimatedMiles } + // Inverse of the forward calc above. Reads from the same refs so it stays + // in sync with the latest gas/surplus data without re-rendering on ticks. + const milesToAmountOut = useCallback( + (targetMiles: number): number | null => { + if (!Number.isFinite(targetMiles) || targetMiles <= 0) return null + const curPriorityFee = priorityFeeRef.current + const curBaseFee = baseFeeRef.current + if (curPriorityFee == null || curBaseFee == null) return null + if (!isEthOutput && (toTokenPrice == null || toTokenPrice <= 0)) return null + if (!isEthOutput && (!ethPrice || ethPrice <= 0)) return null + + const curAvgGasLimit = avgGasLimitRef.current + const curAvgGasUsed = avgGasUsedRef.current + const curSurplusRate = surplusRateRef.current + + const bidCostEth = Number(curPriorityFee * curAvgGasLimit) / 1e18 + const gasCostEth = isPermitPath ? Number(curBaseFee * curAvgGasUsed) / 1e18 : 0 + const sweepMultiplier = isEthOutput ? 1 : 2.5 + const totalBidCost = bidCostEth * sweepMultiplier + const totalGasCost = gasCostEth * sweepMultiplier + + const userMevEth = targetMiles / MILES_PER_ETH + const netMevEth = userMevEth / USER_MEV_SHARE + const slippageAmountEth = netMevEth + totalBidCost + totalGasCost + const outputInEth = slippageAmountEth / curSurplusRate + if (!Number.isFinite(outputInEth) || outputInEth <= 0) return null + + const result = isEthOutput + ? outputInEth + : (outputInEth * (ethPrice as number)) / (toTokenPrice as number) + return Number.isFinite(result) && result > 0 ? result : null + }, + [isEthOutput, isPermitPath, toTokenPrice, ethPrice] + ) + + return { estimatedMiles, milesToAmountOut } } From 3da7fedc2504b354e6dfb431ce1f7cb8a4410076 Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Wed, 29 Apr 2026 07:02:06 -0300 Subject: [PATCH 02/13] feat(swap): match wallet's gas display + zero-tip ETH path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 1.95× display fudge multiplier with the wallet's actual cost formula: gasLimit × (baseFee + priorityFee) × ethPrice. ETH-path tx now populates `maxPriorityFeePerGas: 0n` since FastSwap inclusion is paid by the bidder via mev-commit, so the user pays no L1 tip. Confirmation modal `gasCostUsd` uses the un-buffered eth_estimateGas (matches wallet's "estimated cost" panel, not the max-cost ceiling) plus a 1.20× display padding for the wallet-side safety margin. Net: app's gas display lands within a few cents of the wallet popup instead of being ~2-3× off. --- .../modals/SwapConfirmationModal.tsx | 24 ++++++++++----- src/hooks/use-broadcast-gas-price.tsx | 29 ++++++++++++++++--- src/hooks/use-swap-confirmation.ts | 4 +++ 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/components/modals/SwapConfirmationModal.tsx b/src/components/modals/SwapConfirmationModal.tsx index 180b7f19..63c29f6f 100644 --- a/src/components/modals/SwapConfirmationModal.tsx +++ b/src/components/modals/SwapConfirmationModal.tsx @@ -41,7 +41,7 @@ import { useAccount } from "wagmi" import { mainnet } from "wagmi/chains" import { useTokenPrice } from "@/hooks/use-token-price" import { DEFAULT_ETH_PRICE_USD } from "@/lib/constants" -import { GAS_LIMIT_MULTIPLIER, ETH_PATH_DISPLAY_MULTIPLIER } from "@/hooks/use-broadcast-gas-price" +import { GAS_LIMIT_MULTIPLIER, ETH_PATH_DISPLAY_GAS_PADDING } from "@/hooks/use-broadcast-gas-price" import { useEthPathGasEstimate } from "@/hooks/use-eth-path-gas-estimate" import { ZERO_ADDRESS } from "@/lib/swap-constants" import { useSwapToastStore } from "@/stores/swapToastStore" @@ -337,7 +337,7 @@ function SwapConfirmationModal({ }, }) - const { bufferedPrice: gasPrice } = useBroadcastGasPrice() + const { ethPathDisplayFeePerGas, rawPrice } = useBroadcastGasPrice() const { price: ethPriceFromApi } = useTokenPrice("ETH") const effectiveEthPrice = ethPrice ?? ethPriceFromApi ?? DEFAULT_ETH_PRICE_USD @@ -401,9 +401,13 @@ function SwapConfirmationModal({ if (isWrap || isUnwrap) return wethGasEstimate const base = ethPathGasEstimate ?? gasEstimate if (!base) return null - // ETH path: use display multiplier so estimate aligns with wallet (wallet adds buffers) + // ETH path: pad raw simulation by ~20% to match wallet's "estimated cost" + // panel (wallets add their own safety margin above eth_estimateGas before + // displaying). The 1.4× tx buffer is applied separately at submission in + // use-swap-confirmation; it caps actual gas use but doesn't surface in + // the wallet's cost line. if (ethPathGasEstimate) { - return (base * ETH_PATH_DISPLAY_MULTIPLIER) / 100n + return (base * ETH_PATH_DISPLAY_GAS_PADDING) / 100n } return (base * GAS_LIMIT_MULTIPLIER) / 100n }, [isWrap, isUnwrap, wethGasEstimate, ethPathGasEstimate, gasEstimate]) @@ -524,15 +528,21 @@ function SwapConfirmationModal({ }, [needsPermit2Approval, intentPath, isApprovalInProgress, executeSwap]) const gasCostUsd = useMemo(() => { - if (!activeGasEstimate || !gasPrice) return null + if (!activeGasEstimate) return null + // ETH-path swaps land in the user's wallet — display USD must match the + // wallet popup, so we use the same maxFeePerGas the wallet populates. + // Wrap/unwrap and the permit2 path don't surface in the wallet that way; + // fall back to base fee for the rough on-chain cost. + const feePerGas = ethPathGasEstimate ? ethPathDisplayFeePerGas : rawPrice + if (!feePerGas) return null try { - const totalWei = BigInt(activeGasEstimate) * BigInt(gasPrice) + const totalWei = BigInt(activeGasEstimate) * BigInt(feePerGas) const totalEth = Number(totalWei) / 1e18 return totalEth * effectiveEthPrice } catch { return null } - }, [activeGasEstimate, gasPrice, effectiveEthPrice]) + }, [activeGasEstimate, ethPathGasEstimate, ethPathDisplayFeePerGas, rawPrice, effectiveEthPrice]) // USD value under each token amount (match main swap form, NumberFlow + commas) const fromUsdValue = useMemo(() => { diff --git a/src/hooks/use-broadcast-gas-price.tsx b/src/hooks/use-broadcast-gas-price.tsx index 775174bc..2a4ce44c 100644 --- a/src/hooks/use-broadcast-gas-price.tsx +++ b/src/hooks/use-broadcast-gas-price.tsx @@ -6,8 +6,22 @@ import { formatUnits } from "viem" export const GAS_LIMIT_MULTIPLIER = 100n export const ETH_PATH_GAS_LIMIT_MULTIPLIER = 140n // 40% buffer for tx -/** Display multiplier so our estimate aligns with wallet (wallet adds gas price buffer) */ -export const ETH_PATH_DISPLAY_MULTIPLIER = 195n // ~90% extra for display +/** + * Wallets pad the displayed "estimated cost" by ~20% above the raw simulated + * `gasUsed` as a safety margin (independent of our submitted gasLimit). We + * apply the same padding to our display so the cost line matches the wallet + * popup. Tx submission still uses ETH_PATH_GAS_LIMIT_MULTIPLIER (40%). + */ +export const ETH_PATH_DISPLAY_GAS_PADDING = 120n + +/** + * Priority fee on ETH-input swaps. Zero — FastSwap inclusion is bought by the + * bidder via mev-commit preconfirmation, so the user's L1 tx doesn't need to + * tip a builder for inclusion. We populate `maxPriorityFeePerGas: 0n` on the + * tx in `use-swap-confirmation` so the wallet displays a 0 tip too, keeping + * our cost line and the wallet popup aligned. + */ +const ETH_PATH_PRIORITY_FEE_WEI = 0n const PRIORITY_FEE_WEI = 0n @@ -43,7 +57,14 @@ export function useBroadcastGasPrice() { const effectivePrice = baseFeePerGas != null ? baseFeePerGas + PRIORITY_FEE_WEI : null const rawPrice = effectivePrice const gasPriceGwei = effectivePrice != null ? parseFloat(formatUnits(effectivePrice, 9)) : null - const bufferedPrice = effectivePrice + + // ETH-path display: match the wallet's "estimated cost" panel, which uses + // the EFFECTIVE per-gas price (baseFee + priorityFee) — i.e. what the user + // typically pays. `maxFeePerGas` (baseFee × 2 + tip) only sets the ceiling + // for unusual base-fee spikes; the wallet doesn't put that in the cost + // line, so neither do we. + const ethPathDisplayFeePerGas = + baseFeePerGas != null ? baseFeePerGas + ETH_PATH_PRIORITY_FEE_WEI : null return { gasFees, @@ -51,7 +72,7 @@ export function useBroadcastGasPrice() { rawMaxFeePerGas: effectivePrice, rawLegacyPrice: effectivePrice, rawPrice, - bufferedPrice, + ethPathDisplayFeePerGas, gasPriceGwei, } } diff --git a/src/hooks/use-swap-confirmation.ts b/src/hooks/use-swap-confirmation.ts index 502cd39c..e34ca1c7 100644 --- a/src/hooks/use-swap-confirmation.ts +++ b/src/hooks/use-swap-confirmation.ts @@ -183,11 +183,15 @@ export function useSwapConfirmation({ const bufferedGas = (result.gasEstimate * ETH_PATH_GAS_LIMIT_MULTIPLIER) / 100n + // FastSwap inclusion is paid by the bidder via mev-commit preconfirmation, + // so the user's L1 tx doesn't need a tip. Forcing priority=0 keeps the + // wallet's displayed cost (and the actual charge) aligned with our quote. const txHash = await sendTransactionAsync({ to: result.to as `0x${string}`, data: result.data, value: BigInt(result.value), gas: bufferedGas, + maxPriorityFeePerGas: 0n, }) // Fire after wallet signs — timer should measure submission→preconfirmation, From 677427b153373233b7f1944f87f7e3bcdf44f35c Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Wed, 29 Apr 2026 07:02:26 -0300 Subject: [PATCH 03/13] feat(swap): tighten auto-slippage + correct reset on token swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump AUTO_BUMP_BUFFER_PCT from 0.5 to 1.0 to absorb execution-time routing drift between Barter validation and bidder fill — the previous 0.5% margin was leaving slippage-too-low reverts on small swaps. - Auto slippage = max(autoBase, shortfall + buffer) so even shortfalls below autoBase get the safety margin. - Synchronously reset observedBarterShortfallPct + slippage inside handleSwitch BEFORE the 500ms debounce, so the new pair starts from a clean ratchet instead of inheriting the previous direction's bumped value. - TransactionSettings gear/pill now amber whenever slippage > the auto baseline, regardless of mode — calc-applied custom slippage stays yellow instead of flipping to blue. Auto-bumped popup notice still gates on auto mode only (per spec). - Export computeAutoBumpValue / formatSlippage / finalizeSlippage / sanitizeInput / new computeAutoSlippage for unit testing. - New swapResetCount counter incremented in resetFormAfterSuccess so downstream consumers (miles calc) can clear their session state on preconfirmation. --- src/components/swap/TransactionSettings.tsx | 57 +++++++++++---- src/hooks/use-swap-form.ts | 31 +++++++- src/hooks/use-swap-slippage.ts | 80 +++++++++++++++------ 3 files changed, 129 insertions(+), 39 deletions(-) diff --git a/src/components/swap/TransactionSettings.tsx b/src/components/swap/TransactionSettings.tsx index 6348e042..eba03cba 100644 --- a/src/components/swap/TransactionSettings.tsx +++ b/src/components/swap/TransactionSettings.tsx @@ -26,6 +26,7 @@ interface TransactionSettingsProps { const WARNING_MESSAGE = "Slippage above 5% is unusual. You will earn more miles, but will likely receive less tokens." +const AUTO_BUMP_MESSAGE = "Your slippage has been auto-adjusted to cover gas costs" const TransactionSettingsComponent: React.FC = ({ isSettingsOpen, @@ -42,9 +43,20 @@ const TransactionSettingsComponent: React.FC = ({ autoBumpedForGas, slippageWarning, }) => { - // Show the badge whenever the user has deviated from the base (custom mode) or - // the auto mode has bumped up to cover gas costs. - const showSlippageBadge = mode === "custom" || autoBumpedForGas + // Pill/gear visual: amber whenever slippage sits above the auto-mode + // BASELINE (= max(autoBase, buffer)) — covers auto-bumped, custom-set-high, + // and calc-applied bumps. At or below the baseline (e.g. the 1% default + // for ETH input) the gear has no badge — that's just "auto-default." + // Mirrors AUTO_BUMP_BUFFER_PCT from use-swap-slippage. + const AUTO_BUMP_BUFFER_PCT = 1.0 + const autoBaseline = Math.max(autoBase, AUTO_BUMP_BUFFER_PCT) + const slippagePct = parseFloat(slippage) + const isElevatedSlippage = Number.isFinite(slippagePct) && slippagePct > autoBaseline + const showSlippageBadge = isElevatedSlippage + + // The popup notice copy specifically references the AUTO mode bump and + // should only appear when auto mode actually bumped (per product spec). + const isAutoBumpNoticeOpen = mode === "auto" && autoBumpedForGas const isWarningOpen = slippageWarning !== "none" @@ -65,25 +77,24 @@ const TransactionSettingsComponent: React.FC = ({
+
+
+
+
+ + {AUTO_BUMP_MESSAGE} + +
+
+
+
+
diff --git a/src/hooks/use-swap-form.ts b/src/hooks/use-swap-form.ts index 74b23826..d43597c2 100644 --- a/src/hooks/use-swap-form.ts +++ b/src/hooks/use-swap-form.ts @@ -98,6 +98,11 @@ export function useSwapForm(allTokens: Token[]) { ]) }, [address, chainId, queryClient]) + // Monotonic counter incremented on every successful swap reset. Consumers + // (e.g. the miles calculator) watch this so they can clear their own + // session state when the swap inputs are wiped after a preconfirmation. + const [swapResetCount, setSwapResetCount] = useState(0) + const resetFormAfterSuccess = useCallback(() => { setAmount("") setSwappedQuote(null) @@ -111,6 +116,7 @@ export function useSwapForm(allTokens: Token[]) { delete next[pairKey] return next }) + setSwapResetCount((n) => n + 1) }, [fromToken, toToken]) // Watch for new blocks and refetch connected wallet balances so the UI updates automatically @@ -324,12 +330,25 @@ export function useSwapForm(allTokens: Token[]) { enabled: !isWrapUnwrap && !!displayQuote && hasSufficientBalance, }) - // Feed observed shortfall back into the slippage hook so auto mode can bump to 2% + // Feed observed shortfall back into the slippage hook so auto mode can bump // when the base tier isn't enough to cover Barter's routing cost. + // + // Ratchet: only INCREASES propagate during a session. `barterShortfallPct` + // resets to 0 every time validation re-runs (quote refresh, transient + // network blip, etc.) — without the ratchet, the auto-bumped slippage drops + // back to the base whenever validation is in-flight, and a Swap click in + // that window snapshots the un-bumped value into the confirmation modal. useEffect(() => { - setObservedBarterShortfallPct(barterShortfallPct) + setObservedBarterShortfallPct((prev) => (barterShortfallPct > prev ? barterShortfallPct : prev)) }, [barterShortfallPct]) + // Reset the high-water mark when the swap inputs (amount / pair) change — + // shortfall is amount-and-pair specific, so prior observations no longer + // apply. The next validation result will set a fresh floor. + useEffect(() => { + setObservedBarterShortfallPct(0) + }, [amount, fromToken?.address, toToken?.address]) + // Guard trigger: Barter's routed output exceeds the Uniswap single-hop quote by more than // the configured threshold. Indicates the Uniswap quote is not representative of execution // (e.g. thin direct pool, multihop routing would fill materially better). When triggered, the @@ -453,6 +472,13 @@ export function useSwapForm(allTokens: Token[]) { if (!fromToken || !toToken) return setLastValidRate(exchangeRateContent) + // Reset the slippage state up front so that even rapid switches that + // would be caught by the 500ms debounce below still clear the previous + // pair's auto-bumped slippage. Otherwise the ratchet locks in the prior + // shortfall and the new pair displays an unwarranted 50%. + settings.resetSlippage() + setObservedBarterShortfallPct(0) + const now = Date.now() if (now - lastSwitchTime < 500) return setLastSwitchTime(now) @@ -601,6 +627,7 @@ export function useSwapForm(allTokens: Token[]) { handleSwitch, refreshBalances, resetFormAfterSuccess, + swapResetCount, ...settings, slippage: effectiveSlippage, fromPrice: priceCache[fromToken?.symbol || ""] ?? fromPrice ?? 0, diff --git a/src/hooks/use-swap-slippage.ts b/src/hooks/use-swap-slippage.ts index 2f167ab5..b6020c4c 100644 --- a/src/hooks/use-swap-slippage.ts +++ b/src/hooks/use-swap-slippage.ts @@ -5,19 +5,21 @@ import { useState, useEffect, useMemo, useCallback } from "react" const DEADLINE_MIN_MINUTES = 5 const DEADLINE_MAX_MINUTES = 1440 export const SLIPPAGE_MAX = 50 -const SLIPPAGE_STEP = 0.1 +export const SLIPPAGE_STEP = 0.1 const SLIPPAGE_WARN_THRESHOLD = 5 -const AUTO_BASE_ETH = 0.5 -const AUTO_BASE_PERMIT = 1 +export const AUTO_BASE_ETH = 0.5 +export const AUTO_BASE_PERMIT = 1 /** - * Headroom added above the observed Barter shortfall when auto mode bumps up. - * Small enough to stay in the ballpark of the shortfall, large enough that a - * quote refresh with slightly higher shortfall doesn't immediately re-gate the - * swap with "Amount too small". + * Headroom added above the observed Barter shortfall in auto mode. This is + * also the protocol's per-swap surplus capture (= buffer × uniswapAmountOut), + * so it doubles as the protocol's revenue knob and the safety margin against + * routing drift between Barter validation and bidder fill. 1.0% currently — + * 0.5% wasn't enough to absorb observed execution-time drift on small-shortfall + * swaps and was leaving slippage-too-low reverts. */ -const AUTO_BUMP_BUFFER_PCT = 0.5 +export const AUTO_BUMP_BUFFER_PCT = 1.0 export type SlippageMode = "auto" | "custom" export type SlippageWarning = "none" | "high" @@ -36,8 +38,10 @@ interface UseSwapSlippageOptions { /** * Target slippage when auto mode needs to cover an observed Barter shortfall. * Rounded up to the nearest 0.1% step, buffered, and capped at the UI ceiling. + * + * Exported for testing. */ -function computeAutoBumpValue(shortfallPct: number): number { +export function computeAutoBumpValue(shortfallPct: number): number { const roundedUp = Math.ceil(shortfallPct / SLIPPAGE_STEP) * SLIPPAGE_STEP return Math.min(SLIPPAGE_MAX, roundedUp + AUTO_BUMP_BUFFER_PCT) } @@ -46,25 +50,31 @@ function clampDeadline(minutes: number): number { return Math.max(DEADLINE_MIN_MINUTES, Math.min(DEADLINE_MAX_MINUTES, minutes)) } -/** Format a numeric slippage to step-rounded string (e.g. 0.5, 1, 1.3). */ -function formatSlippage(num: number): string { +/** + * Format a numeric slippage to step-rounded string (e.g. 0.5, 1, 1.3). + * Exported for testing. + */ +export function formatSlippage(num: number): string { return num === Math.floor(num) ? String(num) : num.toFixed(1) } /** * Strip invalid characters and collapse multiple decimal points so the input * feels natural while typing. Does NOT clamp — user can type any value and we - * only finalize on blur. + * only finalize on blur. Exported for testing. */ -function sanitizeInput(val: string): string { +export function sanitizeInput(val: string): string { const cleaned = val.replace(/[^0-9.]/g, "") const dotIdx = cleaned.indexOf(".") if (dotIdx === -1) return cleaned return cleaned.slice(0, dotIdx + 1) + cleaned.slice(dotIdx + 1).replace(/\./g, "") } -/** Snap a typed value to [min, SLIPPAGE_MAX] with 0.1 step rounding. Runs on blur. */ -function finalizeSlippage(val: string, min: number): string { +/** + * Snap a typed value to [min, SLIPPAGE_MAX] with 0.1 step rounding. Runs on blur. + * Exported for testing. + */ +export function finalizeSlippage(val: string, min: number): string { const num = parseFloat(val) if (Number.isNaN(num)) return formatSlippage(min) const rounded = Math.round(num / SLIPPAGE_STEP) * SLIPPAGE_STEP @@ -72,6 +82,22 @@ function finalizeSlippage(val: string, min: number): string { return formatSlippage(clamped) } +/** + * Compute the slippage value auto mode would surface given the observed + * Barter shortfall and the path-dependent auto base. Mirrors the inline + * logic inside `useSwapSlippage` — exported for testing. + */ +export function computeAutoSlippage( + barterShortfallPct: number, + isPermitPath: boolean +): { slippage: string; bumped: boolean } { + const autoBase = isPermitPath ? AUTO_BASE_PERMIT : AUTO_BASE_ETH + const baseline = Math.max(autoBase, AUTO_BUMP_BUFFER_PCT) + const bump = computeAutoBumpValue(barterShortfallPct) + const value = Math.max(autoBase, bump) + return { slippage: formatSlippage(value), bumped: bump > baseline } +} + export function useSwapSlippage(options: UseSwapSlippageOptions = {}) { const { isPermitPath = false, barterShortfallPct = 0 } = options @@ -92,14 +118,22 @@ export function useSwapSlippage(options: UseSwapSlippageOptions = {}) { const autoBase = isPermitPath ? AUTO_BASE_PERMIT : AUTO_BASE_ETH const customMin = autoBase - // Auto mode: when Barter's observed shortfall exceeds the auto base, bump the - // visible slippage to (shortfall + buffer) so the user's tolerance clears the - // amount-too-small gate instead of stranding them at a hardcoded 2% that may - // not be enough. - const autoBumpedForGas = mode === "auto" && barterShortfallPct > autoBase - const autoSlippage = autoBumpedForGas - ? formatSlippage(computeAutoBumpValue(barterShortfallPct)) - : formatSlippage(autoBase) + // Auto mode: slippage is always max(autoBase, observed shortfall + buffer). + // The buffer above shortfall absorbs execution-time drift between when + // Barter validated and when the bidder fills — without it, a swap with + // shortfall just under autoBase (say 0.3% vs 0.5%) submits with autoBase + // and reverts as soon as routing drifts. + // + // Baseline = the auto-mode value when no shortfall is observed. With the + // buffer raised to 1.0, the baseline for ETH input is `buffer` (1.0), not + // autoBase (0.5) — and that 1.0 IS the default the user sees. The + // "auto-bumped" UI badge should only fire when an actual shortfall pushes + // slippage *above* this baseline; otherwise the default state shows a + // misleading "custom" badge next to the gear. + const bumpedFromShortfall = computeAutoBumpValue(barterShortfallPct) + const autoBaseline = Math.max(autoBase, AUTO_BUMP_BUFFER_PCT) + const autoBumpedForGas = mode === "auto" && bumpedFromShortfall > autoBaseline + const autoSlippage = formatSlippage(Math.max(autoBase, bumpedFromShortfall)) // Re-clamp custom value when the floor rises (e.g. user switches from ETH input to ERC20 input). // IMPORTANT: do not depend on customSlippage here — doing so re-runs on every keystroke and From cb9600c5361a50707f7d7dff042a49320d67fa29 Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Wed, 29 Apr 2026 07:02:51 -0300 Subject: [PATCH 04/13] =?UTF-8?q?feat(swap):=20miles=20calculator=20(Enabl?= =?UTF-8?q?e=E2=86=92Calculate=20flow)=20+=20estimator=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calculator UI: - Three-state machine: collapsed badge → "Earn upto N miles" + Enable → "Earn [n] of N miles" + Calculate. Calc only adjusts slippage, never the user's typed sell/buy amount. - Resets on token switch, swap-input change, calc close, and successful preconfirmation (via swapResetCount). - Input hard-capped at maxAchievableMiles so users can't type a target the system can't deliver. Estimator (use-estimated-miles): - New milesToSlippage planner uses the forward calc's last observed effective rate (lastEffectiveSurplusRateRef) so applied slippage produces miles equal to the user's typed target. Math.ceil at 0.01% step + 5e-7 floor epsilon → applied target meets typed value within 0–1 mile. - maxAchievableMiles is now a reactive memo using the same computeSurplusEth function the forward uses, evaluated at the 50% cap — so the calc's max matches what the bar will produce on Apply. - isBarterValidating gate freezes both estimatedMiles and maxAchievableMiles at last-good values during transitions; lastMaxRef mirrors the lastMilesRef pattern for the max display. - DEFAULT_PRIORITY_FEE_WEI initial state so cold load doesn't flash TBD while the FastRPC bid-estimate poll completes. - ETH-path forward gate only requires priorityFee, not baseFee (baseFee is unused on that path). Stale-data sanity gates: - computeSurplusEth returns null when barterPreGasHuman is outside [0.5×, 2×] of the uniswap quote — catches decimals-mismatch from stale-pair quotes during token switches. - useBarterValidation: storedForKey synchronously gates returned values on the current inputKey so a stale frame can't expose previous-pair state. Drops shortfall measurements > 90% as stale-quote artifacts. Net: token switches no longer flash order-of-magnitude wrong miles or get stuck at 50% slippage from a stale ratchet; calculator typed value lands accurately in the bar after Apply. --- src/components/swap/RewardsBadge.tsx | 257 +++++++++++++++------- src/components/swap/SwapForm.tsx | 37 +++- src/components/swap/SwapInterface.tsx | 14 +- src/hooks/use-barter-validation.ts | 43 +++- src/hooks/use-estimated-miles.ts | 293 +++++++++++++++++++++++++- 5 files changed, 539 insertions(+), 105 deletions(-) diff --git a/src/components/swap/RewardsBadge.tsx b/src/components/swap/RewardsBadge.tsx index c438c1d5..44da5088 100644 --- a/src/components/swap/RewardsBadge.tsx +++ b/src/components/swap/RewardsBadge.tsx @@ -2,24 +2,49 @@ import React, { useEffect, useMemo, useRef, useState } from "react" import Image from "next/image" -import { ArrowRight, Calculator, X } from "lucide-react" -import { Token } from "@/types/swap" +import { Calculator, X } from "lucide-react" -interface RewardsBadgeProps { - toToken: Token | null - milesToAmountOut: (targetMiles: number) => number | null - onApply: (amountOut: string) => void +interface MilesPlan { + slippage: string + requiresChange: boolean } -const formatAmount = (n: number): string => { - if (n >= 1) return n.toFixed(Math.min(6, Math.max(2, 6 - Math.floor(Math.log10(n))))) - if (n >= 0.0001) return n.toFixed(6) - return n.toPrecision(3) +interface RewardsBadgeProps { + milesToSlippage: (targetMiles: number) => MilesPlan | null + /** Upper bound (reactive) on miles earnable at the user's CURRENT swap + * size with max slippage (50%). Bounded by what they've already typed; + * calc never changes the swap amount. */ + maxAchievableMiles: number | null + /** Identity key for the user's current swap pair (`from-to`). When this + * changes — e.g. on switch-tokens — the calc resets back to the + * pre-Enable view. */ + swapInputsKey: string + /** Monotonic counter incremented on every successful swap reset (after + * preconfirmation). When this changes the calc collapses entirely so a + * fresh swap starts with a clean badge. */ + swapResetCount: number + onApply: (args: { slippage: string }) => void + /** Called when the calculator is collapsed (X click). Used to reset + * the swap's slippage back to auto since calc-applied slippage is no + * longer in scope. */ + onClose?: () => void } -const RewardsBadgeComponent = ({ toToken, milesToAmountOut, onApply }: RewardsBadgeProps) => { +const RewardsBadgeComponent = ({ + milesToSlippage, + maxAchievableMiles, + swapInputsKey, + swapResetCount, + onApply, + onClose, +}: RewardsBadgeProps) => { + // Three-way state machine for the calc view: + // isOpen=false → collapsed badge ("Calculate Miles") + // isOpen=true, !isEnabled → "Earn upto {max} miles" + [Enable] + // isOpen=true, isEnabled → "Earn [input] of {max} miles" + [Calculate] const [isOpen, setIsOpen] = useState(false) - const [target, setTarget] = useState("1") + const [isEnabled, setIsEnabled] = useState(false) + const [target, setTarget] = useState("") const inputRef = useRef(null) const parsed = useMemo(() => { @@ -27,44 +52,70 @@ const RewardsBadgeComponent = ({ toToken, milesToAmountOut, onApply }: RewardsBa return Number.isFinite(n) && n > 0 ? n : null }, [target]) - const requiredAmountOut = useMemo(() => { + const plan = useMemo(() => { if (parsed == null) return null - return milesToAmountOut(parsed) - }, [parsed, milesToAmountOut]) + return milesToSlippage(parsed) + }, [parsed, milesToSlippage]) - // Skip the first effect run after open so we don't overwrite an existing - // buy amount until the user actually edits the calculator. - const lastAppliedRef = useRef(null) - const didMountRef = useRef(false) - useEffect(() => { - if (!isOpen) return - if (!didMountRef.current) { - didMountRef.current = true - return - } - const next = parsed != null && requiredAmountOut != null ? formatAmount(requiredAmountOut) : "" - if (next !== lastAppliedRef.current) { - lastAppliedRef.current = next - onApply(next) - } - }, [isOpen, parsed, requiredAmountOut, onApply]) + const maxMiles = maxAchievableMiles + + const handleEnable = () => { + setIsEnabled(true) + setTarget("") + } + // Explicit apply: only mutate slippage when the user hits Calculate or + // Enter. We never touch the user's typed sell/buy amount — the calculator + // is purely a slippage nudge. + const handleCalculate = () => { + if (parsed == null || plan == null) return + if (!plan.requiresChange) return + onApply({ slippage: plan.slippage }) + } + + // Reset to the pre-Enable view whenever the calc closes — next open shows + // "Earn upto {max}" again, even after a successful Calculate. useEffect(() => { - if (isOpen) { - const t = setTimeout(() => { - const el = inputRef.current - if (!el) return - el.focus() - const end = el.value.length - el.setSelectionRange(end, end) - }, 320) - return () => clearTimeout(t) + if (!isOpen) { + setIsEnabled(false) + setTarget("") } - didMountRef.current = false - lastAppliedRef.current = null - setTarget("1") }, [isOpen]) + // Reset when the user changes/swaps tokens. The previous max/target is + // stale for the new pair; user re-engages from the Enable view. + useEffect(() => { + setIsEnabled(false) + setTarget("") + }, [swapInputsKey]) + + // Full reset when the swap completes (preconfirmation fires + // `resetFormAfterSuccess`). The calc collapses to the badge; reopening + // starts fresh. Skip the initial mount so opening the page doesn't auto- + // collapse from "0 → 0". + const initialResetRef = useRef(swapResetCount) + useEffect(() => { + if (swapResetCount === initialResetRef.current) return + initialResetRef.current = swapResetCount + setIsOpen(false) + setIsEnabled(false) + setTarget("") + }, [swapResetCount]) + + // Focus the input the moment we enter the active (Enable-clicked) view so + // the user can type immediately. + useEffect(() => { + if (!isOpen || !isEnabled) return + const t = setTimeout(() => { + const el = inputRef.current + if (!el) return + el.focus() + }, 60) + return () => clearTimeout(t) + }, [isOpen, isEnabled]) + + const canCalculate = parsed != null && plan != null && plan.requiresChange + return (
- - -
- setTarget(e.target.value.replace(/[^0-9.,]/g, ""))} - placeholder="0" - aria-label="Target miles" - tabIndex={isOpen ? 0 : -1} - className="w-full min-w-0 bg-transparent text-right text-base font-bold text-foreground outline-none tabular-nums placeholder:text-foreground/30 sm:text-lg" - /> - - miles - -
+ - +
+ {isEnabled ? ( + <> + + Earn + + { + const cleaned = e.target.value.replace(/[^0-9.,]/g, "") + if (cleaned === "") { + setTarget("") + return + } + // Hard cap at maxMiles — user can't type a target above + // what's achievable. Clamp on every keystroke so they + // see the cap applied in real time. + const num = parseFloat(cleaned.replace(/,/g, "")) + if ( + Number.isFinite(num) && + maxMiles != null && + maxMiles > 0 && + num > maxMiles + ) { + setTarget(String(maxMiles)) + return + } + setTarget(cleaned) + }} + onBlur={() => { + if (canCalculate) handleCalculate() + }} + onKeyDown={(e) => { + if (e.key === "Enter" && canCalculate) { + e.preventDefault() + handleCalculate() + } + }} + placeholder="0" + aria-label="Target miles" + tabIndex={isOpen ? 0 : -1} + className="w-20 min-w-0 bg-transparent text-center text-lg font-bold text-foreground outline-none tabular-nums placeholder:text-foreground/30 sm:text-xl" + /> + + {maxMiles != null && maxMiles > 0 + ? `of ${maxMiles.toLocaleString()} miles` + : "miles"} + + + ) : ( + + {maxMiles != null && maxMiles > 0 + ? `Earn up to ${maxMiles.toLocaleString()} miles` + : "Earn miles on this swap"} + + )} +
-
- - {requiredAmountOut != null ? formatAmount(requiredAmountOut) : "—"} - - - {toToken?.symbol ?? "buy"} - -
+ Apply + + ) : ( + + )}
diff --git a/src/hooks/__tests__/miles-math.test.ts b/src/hooks/__tests__/miles-math.test.ts index f0ed0a8a..971408bc 100644 --- a/src/hooks/__tests__/miles-math.test.ts +++ b/src/hooks/__tests__/miles-math.test.ts @@ -303,6 +303,31 @@ describe("maxAchievableMiles is consistent with the forward formula", () => { ) expect(max).toBe(0) }) + + it("returns 0 for a small permit-path swap (~$2 trade replicating real screenshot)", () => { + // Replicates the case where a tiny ERC20→USDC swap can't earn miles + // even at 50% slippage because the 2.5× sweep multiplier on bid+gas + // costs exceeds 0.5×outputInEth. Surfaces the "Swap too small to earn + // miles at current gas" message in the calc. + // + // ~$2 trade @ $3000 ETH → outputInEth ≈ 0.000667 ETH. + // Permit path costs: bidCost=2.7e-5, gasCost=2.7e-4, sweep 2.5x + // → totals 0.74e-3 ETH, dwarfing the 0.5×0.000667 = 3.3e-4 ETH ceiling. + const usdcOut = 1.95 // $1.95 + const outputInEth = (usdcOut * 1) / 3000 + const max = maxMilesAt50( + usdcOut, + USDC_DECIMALS, + false, + 1, // toTokenPrice (USDC = $1) + 3000, // ethPrice + // Barter close to uniswap (passes sanity gate). + usdc(usdcOut * 0.99), + outputInEth, + PERMIT_COSTS + ) + expect(max).toBe(0) + }) }) // ────────────────────────────────────────────────────────────────────────── From 674f389355f3f402fa8c439f85c95791958238b8 Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Wed, 29 Apr 2026 14:21:56 -0300 Subject: [PATCH 08/13] feat(swap+dashboard): surface miles cost on buy card, refine dashboard estimate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BuyCard: when the miles calc applies slippage, header reads "Buy · miles applied" and the USD price line shows "≈ $ (−)" with a tooltip explaining the cost. - SwapForm: tracks miles-applied state, clears on slippage drift, token switch, or successful swap so the buy card never lies. - Dashboard MilesCell: re-runs the forward miles calc against on-chain surplus/gas instead of preferring the swap-time stash. Sanity-gates realized vs. stash to suppress mid-write flicker; tooltip explains why the dashboard number may differ from what the user saw at swap time. - RewardsBadge: copy tweaks (Estimate Miles label, simpler small-swap msg). --- src/components/dashboard/user-swaps-parts.tsx | 143 ++++++++++++++---- src/components/swap/BuyCard.tsx | 89 ++++++++++- src/components/swap/RewardsBadge.tsx | 10 +- src/components/swap/SwapForm.tsx | 36 +++++ src/components/swap/SwapInterface.tsx | 12 ++ 5 files changed, 244 insertions(+), 46 deletions(-) diff --git a/src/components/dashboard/user-swaps-parts.tsx b/src/components/dashboard/user-swaps-parts.tsx index a298996c..b8352857 100644 --- a/src/components/dashboard/user-swaps-parts.tsx +++ b/src/components/dashboard/user-swaps-parts.tsx @@ -143,35 +143,52 @@ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" */ const ESTIMATED_BID_COST_ETH = 0.00004 +/** + * Discriminates which input fed the displayed estimate so the UI can + * tell the user *why* the dashboard number may not match what they saw at + * swap time. `realized` means we recomputed from on-chain settlement data; + * `prior` means a population-average fallback; `null` for both means the + * caller should fall back to the swap-time stash or "TBD". + */ +type EstimateSource = "realized" | "prior" | null + +type MilesEstimate = { + miles: number | null + source: EstimateSource +} + /** * Miles estimate for a pending row. * - * Preferred path: the indexer has already written realized `surplus` and - * `gas_cost` to the row (it populates these the moment the tx is seen), so we - * can compute the miles the finalizer will eventually award — not a population - * prior, actual per-tx math. Only `bid_cost` is NULL until finalize, and we - * proxy it with the post-fix p75 constant above. + * Preferred path (`realized`): the indexer writes `surplus` and `gas_cost` + * the moment the tx is seen, so we recompute miles using the same forward + * formula the finalizer will run — but with on-chain values instead of the + * pre-trade barter prediction the swap UI used. This is the value we want + * to show on the dashboard; it tracks reality, not the user's expectation. + * Only `bid_cost` is NULL until finalize, and we proxy it with the post-fix + * p75 constant above. * - * Fallback: if `surplus` isn't populated yet (rare race window between tx - * submission and the indexer catching up), or the output is a non-ETH token - * we can't convert here without a price oracle, fall back to the old - * `surplusRate × amountOut × 0.9 × 100k` population-prior estimate. + * Fallback (`prior`): if `surplus` isn't populated yet (rare race window + * between tx submission and the indexer catching up), or the output is a + * non-ETH token we can't convert here without a price oracle, return the + * old `surplusRate × amountOut × 0.9 × 100k` population-prior estimate. * - * Returns null when even the fallback can't produce something meaningful; - * the caller renders "TBD" in that case. + * Returns `{miles: null, source: null}` when neither path can produce + * anything; the caller then shows the swap-time stash if available, else + * "TBD". */ -function estimateMiles(row: UserSwapRow, surplusRate: number): number | null { - if (!row.amountOut) return null +function estimateMiles(row: UserSwapRow, surplusRate: number): MilesEstimate { + if (!row.amountOut) return { miles: null, source: null } // Dashboard only handles ETH-output rows in the realized path because // surplus is in output-token units and we'd need a token price to convert - // it to ETH for the math below. ERC20-output pending rows stay as TBD. + // it to ETH for the math below. const outSymbol = row.tokenOut.symbol.toUpperCase() const isEthOut = outSymbol === "ETH" || outSymbol === "WETH" - if (!isEthOut) return null - // Preferred: realized on-chain values. - if (row.surplus != null && row.gasCost != null) { + // Preferred: realized on-chain values. Same formula the finalizer uses, + // matching the swap-time forward calc up to the bid_cost proxy. + if (isEthOut && row.surplus != null && row.gasCost != null) { const surplusNum = Number(row.surplus) const gasNum = Number(row.gasCost) if (Number.isFinite(surplusNum) && surplusNum > 0 && Number.isFinite(gasNum) && gasNum >= 0) { @@ -185,30 +202,37 @@ function estimateMiles(row: UserSwapRow, surplusRate: number): number | null { const gasCostEth = isEthInput ? 0 : gasNum / 1e18 const netMev = surplusEth - ESTIMATED_BID_COST_ETH - gasCostEth - if (netMev <= 0) return 0 + if (netMev <= 0) return { miles: 0, source: "realized" } const userMev = netMev * USER_MEV_SHARE - return Math.floor(userMev * MILES_PER_ETH) + return { miles: Math.floor(userMev * MILES_PER_ETH), source: "realized" } } } // Fallback: population prior × displayed output. Same as the pre-change // formula — used only when realized surplus/gas aren't available yet. const parsed = parseFloat(row.amountOut) - if (!parsed || parsed <= 0) return null + if (!parsed || parsed <= 0) return { miles: null, source: null } const mevPot = surplusRate * parsed const userMev = mevPot * USER_MEV_SHARE const miles = Math.floor(userMev * MILES_PER_ETH) - return miles > 0 ? miles : null + return miles > 0 ? { miles, source: "prior" } : { miles: null, source: null } } /** * Miles column renderer. Shows estimated miles (with ~ prefix) while * pending, and the real finalized value once processed. * - * Estimate priority: - * 1. Stashed estimate from the swap UI (via sessionStorage, survives navigation) - * 2. Local calculation from output amount (ETH/WETH only) - * 3. "TBD" if neither is available + * Estimate priority for pending rows: + * 1. Re-run the forward calc against on-chain data (`surplus` + `gas_cost`). + * This is the most accurate signal we have before the finalizer runs and + * may differ from the swap-time number — when it does, we surface a + * tooltip so the user understands why. + * 2. Stashed estimate from the swap UI (via sessionStorage, survives + * navigation). Used only as a backstop when the indexer hasn't written + * surplus/gas yet, or when the output token is a non-ETH ERC20 we can't + * convert without a price oracle. + * 3. Population-prior calculation from output amount. + * 4. "TBD" if none of the above produce a value. */ export function MilesCell({ row, @@ -218,20 +242,77 @@ export function MilesCell({ surplusRate?: number }) { if (!row.processed) { + const recomputed = estimateMiles(row, surplusRate) const stashed = getEstimatedMilesForHash(row.txHash) - const est = stashed ?? estimateMiles(row, surplusRate) - if (est != null && est > 0) { + + // Prefer the on-chain recompute over the swap-time stash. The recompute + // uses realized surplus/gas, which is closer to what the finalizer will + // award than the pre-trade barter prediction the swap UI displayed. + // + // Sanity gate: if a swap-time stash exists and the realized recompute is + // wildly off (>3× or <1/3×), the indexer is likely mid-write — surplus + // populated, gas_cost still 0, or vice versa. Trust the stash until the + // recompute settles into a plausible range. Without this gate we've seen + // ~12 miles → ~10k miles flicker as gas_cost arrives a beat after surplus. + const realizedMiles = recomputed.source === "realized" ? recomputed.miles : null + const realizedLooksSane = + realizedMiles != null && + (stashed == null || + stashed <= 0 || + (realizedMiles > 0 && realizedMiles <= stashed * 3 && realizedMiles >= stashed / 3) || + realizedMiles === 0) + + let miles: number | null = null + let source: EstimateSource | "stashed" = null + if (realizedLooksSane && realizedMiles != null) { + miles = realizedMiles + source = "realized" + } else if (stashed != null && stashed > 0) { + miles = stashed + source = "stashed" + } else if (recomputed.source === "prior" && recomputed.miles != null) { + miles = recomputed.miles + source = "prior" + } else if (realizedMiles != null) { + // No stash to compare against — fall through and trust the recompute. + miles = realizedMiles + source = "realized" + } + + if (miles == null) { return ( - ~{est.toLocaleString()} miles + TBD ) } - return ( - - TBD + + const badge = ( + + ~{miles.toLocaleString()} miles ) + + // Only attach the "why does this differ?" tooltip on the realized path — + // that's the case where the user can see two different numbers (swap UI + // vs dashboard) and wonder which is right. + if (source === "realized") { + const differsFromSwapUi = stashed != null && stashed !== miles + return ( + + + {badge} + + {differsFromSwapUi + ? `Refined estimate using on-chain settlement data. May differ from the ~${stashed!.toLocaleString()} miles shown at swap time. Final miles credited after settlement.` + : "Refined estimate using on-chain settlement data — more accurate than the swap-time prediction. Final miles credited after settlement."} + + + + ) + } + + return badge } if (row.miles == null || row.miles === 0) { return ( diff --git a/src/components/swap/BuyCard.tsx b/src/components/swap/BuyCard.tsx index c6fafb28..27f2c3d1 100644 --- a/src/components/swap/BuyCard.tsx +++ b/src/components/swap/BuyCard.tsx @@ -8,6 +8,7 @@ import { cn } from "@/lib/utils" // Local Components import AmountInput from "./AmountInput" import TokenInfoRow from "./TokenInfoRow" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" // Hooks import { useBalanceFlash } from "@/hooks/use-balance-flash" @@ -51,6 +52,19 @@ interface BuyCardProps { // Input Control buyInputRef: React.RefObject + + // Miles Calc Surface — when the miles calculator was used to apply slippage, + // we change the header label and replace the USD-price line with a min-out + // breakdown so the user can see *what they're paying* for the miles they + // targeted (lower guaranteed receive vs. standard auto slippage). + milesApplied: boolean + /** Slippage-adjusted minimum receive amount (decimal string). */ + minAmountOut: string | null + /** Currently effective slippage percent (e.g. 5.4). */ + slippagePct: number + /** Slippage percent that auto-mode would land at without the miles calc. + * Used to compute the "vs standard" delta the user is paying for miles. */ + standardSlippagePct: number } const BuyCardComponent: React.FC = ({ @@ -71,6 +85,10 @@ const BuyCardComponent: React.FC = ({ setAmount, setIsToTokenSelectorOpen, buyInputRef, + milesApplied, + minAmountOut, + slippagePct, + standardSlippagePct, }) => { /** * 1. LOCAL UI STATE @@ -96,11 +114,40 @@ const BuyCardComponent: React.FC = ({ setAmount(toBalanceValue.toString()) } + // Cost-of-miles math: the delta between the standard min-out (auto baseline) + // and the slippage-adjusted min-out the user is now agreeing to. Numeric + // computation only — no display strings here so the rendering layer stays + // declarative. + const cleanOutput = outputAmount ? outputAmount.replace(/,/g, "") : "" + const expectedNum = parseFloat(cleanOutput) + const cleanMin = minAmountOut ? minAmountOut.replace(/,/g, "") : "" + const minNum = parseFloat(cleanMin) + const showMilesBreakdown = + milesApplied && + Number.isFinite(expectedNum) && + expectedNum > 0 && + Number.isFinite(minNum) && + minNum > 0 && + slippagePct > standardSlippagePct + const standardMin = showMilesBreakdown ? expectedNum * (1 - standardSlippagePct / 100) : null + const deltaVsStandard = showMilesBreakdown && standardMin != null ? standardMin - minNum : null + + // Compact decimal formatting that mirrors the existing buy-amount precision. + const formatTokenNum = (n: number): string => { + if (!Number.isFinite(n)) return "—" + if (n === 0) return "0" + if (n >= 1) return n.toLocaleString(undefined, { maximumFractionDigits: 4 }) + if (n >= 0.0001) return n.toLocaleString(undefined, { maximumFractionDigits: 6 }) + return n.toPrecision(2) + } + return (
{/* Header Section */}
- Buy + + {milesApplied ? "Buy · miles applied" : "Buy"} + {toToken && (
diff --git a/src/components/swap/RewardsBadge.tsx b/src/components/swap/RewardsBadge.tsx index 900f58d0..08ce9cf0 100644 --- a/src/components/swap/RewardsBadge.tsx +++ b/src/components/swap/RewardsBadge.tsx @@ -138,7 +138,7 @@ const RewardsBadgeComponent = ({
- Calculate Miles + Estimate Miles Fast ) : ( - + {maxMiles == null ? "Earn miles on this swap" : maxMiles > 0 ? `Earn up to ${maxMiles.toLocaleString()} miles` - : "Swap too small to earn miles at current gas"} + : "Swap too small to earn miles."} )}
diff --git a/src/components/swap/SwapForm.tsx b/src/components/swap/SwapForm.tsx index 5113483e..910aff28 100644 --- a/src/components/swap/SwapForm.tsx +++ b/src/components/swap/SwapForm.tsx @@ -137,6 +137,13 @@ export function SwapForm() { const formSlippageRef = useRef(form.slippage) formSlippageRef.current = form.slippage + // Tracks the slippage value the miles calc most recently applied. Used to + // flip the buy card into "miles applied" mode (different label + min-out + // display) so the user can see the cost of the miles they targeted. + // Cleared whenever the user moves slippage off the calc value, switches + // tokens, or completes a swap. + const [milesAppliedSlippage, setMilesAppliedSlippage] = useState(null) + const handleApplyMilesCalc = useCallback( ({ slippage }: { slippage: string }) => { // Calculator only adjusts slippage — never changes the user's typed @@ -145,6 +152,7 @@ export function SwapForm() { if (slippage && slippage !== formSlippageRef.current) { updateSlippage(slippage) } + setMilesAppliedSlippage(slippage) }, [updateSlippage] ) @@ -153,8 +161,34 @@ export function SwapForm() { // calc's bumped value was scoped to the calc session. const handleCloseMilesCalc = useCallback(() => { resetSlippage() + setMilesAppliedSlippage(null) }, [resetSlippage]) + // Drop the miles-applied marker the moment slippage drifts away from + // the calc-applied value (manual edit in settings, retry-with-slippage, + // auto-bump kicking back in, etc.). Without this the buy card would lie. + useEffect(() => { + if (milesAppliedSlippage != null && form.slippage !== milesAppliedSlippage) { + setMilesAppliedSlippage(null) + } + }, [form.slippage, milesAppliedSlippage]) + + // Token switch → calc resets, so the marker should too. + useEffect(() => { + setMilesAppliedSlippage(null) + }, [form.fromToken?.address, form.toToken?.address]) + + // Successful swap → marker resets along with the rest of the form. + const lastResetCountRef = useRef(form.swapResetCount) + useEffect(() => { + if (form.swapResetCount !== lastResetCountRef.current) { + lastResetCountRef.current = form.swapResetCount + setMilesAppliedSlippage(null) + } + }, [form.swapResetCount]) + + const milesApplied = milesAppliedSlippage != null + return (
{/* From Token Selector Modal */} diff --git a/src/components/swap/SwapInterface.tsx b/src/components/swap/SwapInterface.tsx index dad7bdc3..de48b61d 100644 --- a/src/components/swap/SwapInterface.tsx +++ b/src/components/swap/SwapInterface.tsx @@ -108,6 +108,14 @@ interface SwapInterfaceProps { swapResetCount: number onApplyMilesCalc: (args: { slippage: string }) => void onCloseMilesCalc: () => void + /** True when the miles calc has set the current slippage (cleared on token switch, + * manual slippage edit, or successful swap). Drives the buy card's "miles applied" + * label and min-out display. */ + milesApplied: boolean + /** Slippage-adjusted minimum output as a decimal string. Surfaced on the buy card + * alongside the expected output when miles are applied so the user can see the + * guaranteed amount and the cost of the miles they targeted. */ + computedMinAmountOut: string | null } export const SwapInterface: React.FC = (props) => { @@ -254,6 +262,10 @@ export const SwapInterface: React.FC = (props) => { setAmount={setAmount} setIsToTokenSelectorOpen={setIsToTokenSelectorOpen} buyInputRef={buyInputRef} + milesApplied={props.milesApplied} + minAmountOut={props.computedMinAmountOut} + slippagePct={parseFloat(slippage) || 0} + standardSlippagePct={Math.max(slippageAutoBase ?? 0, 1)} />
From f9911ed0b5d9588867145d98de8d8230202607ec Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Wed, 29 Apr 2026 17:26:57 -0300 Subject: [PATCH 09/13] fix+feat(swap): auto-slippage stability + miles UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens auto-slippage and the miles estimator against bugs where toggling sell amount via the percentage buttons produced phantom 50% slippage and inflated miles, plus a batch of miles-calc UX upgrades. Stability fixes: - Linear computeAutoBumpValue (no pre-buffer step rounding) so any positive shortfall under the buffer no longer stair-steps auto from baseline 1.0% to 1.1%. Final value still 0.1%-aligned for display. - Sync customSlippage to autoSlippage on custom→auto transition so the next custom-mode entry doesn't restore a stale value. - Lower barter sanity gate from 90% → 50% and stop silently swallowing: settle with sanityGated=true so amountTooSmall fires and the user sees an explicit warning instead of auto silently railing to 50%. - Defer barter validation while uniswap quote is loading. Without this, rapid amount changes fire validation with fresh barter for the new size vs stale uniswap output for the prior size — manufacturing a 40-50% phantom shortfall the only-goes-up ratchet then locks in. - Clear lastMilesRef + lastEffectiveSurplusRateRef when the swap identity changes (typed amount + pair) so prior-amount values don't leak into the new amount during the validation window. Miles calc UX: - Snake-border accent on the collapsed pill via CSS Motion Path. - Apply now opens a confirmation overlay (✓/✗ over the same pill) before mutating slippage, with concise centered copy. - "of N miles" label became an outline button that fills the input with the max value. - Calc fully closes (and resets slippage to auto) on any sell-amount change — manual typing or percentage button. - "isOpen" lifted to SwapForm so external surfaces can open the calc. - ExchangeRate's no-miles slot is now an "Apply Miles" button that opens the calc; tooltip explains "Miles aren't available by default at this swap size — open the calculator to apply manually" with a bottom-right Learn outline button. - BuyCard shows "Buy · miles applied" + "≈ \$ (−)" when slippage was set by the calc; reverts when slippage drifts. - TransactionSettings warning copy names the cause when miles are applied: "Slippage was increased to meet your miles target." Tests: - New small-swap-slippage.test.ts characterizing pipeline + fuzz invariants for auto, ratchet, and the sanity gate at small sizes. - Updated existing slippage test expectations to the linear math. --- .../modals/SwapConfirmationModal.tsx | 8 +- src/components/swap/ExchangeRate.tsx | 64 +- src/components/swap/RewardsBadge.tsx | 139 +++- src/components/swap/SwapForm.tsx | 9 + src/components/swap/SwapInterface.tsx | 11 +- src/components/swap/TransactionSettings.tsx | 13 +- .../__tests__/small-swap-slippage.test.ts | 628 ++++++++++++++++++ src/hooks/__tests__/use-swap-slippage.test.ts | 32 +- src/hooks/use-barter-validation.ts | 98 ++- src/hooks/use-estimated-miles.ts | 23 + src/hooks/use-swap-form.ts | 1 + src/hooks/use-swap-slippage.ts | 33 +- 12 files changed, 987 insertions(+), 72 deletions(-) create mode 100644 src/hooks/__tests__/small-swap-slippage.test.ts diff --git a/src/components/modals/SwapConfirmationModal.tsx b/src/components/modals/SwapConfirmationModal.tsx index 63c29f6f..92a9ad42 100644 --- a/src/components/modals/SwapConfirmationModal.tsx +++ b/src/components/modals/SwapConfirmationModal.tsx @@ -929,7 +929,7 @@ function SwapConfirmationModal({ /> ) : ( - TBD + Too small for miles ) } tooltip={ @@ -937,8 +937,8 @@ function SwapConfirmationModal({ "Estimated Fast Miles earned from MEV redistribution on this swap" ) : ( <> - We are unable to show a miles estimate at this time. You may continue to - earn miles as your swap executes. See{" "} + At this swap size, gas and execution costs exceed the surplus miles are + paid from. Try a larger amount, or see{" "} {autoAdjustedForGas && (
- Your slippage has been auto-adjusted to cover gas costs + Auto-adjusted to cover execution costs
)} diff --git a/src/components/swap/ExchangeRate.tsx b/src/components/swap/ExchangeRate.tsx index d4f0b701..4e1ac6a9 100644 --- a/src/components/swap/ExchangeRate.tsx +++ b/src/components/swap/ExchangeRate.tsx @@ -62,6 +62,14 @@ interface ExchangeRateProps { // Estimated miles from this swap estimatedMiles?: number | null + /** True when Barter validated the swap as too small to clear at the current + * slippage. Drives the no-miles slot to a plain dash since the swap itself + * is in an error state and a "why" tooltip would compete with the error. */ + barterAmountTooSmall?: boolean + /** Opens the miles calculator. Used by the "apply manually" affordance + * shown when miles aren't available at the current swap size by default + * but could be reachable via custom slippage. */ + onOpenCalculator?: () => void } const ExchangeRateComponent: React.FC = ({ @@ -76,6 +84,8 @@ const ExchangeRateComponent: React.FC = ({ isManualInversion, timeLeft, estimatedMiles, + barterAmountTooSmall = false, + onOpenCalculator, }) => { /** * 1. DERIVED LOCAL STATE @@ -175,31 +185,49 @@ const ExchangeRateComponent: React.FC = ({ ~{estimatedMiles.toLocaleString("en-US")} miles + ) : barterAmountTooSmall ? ( + // Swap itself is in an error state — barter rejected the size. + // The action button surfaces that; here a single dash keeps the + // slot occupied without competing for attention. + + — + ) : ( - // Estimator is a conservative lower bound. "0" misleads - // users into thinking no miles are available, so we show - // "TBD" + tooltip pointing to the learn article. Kept in - // the same slot so no height is added to the swap card. + // Estimator returned 0 — gas/bid costs at this swap size + // consume the entire default surplus, so miles aren't + // available at auto slippage. Render the inline label as + // a button: clicking opens the calc so the user can apply + // a higher slippage manually and target some miles. + // Hover surfaces the why + Learn link. - - TBD miles + - We are unable to show a miles estimate at this time. You may continue to - earn miles as your swap executes. See{" "} -
- Learn - {" "} - for more info. +
+ + Miles are not available by default at this swap size — open the + calculator to apply manually. + + +
diff --git a/src/components/swap/RewardsBadge.tsx b/src/components/swap/RewardsBadge.tsx index 08ce9cf0..03ccdc2c 100644 --- a/src/components/swap/RewardsBadge.tsx +++ b/src/components/swap/RewardsBadge.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react" import Image from "next/image" -import { Calculator, X } from "lucide-react" +import { Calculator, Check, X } from "lucide-react" interface MilesPlan { slippage: string @@ -28,6 +28,10 @@ interface RewardsBadgeProps { * the swap's slippage back to auto since calc-applied slippage is no * longer in scope. */ onClose?: () => void + /** Controlled open state. Lifted so external surfaces (e.g. the no-miles + * message under the swap card) can open the calc directly. */ + isOpen: boolean + setIsOpen: (open: boolean) => void } const RewardsBadgeComponent = ({ @@ -37,14 +41,19 @@ const RewardsBadgeComponent = ({ swapResetCount, onApply, onClose, + isOpen, + setIsOpen, }: RewardsBadgeProps) => { // Three-way state machine for the calc view: // isOpen=false → collapsed badge ("Calculate Miles") // isOpen=true, !isEnabled → "Earn upto {max} miles" + [Enable] // isOpen=true, isEnabled → "Earn [input] of {max} miles" + [Calculate] - const [isOpen, setIsOpen] = useState(false) const [isEnabled, setIsEnabled] = useState(false) const [target, setTarget] = useState("") + // Confirmation overlay shown after the user clicks Apply. Lets the user + // see what the calc is about to do (raise slippage, lower min-out) and + // either accept (✓ → run apply) or back out (✗ → return to input view). + const [isConfirming, setIsConfirming] = useState(false) const inputRef = useRef(null) const parsed = useMemo(() => { @@ -79,15 +88,25 @@ const RewardsBadgeComponent = ({ if (!isOpen) { setIsEnabled(false) setTarget("") + setIsConfirming(false) } }, [isOpen]) - // Reset when the user changes/swaps tokens. The previous max/target is - // stale for the new pair; user re-engages from the Enable view. + // Fully close the calc whenever the user changes the swap inputs — the + // pair OR the typed amount. The applied slippage was scoped to the prior + // amount/pair; carrying it across a new amount would silently apply a + // value the user never explicitly chose for that size. Skip initial mount + // so first render doesn't trip an unwanted onClose call. + const initialInputsKeyRef = useRef(swapInputsKey) useEffect(() => { + if (swapInputsKey === initialInputsKeyRef.current) return + initialInputsKeyRef.current = swapInputsKey + setIsOpen(false) setIsEnabled(false) setTarget("") - }, [swapInputsKey]) + setIsConfirming(false) + onClose?.() + }, [swapInputsKey, onClose]) // Full reset when the swap completes (preconfirmation fires // `resetFormAfterSuccess`). The calc collapses to the badge; reopening @@ -100,6 +119,7 @@ const RewardsBadgeComponent = ({ setIsOpen(false) setIsEnabled(false) setTarget("") + setIsConfirming(false) }, [swapResetCount]) // Focus the input the moment we enter the active (Enable-clicked) view so @@ -123,6 +143,37 @@ const RewardsBadgeComponent = ({ isOpen ? "max-w-full" : "max-w-[160px] sm:max-w-[180px]" }`} > + {/* Snake border — a small bright segment travels the actual pill + perimeter using CSS `offset-path: inset(0 round 9999px)`. The + previous SVG attempt failed because ``'s rx/ry auto-clip + independently per axis, which produces oval corners on a wide + pill rather than the true pill shape. CSS Motion Path's `inset()` + with `round 9999px` auto-clips to the smaller dimension, giving + the correct pill geometry, and the trace dimensions follow the + container automatically (no JS needed). */} + {!isOpen && ( + <> + + + + )} + {/* Collapsed and expanded layers stack absolutely so the pill animates its max-width while the contents crossfade. */} + ) : ( + + miles + + )} ) : ( @@ -223,7 +281,9 @@ const RewardsBadgeComponent = ({ {isEnabled ? (
+ + {/* Confirmation layer — shown after Apply click. Lets the user + see what the calc is about to do before slippage gets mutated. + ✓ runs the apply logic, ✗ returns to the input view. + Padding scales up with viewport (the rounded pill ends curve + inward, so the visible text gutter needs to be larger than a + square box would). Copy is responsive: short on mobile so it + fits the single-line pill without truncating, fuller on desktop + where there's room to spell it out. */} +
+ + This will apply more slippage, less output. + Higher slippage, lower output. + +
+ + +
+
) diff --git a/src/components/swap/SwapForm.tsx b/src/components/swap/SwapForm.tsx index 910aff28..e01d81a7 100644 --- a/src/components/swap/SwapForm.tsx +++ b/src/components/swap/SwapForm.tsx @@ -70,6 +70,11 @@ export function SwapForm() { !!form.fromToken && !!form.toToken, isBarterValidating: form.isBarterValidating, + // Synchronous identity of the user's swap inputs. Changes the instant + // the user clicks a percentage button or types a new amount — used by + // the estimator to clear cached miles/surplus-rate refs so prior-amount + // values don't leak into the new amount during the validation window. + swapIdentityKey: `${form.amount}|${form.fromToken?.address ?? ""}|${form.toToken?.address ?? ""}`, }) // Keep estimation behind the feature flag for UI, but always log to console @@ -143,6 +148,8 @@ export function SwapForm() { // Cleared whenever the user moves slippage off the calc value, switches // tokens, or completes a swap. const [milesAppliedSlippage, setMilesAppliedSlippage] = useState(null) + // Lifted so the no-miles message in ExchangeRate can open the calc directly. + const [isCalcOpen, setIsCalcOpen] = useState(false) const handleApplyMilesCalc = useCallback( ({ slippage }: { slippage: string }) => { @@ -290,6 +297,8 @@ export function SwapForm() { onCloseMilesCalc={handleCloseMilesCalc} milesApplied={milesApplied} computedMinAmountOut={form.computedMinAmountOut ?? null} + isCalcOpen={isCalcOpen} + setIsCalcOpen={setIsCalcOpen} /> {/* From Token Selector Modal */} diff --git a/src/components/swap/SwapInterface.tsx b/src/components/swap/SwapInterface.tsx index de48b61d..a810d390 100644 --- a/src/components/swap/SwapInterface.tsx +++ b/src/components/swap/SwapInterface.tsx @@ -116,6 +116,10 @@ interface SwapInterfaceProps { * alongside the expected output when miles are applied so the user can see the * guaranteed amount and the cost of the miles they targeted. */ computedMinAmountOut: string | null + /** Lifted calc-open state. Lets the no-miles message in ExchangeRate open + * the calc as an "apply manually" affordance. */ + isCalcOpen: boolean + setIsCalcOpen: (open: boolean) => void } export const SwapInterface: React.FC = (props) => { @@ -207,6 +211,7 @@ export const SwapInterface: React.FC = (props) => { autoBase={slippageAutoBase} autoBumpedForGas={slippageAutoBumpedForGas} slippageWarning={slippageWarning} + milesApplied={props.milesApplied} /> {/* 2. CORE SWAP CARDS @@ -293,6 +298,8 @@ export const SwapInterface: React.FC = (props) => { isManualInversion={isManualInversion} timeLeft={timeLeft} estimatedMiles={props.estimatedMiles} + barterAmountTooSmall={barterAmountTooSmall} + onOpenCalculator={() => props.setIsCalcOpen(true)} />
@@ -315,10 +322,12 @@ export const SwapInterface: React.FC = (props) => {
) diff --git a/src/components/swap/TransactionSettings.tsx b/src/components/swap/TransactionSettings.tsx index eba03cba..c6ffd5f3 100644 --- a/src/components/swap/TransactionSettings.tsx +++ b/src/components/swap/TransactionSettings.tsx @@ -22,11 +22,17 @@ interface TransactionSettingsProps { autoBase: number autoBumpedForGas: boolean slippageWarning: SlippageWarning + /** True when the current slippage was set by the miles calculator. Switches + * the high-slippage warning copy to name the cause so the user understands + * why slippage is elevated. */ + milesApplied?: boolean } const WARNING_MESSAGE = "Slippage above 5% is unusual. You will earn more miles, but will likely receive less tokens." -const AUTO_BUMP_MESSAGE = "Your slippage has been auto-adjusted to cover gas costs" +const MILES_WARNING_MESSAGE = + "Slippage was increased to meet your miles target. You'll receive fewer tokens to earn more miles." +const AUTO_BUMP_MESSAGE = "Auto-adjusted to cover execution costs." const TransactionSettingsComponent: React.FC = ({ isSettingsOpen, @@ -42,6 +48,7 @@ const TransactionSettingsComponent: React.FC = ({ autoBase, autoBumpedForGas, slippageWarning, + milesApplied = false, }) => { // Pill/gear visual: amber whenever slippage sits above the auto-mode // BASELINE (= max(autoBase, buffer)) — covers auto-bumped, custom-set-high, @@ -116,7 +123,9 @@ const TransactionSettingsComponent: React.FC = ({
- {WARNING_MESSAGE} + + {milesApplied ? MILES_WARNING_MESSAGE : WARNING_MESSAGE} +
diff --git a/src/hooks/__tests__/small-swap-slippage.test.ts b/src/hooks/__tests__/small-swap-slippage.test.ts new file mode 100644 index 00000000..545875ef --- /dev/null +++ b/src/hooks/__tests__/small-swap-slippage.test.ts @@ -0,0 +1,628 @@ +/** + * Characterization + fuzz tests for the auto-slippage behavior at small swap + * sizes. Motivated by the observation that toggling between 0.01 and 0.02 ETH + * (and similar boundary cases) produces a slippage value that "feels random" — + * sometimes ~8.8%, sometimes 50%, sometimes 1%. + * + * These tests do NOT assert that the current behavior is *correct*; they + * document what the pipeline actually does so we have a baseline before + * deciding which knobs to turn. If the documented behavior changes + * intentionally, update these tests in the same commit. + * + * The full pipeline simulated here: + * + * uniswapAmtOut ← uniswap's quoted output for the user's amount + * barterPreGasAmtOut ← barter's pre-gas routed output + * barterPostGasAmtOut ← barter's post-gas output (pre-gas − gas) + * shortfall% ← (uniswapAmtOut − barterPostGasAmtOut) / uniswap × 100 + * + * if |shortfall| > 90 → discard (sanity guard in use-barter-validation) + * else clamped ← max(0, shortfall) + * ratchet ← max(prevRatchet, clamped) [resets on amount/pair change] + * autoSlippage ← computeAutoSlippage(ratchet, isPermit) + * + * The ratchet is the load-bearing piece for the user's "all over the board" + * complaint: it ONLY moves up within a session, so a single high observation + * (or a stale stale-quote artifact) sticks until the user changes amount or + * pair. + */ + +import { describe, it, expect } from "vitest" +import { + AUTO_BASE_ETH, + AUTO_BASE_PERMIT, + AUTO_BUMP_BUFFER_PCT, + SLIPPAGE_MAX, + computeAutoSlippage, +} from "../use-swap-slippage" + +// The barter validator's sanity threshold (lowered from 90 → 50). Stale-quote +// or gas-eats-output observations above this are surfaced via amountTooSmall +// instead of polluting the ratchet. +const SANITY_GATE_PCT = 50 + +// ────────────────────────────────────────────────────────────────────────── +// Pipeline simulation — pure function mirror of the live data flow +// ────────────────────────────────────────────────────────────────────────── + +interface SimulatedAuto { + /** Observed shortfall this tick (0 if discarded by sanity gate). */ + shortfallPct: number + /** Whether the sanity gate dropped the observation. */ + sanityGated: boolean + /** New ratcheted shortfall after this tick. */ + newRatchet: number + /** Auto slippage produced by computeAutoSlippage(ratchet, isPermit). */ + slippagePct: number + /** Whether auto reports itself as bumped above baseline. */ + bumped: boolean +} + +/** + * Mirror of the validation → ratchet → auto-slippage sequence that runs across + * use-barter-validation, use-swap-form, and use-swap-slippage. + */ +function simulateAuto(args: { + uniswapAmtOut: number + barterPreGas: number + gasCost: number + isPermit: boolean + prevRatchet: number +}): SimulatedAuto { + const { uniswapAmtOut, barterPreGas, gasCost, isPermit, prevRatchet } = args + + const barterPostGas = barterPreGas - gasCost + const rawShortfall = + uniswapAmtOut > 0 ? ((uniswapAmtOut - barterPostGas) / uniswapAmtOut) * 100 : 0 + + const sanityGated = Math.abs(rawShortfall) > SANITY_GATE_PCT + const observedShortfall = sanityGated ? 0 : Math.max(0, rawShortfall) + + // The ratchet is unaffected by sanity-gated observations (they're discarded + // before reaching the ratchet effect). prevRatchet carries through unchanged. + const newRatchet = sanityGated ? prevRatchet : Math.max(prevRatchet, observedShortfall) + + const auto = computeAutoSlippage(newRatchet, isPermit) + return { + shortfallPct: observedShortfall, + sanityGated, + newRatchet, + slippagePct: parseFloat(auto.slippage), + bumped: auto.bumped, + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Deterministic PRNG so failures are reproducible +// ────────────────────────────────────────────────────────────────────────── +function mulberry32(seed: number) { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +// Realistic gas cost defaults — match miles-math.test.ts so cross-file numbers +// can be eyeballed against each other. +const PERMIT_GAS_COST_ETH = 2.7e-4 // baseFee 1.5 gwei × ~180k gasUsed +const ETH_PATH_GAS_COST_ETH = 2.7e-5 // priorityFee 0.06 gwei × ~450k + +// ────────────────────────────────────────────────────────────────────────── +// Reproducing the user's report: 0.01 ETH ⇄ 0.02 ETH toggle +// ────────────────────────────────────────────────────────────────────────── +describe("small-swap reproduction: 0.01 ↔ 0.02 ETH toggle", () => { + // We use barter ≈ uniswap (1:1 pre-gas) so the ONLY shortfall driver is gas + // overhead. This isolates the small-swap math from routing-divergence noise. + const PERMIT = true + + it("0.01 ETH permit swap with realistic gas: shortfall is meaningful, auto bumps", () => { + const eth = 0.01 + const result = simulateAuto({ + uniswapAmtOut: eth, + barterPreGas: eth, // barter matches uniswap pre-gas + gasCost: PERMIT_GAS_COST_ETH, + isPermit: PERMIT, + prevRatchet: 0, + }) + // shortfall = gas/output × 100 = 2.7e-4 / 0.01 × 100 = 2.7% + expect(result.shortfallPct).toBeCloseTo(2.7, 1) + // Linear: shortfall + buffer = 2.7 + 1.0 = 3.7% (formatted to 1 decimal). + expect(result.slippagePct).toBeCloseTo(3.7, 9) + expect(result.bumped).toBe(true) + }) + + it("0.02 ETH permit swap halves the shortfall, auto stays close to baseline", () => { + const eth = 0.02 + const result = simulateAuto({ + uniswapAmtOut: eth, + barterPreGas: eth, + gasCost: PERMIT_GAS_COST_ETH, + isPermit: PERMIT, + prevRatchet: 0, + }) + // shortfall = 2.7e-4 / 0.02 × 100 = 1.35% + expect(result.shortfallPct).toBeCloseTo(1.35, 1) + // Linear: 1.35 + 1.0 = 2.35. JS toFixed(1) rounds 2.35 to "2.3" (because + // the float representation is 2.3499999…). The exact display value is + // less important than the magnitude — well below the 5% cap. + expect(result.slippagePct).toBeLessThan(2.5) + expect(result.slippagePct).toBeGreaterThan(2.2) + }) + + it("RATCHET LOCK-IN: starting at 0.01 then toggling to 0.02 keeps the higher slippage", () => { + // First tick: 0.01 raises ratchet to ~2.7%. + const tick1 = simulateAuto({ + uniswapAmtOut: 0.01, + barterPreGas: 0.01, + gasCost: PERMIT_GAS_COST_ETH, + isPermit: PERMIT, + prevRatchet: 0, + }) + // Second tick: 0.02 produces a smaller observed shortfall (~1.35%). + // BUT the ratchet only moves UP within a session — so until amount changes + // reset it, the auto value sticks at the 0.01 high-water mark. + // + // NOTE: this test simulates a hypothetical "bug path" where the ratchet is + // NOT reset between ticks. In production the ratchet IS reset on amount + // change (use-swap-form.ts:348-350). This test exists to characterize + // what would happen if that reset ever regressed. + const tick2WithoutReset = simulateAuto({ + uniswapAmtOut: 0.02, + barterPreGas: 0.02, + gasCost: PERMIT_GAS_COST_ETH, + isPermit: PERMIT, + prevRatchet: tick1.newRatchet, + }) + expect(tick2WithoutReset.slippagePct).toBeCloseTo(tick1.slippagePct, 9) + expect(tick2WithoutReset.newRatchet).toBeCloseTo(tick1.newRatchet, 9) + }) + + it("RATCHET RESET: toggling amount reset → 0.02 produces its own (lower) auto value", () => { + // Production path: amount-change effect resets ratchet to 0. + const tick1 = simulateAuto({ + uniswapAmtOut: 0.01, + barterPreGas: 0.01, + gasCost: PERMIT_GAS_COST_ETH, + isPermit: PERMIT, + prevRatchet: 0, + }) + const tick2WithReset = simulateAuto({ + uniswapAmtOut: 0.02, + barterPreGas: 0.02, + gasCost: PERMIT_GAS_COST_ETH, + isPermit: PERMIT, + prevRatchet: 0, // ← reset + }) + expect(tick2WithReset.slippagePct).toBeLessThan(tick1.slippagePct) + }) +}) + +// ────────────────────────────────────────────────────────────────────────── +// Where could 50% come from? +// ────────────────────────────────────────────────────────────────────────── +describe("auto-slippage ramp behavior (post-fix)", () => { + it("auto ramps to shortfall + buffer, signalling the real cost of execution", () => { + const r = simulateAuto({ + uniswapAmtOut: 1, + barterPreGas: 1, + gasCost: 0.045, // 4.5% gas-eat + isPermit: true, + prevRatchet: 0, + }) + expect(r.sanityGated).toBe(false) + expect(r.shortfallPct).toBeCloseTo(4.5, 1) + // Linear ramp: 4.5 + 1.0 = 5.5%. User sees the real cost. + expect(r.slippagePct).toBeCloseTo(5.5, 9) + }) + + it("auto ramps freely up to SLIPPAGE_MAX when shortfall demands it (no premature cap)", () => { + // Just under the new sanity gate. Shortfall is real and observable. + const r = simulateAuto({ + uniswapAmtOut: 1, + barterPreGas: 1, + gasCost: 0.45, // 45% gas-eat + isPermit: true, + prevRatchet: 0, + }) + expect(r.sanityGated).toBe(false) + // 45 + 1.0 = 46% — auto reports the actual slippage required. + expect(r.slippagePct).toBeCloseTo(46, 9) + }) + + it("auto rails at SLIPPAGE_MAX once shortfall + buffer would exceed it", () => { + // Push shortfall to ~49% (under the 50% sanity gate). + const ratcheted = simulateAuto({ + uniswapAmtOut: 0.001, + barterPreGas: 0.001, + gasCost: 0.001 * 0.495, // 49.5% gas-eat + isPermit: true, + prevRatchet: 0, + }).newRatchet + expect(ratcheted).toBeGreaterThan(48) + + // Next tick — ratchet still active. + const next = simulateAuto({ + uniswapAmtOut: 0.05, + barterPreGas: 0.05, + gasCost: PERMIT_GAS_COST_ETH, + isPermit: true, + prevRatchet: ratcheted, + }) + // 49.5 + 1.0 = 50.5 → clamped to SLIPPAGE_MAX. + expect(next.slippagePct).toBe(SLIPPAGE_MAX) + }) + + it("sanity-gated observations DO NOT raise the ratchet (>50% shortfall is dropped)", () => { + // Pathological case: gas exceeds output → shortfall > 100%. Sanity gate + // discards it; ratchet stays put. (Threshold lowered 90 → 50 in this fix.) + const r = simulateAuto({ + uniswapAmtOut: 0.0001, + barterPreGas: 0.0001, + gasCost: 0.001, // 10× the output + isPermit: true, + prevRatchet: 3, + }) + expect(r.sanityGated).toBe(true) + expect(r.newRatchet).toBe(3) + // Auto reflects the prior ratchet (linear): 3 + 1 = 4% + expect(r.slippagePct).toBeCloseTo(4.0, 9) + }) + + it("sanity gate now fires at 51% (was 91%)", () => { + // Just under the new gate. + const under = simulateAuto({ + uniswapAmtOut: 1, + barterPreGas: 1, + gasCost: 0.49, + isPermit: true, + prevRatchet: 0, + }) + expect(under.sanityGated).toBe(false) + + // Just over the new gate. + const over = simulateAuto({ + uniswapAmtOut: 1, + barterPreGas: 1, + gasCost: 0.51, + isPermit: true, + prevRatchet: 0, + }) + expect(over.sanityGated).toBe(true) + }) +}) + +// ────────────────────────────────────────────────────────────────────────── +// Continuous sweep — characterize the slippage curve as swap size shrinks +// ────────────────────────────────────────────────────────────────────────── +describe("sweep: slippage as a function of swap size (fixed gas, fresh ratchet)", () => { + it("auto slippage decreases monotonically as swap size grows from 0.001 to 1 ETH", () => { + // Fresh ratchet each tick — simulates the production amount-change reset. + const sizes: number[] = [] + for (let eth = 0.001; eth <= 1.0; eth += 0.001) sizes.push(eth) + + let prev = Infinity + let strictDecreaseCount = 0 + for (const eth of sizes) { + const r = simulateAuto({ + uniswapAmtOut: eth, + barterPreGas: eth, + gasCost: PERMIT_GAS_COST_ETH, + isPermit: true, + prevRatchet: 0, + }) + // Slippage is non-increasing in swap size. + expect(r.slippagePct).toBeLessThanOrEqual(prev + 1e-9) + if (r.slippagePct < prev - 1e-9) strictDecreaseCount++ + prev = r.slippagePct + } + // Must have actually moved across at least a handful of step boundaries. + expect(strictDecreaseCount).toBeGreaterThan(20) + }) + + it("auto slippage at very small sizes: gate fires above 50% shortfall, otherwise ramps", () => { + // 5e-4 ETH × default permit gas (2.7e-4 ETH) → 54% shortfall. + // Above the new 50% sanity gate → observation discarded, auto stays at + // baseline and amountTooSmall fires upstream via sanityGated. + const r = simulateAuto({ + uniswapAmtOut: 5e-4, + barterPreGas: 5e-4, + gasCost: PERMIT_GAS_COST_ETH, + isPermit: true, + prevRatchet: 0, + }) + expect(r.sanityGated).toBe(true) + const baseline = Math.max(AUTO_BASE_PERMIT, AUTO_BUMP_BUFFER_PCT) + expect(r.slippagePct).toBeCloseTo(baseline, 9) + }) + + it("just below the gate, auto ramps to the actual shortfall + buffer (transparent cost)", () => { + // 6e-4 ETH × 2.7e-4 gas → 45% shortfall, well under the gate. + const r = simulateAuto({ + uniswapAmtOut: 6e-4, + barterPreGas: 6e-4, + gasCost: PERMIT_GAS_COST_ETH, + isPermit: true, + prevRatchet: 0, + }) + expect(r.sanityGated).toBe(false) + expect(r.shortfallPct).toBeCloseTo(45, 0) + expect(r.slippagePct).toBeCloseTo(46, 9) // shortfall + buffer + }) + + it("FIXED (Problem 1): tiny positive shortfall yields auto at exact baseline (linear, not stair-stepped)", () => { + // 1 ETH × permit gas → shortfall ≈ 0.027%. Linear: 0.027 + 1.0 = 1.027. + // formatSlippage rounds to 1 decimal → "1" → 1.0. Pre-fix: stair-stepped + // to 1.1 because shortfall was step-rounded UP before adding the buffer. + const r = simulateAuto({ + uniswapAmtOut: 1, + barterPreGas: 1, + gasCost: PERMIT_GAS_COST_ETH, + isPermit: true, + prevRatchet: 0, + }) + const baseline = Math.max(AUTO_BASE_PERMIT, AUTO_BUMP_BUFFER_PCT) + expect(r.slippagePct).toBeCloseTo(baseline, 9) + // `bumped` is still false: the underlying numeric (1.027) is above + // baseline by less than display precision. + }) + + it("a perfect-match barter (zero gas) leaves auto at exact baseline", () => { + const r = simulateAuto({ + uniswapAmtOut: 1, + barterPreGas: 1, + gasCost: 0, + isPermit: true, + prevRatchet: 0, + }) + const baseline = Math.max(AUTO_BASE_PERMIT, AUTO_BUMP_BUFFER_PCT) + expect(r.slippagePct).toBeCloseTo(baseline, 9) + expect(r.bumped).toBe(false) + }) + + it("ETH-path swaps see far less small-size pressure (gas ~10× smaller)", () => { + const r = simulateAuto({ + uniswapAmtOut: 0.01, + barterPreGas: 0.01, + gasCost: ETH_PATH_GAS_COST_ETH, + isPermit: false, + prevRatchet: 0, + }) + // 2.7e-5 / 0.01 × 100 = 0.27% shortfall. Linear: 0.27 + 1.0 = 1.27. + // formatSlippage rounds to 1.3 (1 decimal precision). + expect(r.shortfallPct).toBeCloseTo(0.27, 1) + expect(r.slippagePct).toBeCloseTo(1.3, 9) + }) +}) + +// ────────────────────────────────────────────────────────────────────────── +// Discontinuity scan — find each step boundary where slippage jumps +// ────────────────────────────────────────────────────────────────────────── +describe("step-aligned discontinuities", () => { + it("slippage transitions through the expected 0.1% steps as size grows", () => { + const observed = new Set() + for (let eth = 0.001; eth <= 0.05; eth += 0.0005) { + const r = simulateAuto({ + uniswapAmtOut: eth, + barterPreGas: eth, + gasCost: PERMIT_GAS_COST_ETH, + isPermit: true, + prevRatchet: 0, + }) + observed.add(Math.round(r.slippagePct * 10)) + } + // Should observe at least 5 distinct step values across this range. + expect(observed.size).toBeGreaterThanOrEqual(5) + // Every observed value should be an integer multiple of 0.1. + for (const v of observed) { + const slip = v / 10 + const stepUnits = Math.round(slip / 0.1) + expect(Math.abs(slip - stepUnits * 0.1)).toBeLessThan(1e-9) + } + }) + + it("the user's 8.8% observation maps to a real ~7.8% shortfall post-fix (linear)", () => { + // Linear: shortfall + buffer. To produce 8.8% (formatted) the underlying + // shortfall is 7.8%. Pre-fix this was step-rounded so any shortfall in + // (7.7, 7.8] produced 8.8 — same display, but the fixed math is now + // honestly continuous in the underlying. + expect(parseFloat(computeAutoSlippage(7.8, true).slippage)).toBeCloseTo(8.8, 9) + }) +}) + +// ────────────────────────────────────────────────────────────────────────── +// Fuzz: pipeline invariants across random swap sizes and gas costs +// ────────────────────────────────────────────────────────────────────────── +describe("pipeline fuzz invariants", () => { + it("auto slippage is always within [autoBase, SLIPPAGE_MAX] regardless of inputs", () => { + const rng = mulberry32(101) + for (let i = 0; i < 10_000; i++) { + const isPermit = rng() < 0.5 + const eth = 1e-5 + rng() * 5 // 0.00001..5 ETH + const gas = rng() * 0.01 // 0..0.01 ETH gas (deliberately wide) + const ratchet = rng() < 0.2 ? rng() * 49 : 0 // sometimes pre-loaded ratchet + const r = simulateAuto({ + uniswapAmtOut: eth, + barterPreGas: eth * (0.95 + rng() * 0.1), // ±5% routing drift + gasCost: gas, + isPermit, + prevRatchet: ratchet, + }) + const floor = isPermit ? AUTO_BASE_PERMIT : AUTO_BASE_ETH + expect(r.slippagePct).toBeGreaterThanOrEqual(floor - 1e-9) + expect(r.slippagePct).toBeLessThanOrEqual(SLIPPAGE_MAX + 1e-9) + } + }) + + it("with fresh ratchet, slippage is monotonic non-increasing in swap size — IF every observation passes the sanity gate", () => { + const rng = mulberry32(7) + // Constrain gas so the smallest swap in the sweep (0.001 ETH) still has + // shortfall ≤ 50% — i.e. the (now lower) sanity gate doesn't fire. + // gas/0.001 ≤ 50% means gas ≤ 5e-4 ETH. + for (let cfg = 0; cfg < 200; cfg++) { + const isPermit = rng() < 0.5 + const gas = 1e-5 + rng() * 4e-4 // safely under the new sanity gate + const sizes: number[] = [] + for (let s = 0.001; s <= 1.0; s += 0.01) sizes.push(s) + let prev = Infinity + for (const eth of sizes) { + const r = simulateAuto({ + uniswapAmtOut: eth, + barterPreGas: eth, + gasCost: gas, + isPermit, + prevRatchet: 0, + }) + expect(r.sanityGated).toBe(false) + expect(r.slippagePct).toBeLessThanOrEqual(prev + 1e-9) + prev = r.slippagePct + } + } + }) + + it("FIXED (Problem 2): below-gate swaps ramp transparently; above-gate swaps surface 'swap too small'", () => { + // Pre-fix: tiny swaps (gated at 90%) silently railed auto to 50%, no + // explicit signal. Post-fix the gate is at 50% and gated swaps surface + // sanityGated → amountTooSmall, while non-gated swaps see auto ramp + // honestly to whatever the shortfall demands. No silent rail. + const gas = 0.0006 // sanity-gates swaps where gas/output > 50% + + const tiny = simulateAuto({ + uniswapAmtOut: 0.001, + barterPreGas: 0.001, + gasCost: gas, + isPermit: true, + prevRatchet: 0, + }) + expect(tiny.sanityGated).toBe(true) + expect(tiny.slippagePct).toBeCloseTo(Math.max(AUTO_BASE_PERMIT, AUTO_BUMP_BUFFER_PCT), 9) + + const justOver = simulateAuto({ + uniswapAmtOut: 0.0014, // gas/output ≈ 43%, under the gate + barterPreGas: 0.0014, + gasCost: gas, + isPermit: true, + prevRatchet: 0, + }) + expect(justOver.sanityGated).toBe(false) + // Auto ramps honestly: shortfall ≈ 43% + 1% buffer = 44%. The user sees + // exactly what slippage is required to execute — slippageWarning="high" + // fires above 5%, so the cost-of-execution is visible. + expect(justOver.slippagePct).toBeCloseTo(44, 0) + }) + + it("ratcheted slippage NEVER decreases across ticks within a session", () => { + const rng = mulberry32(2026) + for (let session = 0; session < 200; session++) { + let ratchet = 0 + let lastSlippage = 0 + const isPermit = rng() < 0.5 + // 50 ticks per session, random small/large swaps, each *without* an + // amount-change reset (i.e. simulate a single bound session). + for (let tick = 0; tick < 50; tick++) { + const eth = 1e-4 + rng() * 0.5 + const gas = 1e-5 + rng() * 1e-3 + const r = simulateAuto({ + uniswapAmtOut: eth, + barterPreGas: eth, + gasCost: gas, + isPermit, + prevRatchet: ratchet, + }) + expect(r.slippagePct).toBeGreaterThanOrEqual(lastSlippage - 1e-9) + ratchet = r.newRatchet + lastSlippage = r.slippagePct + } + } + }) + + it("sanity gate is triggered exactly when |raw shortfall| > SANITY_GATE_PCT", () => { + const rng = mulberry32(900) + for (let i = 0; i < 5_000; i++) { + const eth = 1e-5 + rng() * 0.1 + // Pick gas to cover the full range — including pathological cases. + const gas = rng() * 0.5 + const r = simulateAuto({ + uniswapAmtOut: eth, + barterPreGas: eth, + gasCost: gas, + isPermit: true, + prevRatchet: 0, + }) + const raw = ((eth - (eth - gas)) / eth) * 100 // = gas/eth × 100 + const shouldGate = Math.abs(raw) > SANITY_GATE_PCT + expect(r.sanityGated).toBe(shouldGate) + } + }) + + it("sanity-gated tick preserves prior ratchet (never resets it)", () => { + const rng = mulberry32(43) + for (let i = 0; i < 1_000; i++) { + const startRatchet = rng() * 30 + const r = simulateAuto({ + uniswapAmtOut: 0.0001, + barterPreGas: 0.0001, + gasCost: 0.01, // huge → sanity-gated + isPermit: true, + prevRatchet: startRatchet, + }) + expect(r.sanityGated).toBe(true) + expect(r.newRatchet).toBeCloseTo(startRatchet, 9) + } + }) +}) + +// ────────────────────────────────────────────────────────────────────────── +// Documentation tests — what the user sees when they toggle 0.01 ↔ 0.011 +// ────────────────────────────────────────────────────────────────────────── +describe("documented user-facing scenarios", () => { + it("0.01 → 0.011 toggle (with production reset) shows the slippage drop the user observed", () => { + // The user types 0.01 (auto bumps to ~3.7%), then changes to 0.011. + // Production resets the ratchet on amount change, so 0.011 produces its + // own auto value: + // shortfall ≈ 2.7e-4 / 0.011 × 100 ≈ 2.45% → ceil(2.45/0.1)*0.1 + 1 = 3.5% + // Slippage drops from 3.7% → 3.5% (a small but real change). + const t1 = simulateAuto({ + uniswapAmtOut: 0.01, + barterPreGas: 0.01, + gasCost: PERMIT_GAS_COST_ETH, + isPermit: true, + prevRatchet: 0, + }) + const t2 = simulateAuto({ + uniswapAmtOut: 0.011, + barterPreGas: 0.011, + gasCost: PERMIT_GAS_COST_ETH, + isPermit: true, + prevRatchet: 0, // production reset + }) + expect(t1.slippagePct).toBeGreaterThan(t2.slippagePct) + }) + + it("a high ratcheted shortfall produces auto = ratchet + buffer, clamped at SLIPPAGE_MAX", () => { + const r = simulateAuto({ + uniswapAmtOut: 0.05, + barterPreGas: 0.05, + gasCost: PERMIT_GAS_COST_ETH, + isPermit: true, + prevRatchet: 49.5, + }) + // 49.5 + 1.0 = 50.5 → clamped to SLIPPAGE_MAX (50). + expect(r.slippagePct).toBe(SLIPPAGE_MAX) + }) + + it("computeAutoSlippage yields shortfall + buffer (linear, formatted at 0.1%)", () => { + expect(parseFloat(computeAutoSlippage(2, true).slippage)).toBeCloseTo(3, 9) + expect(parseFloat(computeAutoSlippage(2.5, true).slippage)).toBeCloseTo(3.5, 9) + expect(parseFloat(computeAutoSlippage(7.8, true).slippage)).toBeCloseTo(8.8, 9) + expect(parseFloat(computeAutoSlippage(40, true).slippage)).toBeCloseTo(41, 9) + }) + + it("auto rails at SLIPPAGE_MAX on extreme shortfall (only after the sanity gate has passed)", () => { + expect(parseFloat(computeAutoSlippage(49.5, true).slippage)).toBe(SLIPPAGE_MAX) + expect(parseFloat(computeAutoSlippage(80, true).slippage)).toBe(SLIPPAGE_MAX) + }) +}) diff --git a/src/hooks/__tests__/use-swap-slippage.test.ts b/src/hooks/__tests__/use-swap-slippage.test.ts index d90774be..e83d7a72 100644 --- a/src/hooks/__tests__/use-swap-slippage.test.ts +++ b/src/hooks/__tests__/use-swap-slippage.test.ts @@ -17,25 +17,27 @@ describe("computeAutoBumpValue", () => { expect(computeAutoBumpValue(0)).toBeCloseTo(AUTO_BUMP_BUFFER_PCT, 9) }) - it("rounds shortfall UP to the 0.1% step before adding the buffer", () => { - // 0.31 → ceil(3.1)/10 = 0.4 + buffer - expect(computeAutoBumpValue(0.31)).toBeCloseTo(0.4 + AUTO_BUMP_BUFFER_PCT, 9) - // exactly on a step → no extra rounding + it("is linear in shortfall (no step rounding before the cap)", () => { + // The pre-cap pipeline used to ceil-round shortfall to 0.1% then add the + // buffer, which produced the artifact where any positive shortfall pushed + // auto one step above baseline. Linear: shortfall + buffer. + expect(computeAutoBumpValue(0.31)).toBeCloseTo(0.31 + AUTO_BUMP_BUFFER_PCT, 9) expect(computeAutoBumpValue(0.5)).toBeCloseTo(0.5 + AUTO_BUMP_BUFFER_PCT, 9) + expect(computeAutoBumpValue(0.001)).toBeCloseTo(0.001 + AUTO_BUMP_BUFFER_PCT, 9) }) - it("never returns above SLIPPAGE_MAX", () => { - expect(computeAutoBumpValue(45)).toBeLessThanOrEqual(SLIPPAGE_MAX) - expect(computeAutoBumpValue(50)).toBeLessThanOrEqual(SLIPPAGE_MAX) - expect(computeAutoBumpValue(75)).toBeLessThanOrEqual(SLIPPAGE_MAX) - expect(computeAutoBumpValue(1_000)).toBeLessThanOrEqual(SLIPPAGE_MAX) + it("clamps at SLIPPAGE_MAX so auto can ramp all the way up if shortfall demands", () => { + expect(computeAutoBumpValue(45)).toBeCloseTo(45 + AUTO_BUMP_BUFFER_PCT, 9) + expect(computeAutoBumpValue(50)).toBe(SLIPPAGE_MAX) + expect(computeAutoBumpValue(75)).toBe(SLIPPAGE_MAX) + expect(computeAutoBumpValue(1_000)).toBe(SLIPPAGE_MAX) }) - it("is monotonic in shortfall (within the cap)", () => { + it("is monotonic non-decreasing in shortfall", () => { let prev = -Infinity - for (let s = 0; s < SLIPPAGE_MAX - AUTO_BUMP_BUFFER_PCT; s += 0.05) { + for (let s = 0; s < SLIPPAGE_MAX; s += 0.1) { const v = computeAutoBumpValue(s) - expect(v).toBeGreaterThanOrEqual(prev) + expect(v).toBeGreaterThanOrEqual(prev - 1e-9) prev = v } }) @@ -151,16 +153,16 @@ describe("auto-slippage fuzz invariants", () => { } }) - it("auto-slippage covers the shortfall when feasible (slippage ≥ shortfall)", () => { + it("auto-slippage covers the shortfall when feasible (slippage ≥ shortfall, up to the SLIPPAGE_MAX rail)", () => { const rng = mulberry32(7) for (let i = 0; i < 10_000; i++) { - // Stay within range where the cap doesn't swallow the buffer. + // Stay within range where adding the buffer doesn't run into SLIPPAGE_MAX. const shortfall = rng() * (SLIPPAGE_MAX - AUTO_BUMP_BUFFER_PCT - 0.5) const isPermit = rng() < 0.5 const out = computeAutoSlippage(shortfall, isPermit) const v = parseFloat(out.slippage) // Auto must always sit at or above the observed shortfall, otherwise - // the swap would revert as amount-too-small. + // the swap reverts as amount-too-small. expect(v).toBeGreaterThanOrEqual(shortfall - 1e-9) } }) diff --git a/src/hooks/use-barter-validation.ts b/src/hooks/use-barter-validation.ts index 962e129f..4da171ba 100644 --- a/src/hooks/use-barter-validation.ts +++ b/src/hooks/use-barter-validation.ts @@ -25,6 +25,21 @@ const RETRY_DELAY_MS = 800 */ const UNAVAILABLE_ERROR_THRESHOLD = 2 +/** + * Maximum |shortfall%| we'll accept as a real barter routing observation. + * Anything above this is treated as either: + * - a stale-quote artifact (e.g. mid-token-switch where amountOut is still + * the previous pair's bigint while barterOut is the new pair's), or + * - a swap so small that gas eats most of the output and the swap can't be + * auto-covered by widening slippage in any meaningful way. + * + * Lowered from 90 → 50: real barter routing rarely deviates more than ~30% + * from uniswap, and the lower threshold catches the gas-eats-output regime + * earlier so the user sees an explicit "swap too small" warning instead of + * the previously-silent auto-bump-to-50%. + */ +const SANITY_GATE_PCT = 50 + interface UseBarterValidationParams { fromToken: Token | undefined toToken: Token | undefined @@ -41,6 +56,15 @@ interface UseBarterValidationParams { */ maxSlippagePct: number enabled: boolean + /** True while the uniswap quote is in flight. When set, the validator + * defers its API call: with `sellAmount` updating synchronously but + * `amountOut` only updating after the quote refetch, firing the + * validator before the quote settles produces a stale comparison + * (fresh barter for the new size vs uniswap output for the prior size). + * That stale comparison can manufacture a 40-50% shortfall that the + * ratchet then locks in — visible as auto rails to 50% after rapid + * amount toggles. */ + isQuoteLoading?: boolean } interface UseBarterValidationReturn { @@ -93,6 +117,7 @@ export function useBarterValidation({ quoteGeneration, maxSlippagePct, enabled, + isQuoteLoading = false, }: UseBarterValidationParams): UseBarterValidationReturn { const [shortfallPct, setShortfallPct] = useState(0) const [settled, setSettled] = useState(true) @@ -101,6 +126,14 @@ export function useBarterValidation({ undefined ) const [barterUnavailable, setBarterUnavailable] = useState(false) + /** + * True when the most recent barter response produced an out-of-band shortfall + * (|shortfall| > SANITY_GATE_PCT) that we don't trust as a real routing + * signal. Used to surface "swap too small" via amountTooSmall instead of + * silently swallowing the observation, which previously left auto-slippage at + * baseline and the swap button enabled despite an un-coverable routing cost. + */ + const [sanityGated, setSanityGated] = useState(false) // The inputKey the *stored* barter values were validated against. When the // inputKey changes (e.g. token switch, new amount) the values held in state // are immediately stale — but the cleanup `useEffect` runs after render, so @@ -121,6 +154,7 @@ export function useBarterValidation({ // Reset when disabled or missing inputs if (!enabled || !fromToken || !toToken || !amountOut || amountOut === 0n) { setShortfallPct(0) + setSanityGated(false) setBarterAmountOut(undefined) setBarterPreGasOutputAmount(undefined) setBarterUnavailable(false) @@ -130,9 +164,22 @@ export function useBarterValidation({ return } + // Defer while uniswap quote is in flight. With sellAmount changing + // synchronously and amountOut only updating after the quote refetch, + // running the comparison now would feed fresh barter for the new size + // against stale uniswap output for the prior size — a 40-50% phantom + // shortfall that the ratchet locks in. Stay marked unsettled so + // downstream gates know we're not ready. + if (isQuoteLoading) { + setSettled(false) + requestIdRef.current++ + return + } + const sellClean = sellAmount?.replace(/,/g, "").trim() if (!sellClean || parseFloat(sellClean) <= 0) { setShortfallPct(0) + setSanityGated(false) setBarterAmountOut(undefined) setBarterPreGasOutputAmount(undefined) setBarterUnavailable(false) @@ -146,6 +193,7 @@ export function useBarterValidation({ lastSettledKeyRef.current = inputKey setSettled(false) setShortfallPct(0) + setSanityGated(false) setBarterAmountOut(undefined) setBarterPreGasOutputAmount(undefined) // Do NOT reset barterUnavailable here — if we're in an outage, leaving it true @@ -174,18 +222,34 @@ export function useBarterValidation({ const shortfallRaw = amountOut > 0n ? Number(((amountOut - barterOut) * 10000n) / amountOut) / 100 : 0 - // Sanity guard: real Barter routing rarely deviates more than ~30% - // from uniswap. A shortfall > 90% is almost always a decimals - // mismatch — `amountOut` is the previous pair's bigint (e.g. ETH - // wei) while `barterOut` is the new pair's (e.g. USDC raw), - // because `displayQuote` was using a stale quote during the - // switch transition. Drop the result; the next validation with - // a fresh in-pair quote will set the right value. - if (Math.abs(shortfallRaw) > 90) return + // Sanity guard. Two failure modes share this branch: + // 1. Stale-quote artifact mid-token-switch — `amountOut` is the + // previous pair's bigint (e.g. ETH wei) while `barterOut` is the + // new pair's (e.g. USDC raw). The shortfall reads astronomical. + // 2. Real-but-uncoverable: gas eats so much of output that no + // reasonable slippage can absorb it. The swap genuinely can't go + // through, but we want the user to know — not silently push auto + // to 50% and pretend it'll succeed. + // + // Both surface via `sanityGated → amountTooSmall = true` so the existing + // "swap too small" UI path picks them up. We do NOT update barter + // amounts (case 1's data is garbage) but we DO settle so the swap + // button reflects the new gated state immediately. + if (Math.abs(shortfallRaw) > SANITY_GATE_PCT) { + setBarterAmountOut(undefined) + setBarterPreGasOutputAmount(undefined) + setShortfallPct(0) + setSanityGated(true) + setBarterUnavailable(false) + setSettled(true) + setStoredForKey(inputKey) + return + } setBarterAmountOut(barterOut) setBarterPreGasOutputAmount(barterPreGas) setShortfallPct(Math.max(0, shortfallRaw)) + setSanityGated(false) setBarterUnavailable(false) setSettled(true) setStoredForKey(inputKey) @@ -236,13 +300,27 @@ export function useBarterValidation({ clearTimeout(debounceTimer) if (retryTimer) clearTimeout(retryTimer) } - }, [fromToken, toToken, amountOut, sellAmount, quoteGeneration, enabled, inputKey]) + }, [ + fromToken, + toToken, + amountOut, + sellAmount, + quoteGeneration, + enabled, + inputKey, + isQuoteLoading, + ]) // Derive amountTooSmall from cached shortfall + live slippage tolerance. // This lets the gate re-evaluate instantly when the user bumps their slippage // (no extra Barter API call, no "calculating" flicker) while still accurately // reflecting whether the current tolerance covers the measured shortfall. - const amountTooSmall = settled && shortfallPct > 0 && shortfallPct > maxSlippagePct + // + // Also fires when the most recent observation hit the sanity gate — that's + // the "uncoverable shortfall / stale data" branch above. Both shapes mean + // the user's current swap can't be filled at the current tolerance. + const amountTooSmall = + settled && (sanityGated || (shortfallPct > 0 && shortfallPct > maxSlippagePct)) // Synchronously gate every returned value on whether it was validated for // the *current* inputKey. On a token switch, inputKey changes immediately diff --git a/src/hooks/use-estimated-miles.ts b/src/hooks/use-estimated-miles.ts index 915ab52a..5d48c534 100644 --- a/src/hooks/use-estimated-miles.ts +++ b/src/hooks/use-estimated-miles.ts @@ -129,6 +129,12 @@ interface UseEstimatedMilesParams { * values until the new pair's data is ready, so they don't briefly * compute on partially-settled props (e.g. right after a token switch). */ isBarterValidating: boolean + /** Identity of the user's swap inputs (typed amount + pair). Changes + * synchronously when the user clicks/types. Used to clear the estimator's + * cached miles/effective-rate refs so prior-amount values don't leak + * into the new amount during the validation window — critical for the + * percentage-button toggle case where `amountOut` lags the click. */ + swapIdentityKey: string } export interface MilesPlan { @@ -177,6 +183,7 @@ export function useEstimatedMiles({ isPermitPath, enabled, isBarterValidating, + swapIdentityKey, }: UseEstimatedMilesParams): UseEstimatedMilesReturn { const [priorityFee, setPriorityFee] = useState(DEFAULT_PRIORITY_FEE_WEI) const [avgGasLimit, setAvgGasLimit] = useState(DEFAULT_AVG_GAS_LIMIT) @@ -273,6 +280,22 @@ export function useEstimatedMiles({ // particularly when barter routing differs from `slippage/100`. const lastEffectiveSurplusRateRef = useRef(null) + // Clear both refs the moment the swap identity changes. Driven by the + // user's typed amount + pair (passed in via `swapIdentityKey`) rather than + // `amountOut`, because `amountOut` lags the click — it only updates when + // the new quote refetches. With the lagging signal, toggling 50% → 25% + // briefly leaves these refs holding the prior amount's values, which the + // inverse calc then uses to project a wildly inflated slippage (visible + // as "50% slippage + high miles"). The synchronous identity key clears + // them on the click itself, so the validation gate falls through to a + // genuine empty state until fresh values arrive. + const lastIdentityKeyRef = useRef(swapIdentityKey) + if (lastIdentityKeyRef.current !== swapIdentityKey) { + lastIdentityKeyRef.current = swapIdentityKey + lastMilesRef.current = null + lastEffectiveSurplusRateRef.current = null + } + // Whether gas data has loaded at least once — triggers one recalc when it arrives. // Only require baseFeePerGas on the permit path — the ETH path doesn't use // it, so blocking on its initial null would needlessly delay the first diff --git a/src/hooks/use-swap-form.ts b/src/hooks/use-swap-form.ts index d43597c2..686a7cd3 100644 --- a/src/hooks/use-swap-form.ts +++ b/src/hooks/use-swap-form.ts @@ -328,6 +328,7 @@ export function useSwapForm(allTokens: Token[]) { quoteGeneration, maxSlippagePct: parseFloat(effectiveSlippage) || 0, enabled: !isWrapUnwrap && !!displayQuote && hasSufficientBalance, + isQuoteLoading, }) // Feed observed shortfall back into the slippage hook so auto mode can bump diff --git a/src/hooks/use-swap-slippage.ts b/src/hooks/use-swap-slippage.ts index b6020c4c..55423f31 100644 --- a/src/hooks/use-swap-slippage.ts +++ b/src/hooks/use-swap-slippage.ts @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useMemo, useCallback } from "react" +import { useState, useEffect, useMemo, useCallback, useRef } from "react" const DEADLINE_MIN_MINUTES = 5 const DEADLINE_MAX_MINUTES = 1440 @@ -37,13 +37,25 @@ interface UseSwapSlippageOptions { /** * Target slippage when auto mode needs to cover an observed Barter shortfall. - * Rounded up to the nearest 0.1% step, buffered, and capped at the UI ceiling. + * + * Linear in the underlying shortfall — `shortfall + buffer` — clamped at + * SLIPPAGE_MAX. We deliberately do NOT step-round the shortfall before adding + * the buffer here: stair-stepping the underlying produced an artifact where + * any positive shortfall (even 0.001%) would push auto from baseline (1.0%) + * to 1.1%, even though the buffer alone already covered it. The final + * formatted display value is still 0.1%-aligned via `formatSlippage`, so the + * user-facing number stays clean. + * + * Auto is allowed to ramp up to SLIPPAGE_MAX so the user can see the actual + * slippage required to execute their swap. The `slippageWarning` derived + * value flips to "high" above SLIPPAGE_WARN_THRESHOLD (5%) to surface the + * cost-of-execution as the bump climbs. * * Exported for testing. */ export function computeAutoBumpValue(shortfallPct: number): number { - const roundedUp = Math.ceil(shortfallPct / SLIPPAGE_STEP) * SLIPPAGE_STEP - return Math.min(SLIPPAGE_MAX, roundedUp + AUTO_BUMP_BUFFER_PCT) + const raw = Math.max(0, shortfallPct) + AUTO_BUMP_BUFFER_PCT + return Math.min(SLIPPAGE_MAX, raw) } function clampDeadline(minutes: number): number { @@ -176,6 +188,19 @@ export function useSwapSlippage(options: UseSwapSlippageOptions = {}) { setMode("auto") } + // When the user flips back to auto, drop the stored custom value so the next + // custom-mode entry starts from the current auto baseline rather than + // restoring whatever was typed before. Without this, custom remembers the + // last value across an auto round-trip — surprising when auto's own value + // has shifted in the meantime (calc-applied bumps, gas changes, etc.). + const prevModeRef = useRef(mode) + useEffect(() => { + if (prevModeRef.current === "custom" && mode === "auto") { + setCustomSlippage(autoSlippage) + } + prevModeRef.current = mode + }, [mode, autoSlippage]) + const updateDeadline = (val: number) => { const num = Number(val) if (Number.isNaN(num)) return From 73df17f9ad1e31edc239fe9ce8adb4a1c7d60d6b Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Sat, 2 May 2026 11:16:41 -0300 Subject: [PATCH 10/13] feat(swap): promote slippage-adjusted min as headline when miles are applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuyCard and SwapConfirmationModal now show the slippage-adjusted minimum as the large white receive amount when the miles calculator has lifted slippage above the auto baseline — keeps the primary number consistent with the actual swap conditions instead of a stale optimistic quote. The pre-calc estimate stays underneath as a one-line diff with a "Revert" link that closes the calc, returns slippage to auto, and clears the miles-applied marker. Also: - Close the calc when the user flips slippage mode custom→auto in settings (transition-tracked via ref so the default auto state doesn't slam the calc closed on open). - milesToSlippage now tolerates the FLOOR_EPSILON-induced drift up to SLIPPAGE_MAX + 0.5%, clamping to 50% — fixes Apply being disabled when the user typed exactly maxAchievableMiles. --- .../modals/SwapConfirmationModal.tsx | 76 ++++++++++++++++- src/components/swap/BuyCard.tsx | 84 +++++++++++-------- src/components/swap/SwapForm.tsx | 26 ++++++ src/components/swap/SwapInterface.tsx | 4 + src/hooks/use-estimated-miles.ts | 8 +- 5 files changed, 158 insertions(+), 40 deletions(-) diff --git a/src/components/modals/SwapConfirmationModal.tsx b/src/components/modals/SwapConfirmationModal.tsx index 92a9ad42..868b7716 100644 --- a/src/components/modals/SwapConfirmationModal.tsx +++ b/src/components/modals/SwapConfirmationModal.tsx @@ -99,6 +99,11 @@ interface SwapConfirmationModalProps { approveTokenSymbol?: string /** Estimated Fast Miles earned from this swap */ estimatedMiles?: number | null + /** True when the miles calc lifted slippage above the auto baseline. The + * headline receive amount switches to the slippage-adjusted min so the + * primary number reflects the swap conditions the user actually agreed + * to (matches the BuyCard behavior). */ + milesApplied?: boolean /** Called with the recommended slippage when a barter slippage error is detected. */ onRetryWithSlippage?: (slippage: string) => void /** When true, immediately execute the swap on open (skip review). Used by toast retry flow. */ @@ -213,6 +218,7 @@ function SwapConfirmationModal({ onApprove, approveTokenSymbol, estimatedMiles: estimatedMilesLive, + milesApplied: milesAppliedLive = false, onRetryWithSlippage, autoExecute = false, onAutoExecuteConsumed, @@ -240,6 +246,7 @@ function SwapConfirmationModal({ fromTokenPrice: number | null | undefined toTokenPrice: number | null | undefined estimatedMiles: number | null | undefined + milesApplied: boolean } | null>(null) const wasOpenRef = useRef(open) @@ -263,6 +270,7 @@ function SwapConfirmationModal({ fromTokenPrice: fromTokenPriceLive, toTokenPrice: toTokenPriceLive, estimatedMiles: estimatedMilesLive, + milesApplied: milesAppliedLive, } } else if (!open && wasOpenRef.current) { // Modal just closed — clear snapshot @@ -288,6 +296,7 @@ function SwapConfirmationModal({ const fromTokenPrice = snapshotRef.current?.fromTokenPrice ?? fromTokenPriceLive const toTokenPrice = snapshotRef.current?.toTokenPrice ?? toTokenPriceLive const estimatedMiles = snapshotRef.current?.estimatedMiles ?? estimatedMilesLive + const milesApplied = snapshotRef.current?.milesApplied ?? milesAppliedLive // --- EXTERNAL HOOKS --- const { chain: signerChain, isConnected } = useAccount() @@ -556,6 +565,50 @@ function SwapConfirmationModal({ return num * toTokenPrice }, [amountOut, toTokenPrice]) + // Miles-applied headline: when the calc lifted slippage, the slippage- + // adjusted min becomes the headline receive amount and the pre-calc expected + // amount drops to a supporting line below. Mirrors the BuyCard so the user + // sees a consistent number across the swap form and confirmation review. + const milesEstimateView = useMemo(() => { + if (!milesApplied) return null + const expected = parseFloat(amountOut?.replace(/,/g, "") ?? "") + const min = parseFloat(minAmountOut?.replace(/,/g, "") ?? "") + if ( + !Number.isFinite(expected) || + expected <= 0 || + !Number.isFinite(min) || + min <= 0 || + min >= expected + ) { + return null + } + return { expected, min } + }, [milesApplied, amountOut, minAmountOut]) + + const headlineReceiveValue = milesEstimateView + ? slippageLimitFormatted || minAmountOut || amountOut + : amountOut + + const headlineUsdValue = useMemo(() => { + if (!milesEstimateView) return toUsdValue + if (toTokenPrice == null || toTokenPrice <= 0) return null + return milesEstimateView.min * toTokenPrice + }, [milesEstimateView, toTokenPrice, toUsdValue]) + + const expectedUsdValue = useMemo(() => { + if (!milesEstimateView) return null + if (toTokenPrice == null || toTokenPrice <= 0) return null + return milesEstimateView.expected * toTokenPrice + }, [milesEstimateView, toTokenPrice]) + + const formatTokenAmount = (n: number): string => { + if (!Number.isFinite(n)) return "—" + if (n === 0) return "0" + if (n >= 1) return n.toLocaleString(undefined, { maximumFractionDigits: 4 }) + if (n >= 0.0001) return n.toLocaleString(undefined, { maximumFractionDigits: 6 }) + return n.toPrecision(2) + } + const activeError = externalError ? new RPCError( externalError.message, @@ -805,15 +858,15 @@ function SwapConfirmationModal({

- {" "} + {" "} {tokenOut?.symbol}

- {toUsdValue != null ? ( + {headlineUsdValue != null ? ( ≈ $ + {milesEstimateView && ( +

+ Est. without miles: {formatTokenAmount(milesEstimateView.expected)}{" "} + {tokenOut?.symbol} + {expectedUsdValue != null ? ( + + {" "} + (≈ $ + {expectedUsdValue.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + ) + + ) : null} +

+ )}
diff --git a/src/components/swap/BuyCard.tsx b/src/components/swap/BuyCard.tsx index 27f2c3d1..ecbee175 100644 --- a/src/components/swap/BuyCard.tsx +++ b/src/components/swap/BuyCard.tsx @@ -8,7 +8,6 @@ import { cn } from "@/lib/utils" // Local Components import AmountInput from "./AmountInput" import TokenInfoRow from "./TokenInfoRow" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" // Hooks import { useBalanceFlash } from "@/hooks/use-balance-flash" @@ -54,9 +53,10 @@ interface BuyCardProps { buyInputRef: React.RefObject // Miles Calc Surface — when the miles calculator was used to apply slippage, - // we change the header label and replace the USD-price line with a min-out - // breakdown so the user can see *what they're paying* for the miles they - // targeted (lower guaranteed receive vs. standard auto slippage). + // the headline buy amount becomes the slippage-adjusted minimum (the new + // "estimate") so the primary number reflects the swap conditions the user + // actually agreed to. The pre-calc expected amount is preserved underneath + // as supporting context so the diff stays visible. milesApplied: boolean /** Slippage-adjusted minimum receive amount (decimal string). */ minAmountOut: string | null @@ -65,6 +65,10 @@ interface BuyCardProps { /** Slippage percent that auto-mode would land at without the miles calc. * Used to compute the "vs standard" delta the user is paying for miles. */ standardSlippagePct: number + /** Closes the miles calc, returns slippage to auto, and clears the + * miles-applied marker. Surfaced as a "Revert" link next to the + * pre-calc estimate so the user has a one-click way back. */ + onRevertMiles: () => void } const BuyCardComponent: React.FC = ({ @@ -89,6 +93,7 @@ const BuyCardComponent: React.FC = ({ minAmountOut, slippagePct, standardSlippagePct, + onRevertMiles, }) => { /** * 1. LOCAL UI STATE @@ -114,23 +119,25 @@ const BuyCardComponent: React.FC = ({ setAmount(toBalanceValue.toString()) } - // Cost-of-miles math: the delta between the standard min-out (auto baseline) - // and the slippage-adjusted min-out the user is now agreeing to. Numeric - // computation only — no display strings here so the rendering layer stays - // declarative. + // Cost-of-miles math: when the calc lifted slippage above the auto baseline, + // we promote the slippage-adjusted minimum to the headline buy amount and + // preserve the pre-calc expected amount underneath. Numeric computation only + // — display strings live in the render block. const cleanOutput = outputAmount ? outputAmount.replace(/,/g, "") : "" const expectedNum = parseFloat(cleanOutput) const cleanMin = minAmountOut ? minAmountOut.replace(/,/g, "") : "" const minNum = parseFloat(cleanMin) - const showMilesBreakdown = + // Only flip the headline when the user is NOT actively typing into the buy + // input — otherwise we'd overwrite their in-flight value mid-keystroke. + const showMilesEstimate = milesApplied && + editingSide !== "buy" && Number.isFinite(expectedNum) && expectedNum > 0 && Number.isFinite(minNum) && minNum > 0 && - slippagePct > standardSlippagePct - const standardMin = showMilesBreakdown ? expectedNum * (1 - standardSlippagePct / 100) : null - const deltaVsStandard = showMilesBreakdown && standardMin != null ? standardMin - minNum : null + slippagePct > standardSlippagePct && + minNum < expectedNum // Compact decimal formatting that mirrors the existing buy-amount precision. const formatTokenNum = (n: number): string => { @@ -141,6 +148,12 @@ const BuyCardComponent: React.FC = ({ return n.toPrecision(2) } + // Headline value the AmountInput renders. When miles are applied, it's the + // slippage-adjusted min — same formatting precision as the typed amount. + const headlineValue = showMilesEstimate ? formatTokenNum(minNum) : buyDisplayValue + const expectedUsd = + showMilesEstimate && activeToTokenPrice > 0 ? expectedNum * activeToTokenPrice : null + return (
{/* Header Section */} @@ -166,11 +179,15 @@ const BuyCardComponent: React.FC = ({
{ if (editingSide !== "buy") { - const cleanValue = buyDisplayValue?.replace(/,/g, "") || "" + // When swapping into the buy input, seed it with whatever the + // user is currently looking at — the calc-applied min if it's + // promoted, otherwise the standard buy display value. + const seed = (showMilesEstimate ? headlineValue : buyDisplayValue) ?? "" + const cleanValue = seed.replace(/,/g, "") if (cleanValue && !isNaN(parseFloat(cleanValue))) { setAmount(cleanValue) } @@ -187,28 +204,23 @@ const BuyCardComponent: React.FC = ({ {toToken && !!outputAmount && outputAmount !== "0" && ( <> - {showMilesBreakdown && minNum != null && deltaVsStandard != null ? ( - - - -
- - ≈ $ - {(minNum * (activeToTokenPrice || 0)).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} - {deltaVsStandard > 0 ? ` (−${formatTokenNum(deltaVsStandard)})` : ""} - -
-
- - Minimum you'll receive at the slippage the miles calculator applied. The - number in parentheses is what those miles cost you in {toToken.symbol}{" "} - compared to standard auto slippage. - -
-
+ {showMilesEstimate ? ( + // Miles applied → headline above is the slippage-adjusted min. + // The pre-calc estimate stays as a one-line diff with an + // inline Revert link so the user can back out without hunting + // for the calc or settings gear. +
+ + {formatTokenNum(expectedNum)} {toToken.symbol} + + +
) : ( { + setIsCalcOpen(false) + resetSlippage() + setMilesAppliedSlippage(null) + }, [resetSlippage]) + // Drop the miles-applied marker the moment slippage drifts away from // the calc-applied value (manual edit in settings, retry-with-slippage, // auto-bump kicking back in, etc.). Without this the buy card would lie. @@ -180,6 +189,21 @@ export function SwapForm() { } }, [form.slippage, milesAppliedSlippage]) + // Flipping slippage mode FROM custom BACK to auto while the calculator is + // open means the calc-applied slippage is no longer in effect — close the + // calculator so the badge collapses to its baseline state. Tracked as a + // transition (not steady state) so the default auto mode doesn't slam the + // calc closed the moment the user opens it. + const prevSlippageModeRef = useRef(form.mode) + useEffect(() => { + const prev = prevSlippageModeRef.current + prevSlippageModeRef.current = form.mode + if (prev === "custom" && form.mode === "auto" && isCalcOpen) { + setIsCalcOpen(false) + setMilesAppliedSlippage(null) + } + }, [form.mode, isCalcOpen]) + // Token switch → calc resets, so the marker should too. useEffect(() => { setMilesAppliedSlippage(null) @@ -295,6 +319,7 @@ export function SwapForm() { swapResetCount={form.swapResetCount} onApplyMilesCalc={handleApplyMilesCalc} onCloseMilesCalc={handleCloseMilesCalc} + onRevertMiles={handleRevertMiles} milesApplied={milesApplied} computedMinAmountOut={form.computedMinAmountOut ?? null} isCalcOpen={isCalcOpen} @@ -366,6 +391,7 @@ export function SwapForm() { onApprove={form.approvePermit2} approveTokenSymbol={form.approveTokenSymbol} estimatedMiles={estimatedMiles} + milesApplied={milesApplied} externalError={lastTxError} onRetryWithSlippage={(newSlippage) => { form.updateSlippage(newSlippage) diff --git a/src/components/swap/SwapInterface.tsx b/src/components/swap/SwapInterface.tsx index a810d390..4db42644 100644 --- a/src/components/swap/SwapInterface.tsx +++ b/src/components/swap/SwapInterface.tsx @@ -108,6 +108,9 @@ interface SwapInterfaceProps { swapResetCount: number onApplyMilesCalc: (args: { slippage: string }) => void onCloseMilesCalc: () => void + /** Closes the miles calc, drops slippage back to auto, and clears the + * miles-applied marker. Wired to the BuyCard's "Revert" link. */ + onRevertMiles: () => void /** True when the miles calc has set the current slippage (cleared on token switch, * manual slippage edit, or successful swap). Drives the buy card's "miles applied" * label and min-out display. */ @@ -271,6 +274,7 @@ export const SwapInterface: React.FC = (props) => { minAmountOut={props.computedMinAmountOut} slippagePct={parseFloat(slippage) || 0} standardSlippagePct={Math.max(slippageAutoBase ?? 0, 1)} + onRevertMiles={props.onRevertMiles} />
diff --git a/src/hooks/use-estimated-miles.ts b/src/hooks/use-estimated-miles.ts index 5d48c534..9d845adf 100644 --- a/src/hooks/use-estimated-miles.ts +++ b/src/hooks/use-estimated-miles.ts @@ -604,7 +604,13 @@ export function useEstimatedMiles({ // drops the miles count below the target. Drift up: ≤1 mile. const SLIPPAGE_STEP = 0.01 const requiredSlippagePct = Math.ceil(requiredSlippagePctRaw / SLIPPAGE_STEP) * SLIPPAGE_STEP - if (!Number.isFinite(requiredSlippagePct) || requiredSlippagePct > SLIPPAGE_MAX) { + // Tolerance above SLIPPAGE_MAX: when the target equals the displayed + // `maxAchievableMiles` (computed at exactly 50% slippage), the FLOOR_EPSILON + // bump above can push the required raw slippage 0.005–0.05% past 50% + // depending on outputInEth. Without this tolerance, typing the max would + // disable Apply (raw > 50% → null), forcing the user to subtract a mile + // to re-enable. Anything more than 0.5% over is a real over-target. + if (!Number.isFinite(requiredSlippagePct) || requiredSlippagePct > SLIPPAGE_MAX + 0.5) { return null } From 57558eff0c61fd7e4cfcb16885e939f7fdb13c07 Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Mon, 4 May 2026 21:41:00 -0300 Subject: [PATCH 11/13] feat(miles): operator-tunable miles calc slippage cap via Edge Config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `miles_calc_max_slippage_pct` as a single Edge Config dial that controls the slippage ceiling the miles calculator plans against. Drives both `maxAchievableMiles` (the headline "up to ~X miles") and the `milesToSlippage` planner cap, so changes take effect on the next page load with no redeploy. The route clamps the value to [1%, 50%] server-side: floor keeps the planner range above the path autoBase (1% on permit), ceiling prevents a typo from unlocking slippage past the system-wide max. Falls back to 50 on missing/invalid/null/error. Tests: parameterized the JS-mirrored math helpers on `slippageMax` and added cap-tunable cases covering proportional shrink (50→25, 50→10), inverse rejection past cap+tolerance, exactly-max admission at any cap, and a fuzz invariant that planner output never exceeds the cap. New route test mocks `@vercel/edge-config` and exercises defaults, pass-through, clamps, fallbacks, and the error path. 98 pass. --- docs/miles-estimation.md | 1 + src/app/api/config/gas-estimate/route.test.ts | 136 +++++++++++ src/app/api/config/gas-estimate/route.ts | 20 +- src/hooks/__tests__/miles-math.test.ts | 219 ++++++++++++++++-- src/hooks/use-estimated-miles.ts | 52 ++++- 5 files changed, 399 insertions(+), 29 deletions(-) create mode 100644 src/app/api/config/gas-estimate/route.test.ts diff --git a/docs/miles-estimation.md b/docs/miles-estimation.md index 6750de63..7ea955ce 100644 --- a/docs/miles-estimation.md +++ b/docs/miles-estimation.md @@ -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 | — | — | diff --git a/src/app/api/config/gas-estimate/route.test.ts b/src/app/api/config/gas-estimate/route.test.ts new file mode 100644 index 00000000..5bcfd6f9 --- /dev/null +++ b/src/app/api/config/gas-estimate/route.test.ts @@ -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>) { + 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() + ) + }) +}) diff --git a/src/app/api/config/gas-estimate/route.ts b/src/app/api/config/gas-estimate/route.ts index ca09e111..868d5567 100644 --- a/src/app/api/config/gas-estimate/route.ts +++ b/src/app/api/config/gas-estimate/route.ts @@ -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("miles_estimate_gas_limit_average"), get("miles_estimate_gas_used_average"), get("miles_estimate_surplus_rate"), + get("miles_calc_max_slippage_pct"), ]) return NextResponse.json( @@ -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" } } ) @@ -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" } } ) diff --git a/src/hooks/__tests__/miles-math.test.ts b/src/hooks/__tests__/miles-math.test.ts index 971408bc..d5a9be12 100644 --- a/src/hooks/__tests__/miles-math.test.ts +++ b/src/hooks/__tests__/miles-math.test.ts @@ -19,7 +19,14 @@ import { computeSurplusEth } from "../use-estimated-miles" // ────────────────────────────────────────────────────────────────────────── const USER_MEV_SHARE = 0.9 const MILES_PER_ETH = 100_000 -const SLIPPAGE_MAX = 50 +/** Default cap mirrors `DEFAULT_MILES_CALC_MAX_SLIPPAGE_PCT`. The hook + * reads this from Edge Config (`miles_calc_max_slippage_pct`) at runtime + * — these tests parameterize the helpers below so we can exercise the + * default and any operator-set value with the same machinery. */ +const DEFAULT_SLIPPAGE_MAX = 50 +/** Tolerance the planner allows above the cap before declaring a target + * unreachable. Mirrors `MILES_CALC_SLIPPAGE_TOLERANCE_PCT`. */ +const SLIPPAGE_TOLERANCE = 0.5 const SLIPPAGE_STEP = 0.01 // planner step const ETH_DECIMALS = 18 const USDC_DECIMALS = 6 @@ -50,7 +57,8 @@ function forwardMiles(surplusEth: number, c: CostInputs): number { /** * Inverse: given a target miles count + the forward calc's last observed * effective surplus rate, return the slippage that produces target. - * Mirrors `milesToSlippage` in use-estimated-miles.ts. + * Mirrors `milesToSlippage` in use-estimated-miles.ts. The `slippageMax` + * parameter mirrors the Edge-Config-driven cap. */ function milesToSlippage( target: number, @@ -58,7 +66,8 @@ function milesToSlippage( currentSlippagePct: number, lastEffectiveRate: number, c: CostInputs, - autoBase: number + autoBase: number, + slippageMax: number = DEFAULT_SLIPPAGE_MAX ): number | null { if (target <= 0 || outputInEth <= 0) return null const totalBidCost = c.bidCostEth * c.sweepMultiplier @@ -69,12 +78,12 @@ function milesToSlippage( const K = netMevEth + totalBidCost + totalGasCost + FLOOR_EPSILON const requiredRaw = currentSlippagePct + 100 * (K / outputInEth - lastEffectiveRate) const required = Math.ceil(requiredRaw / SLIPPAGE_STEP) * SLIPPAGE_STEP - if (required > SLIPPAGE_MAX) return null - return Math.min(SLIPPAGE_MAX, Math.max(autoBase, required)) + if (required > slippageMax + SLIPPAGE_TOLERANCE) return null + return Math.min(slippageMax, Math.max(autoBase, required)) } -/** Inverse: max miles at given outputInEth and slippage = 50%. */ -function maxMilesAt50( +/** Inverse: max miles at given outputInEth and slippage = `slippageMax`. */ +function maxMilesAtCap( parsedAmountOut: number, toTokenDecimals: number, isEthOutput: boolean, @@ -82,13 +91,14 @@ function maxMilesAt50( ethPrice: number | null, barterPreGas: bigint | null, outputInEth: number, - c: CostInputs + c: CostInputs, + slippageMax: number = DEFAULT_SLIPPAGE_MAX ): number | null { let surplusEth: number | null = null if (barterPreGas != null && barterPreGas > 0n) { surplusEth = computeSurplusEth({ parsedAmountOut, - slippagePct: SLIPPAGE_MAX, + slippagePct: slippageMax, barterPreGasOutputAmount: barterPreGas, toTokenDecimals, isEthOutput, @@ -97,7 +107,7 @@ function maxMilesAt50( }) } if (surplusEth == null) { - surplusEth = (SLIPPAGE_MAX / 100) * outputInEth + surplusEth = (slippageMax / 100) * outputInEth } return forwardMiles(surplusEth, c) } @@ -195,7 +205,7 @@ describe("inverse-then-forward round trip", () => { const lastEffectiveRate = currentSlippage / 100 + (rng() - 0.5) * 0.005 const autoBase = 0.5 // Target small enough to be reachable within 50%. - const maxMiles = forwardMiles(outputInEth * (SLIPPAGE_MAX / 100), DEFAULT_COSTS) + const maxMiles = forwardMiles(outputInEth * (DEFAULT_SLIPPAGE_MAX / 100), DEFAULT_COSTS) if (maxMiles <= 1) continue const target = 1 + Math.floor(rng() * (maxMiles - 1)) @@ -208,6 +218,11 @@ describe("inverse-then-forward round trip", () => { autoBase ) if (s == null) continue + // Skip targets where the tolerance window pinned the planner to the + // cap — the tolerance trades a small under-delivery for the property + // that "exactly maxAchievable" is always a clickable target. The + // dedicated tolerance test above covers that path. + if (s >= DEFAULT_SLIPPAGE_MAX - 1e-9) continue // Compute the forward result at this slippage (using the same // effective rate the planner assumed). @@ -225,7 +240,7 @@ describe("inverse-then-forward round trip", () => { expect(asserts).toBeGreaterThan(100) }) - it("ALL planner outputs sit within [autoBase, SLIPPAGE_MAX]", () => { + it("ALL planner outputs sit within [autoBase, DEFAULT_SLIPPAGE_MAX]", () => { const rng = mulberry32(22) for (let i = 0; i < 5_000; i++) { const outputInEth = 0.001 + rng() * 5 @@ -244,7 +259,7 @@ describe("inverse-then-forward round trip", () => { ) if (s == null) continue expect(s).toBeGreaterThanOrEqual(autoBase - 1e-9) - expect(s).toBeLessThanOrEqual(SLIPPAGE_MAX + 1e-9) + expect(s).toBeLessThanOrEqual(DEFAULT_SLIPPAGE_MAX + 1e-9) } }) }) @@ -260,7 +275,7 @@ describe("maxAchievableMiles is consistent with the forward formula", () => { const barterPreGas = wei(outputInEth * 0.999) const direct = computeSurplusEth({ parsedAmountOut, - slippagePct: SLIPPAGE_MAX, + slippagePct: DEFAULT_SLIPPAGE_MAX, barterPreGasOutputAmount: barterPreGas, toTokenDecimals: ETH_DECIMALS, isEthOutput: true, @@ -269,7 +284,7 @@ describe("maxAchievableMiles is consistent with the forward formula", () => { })! const expectedMiles = forwardMiles(direct, DEFAULT_COSTS) - const maxMiles = maxMilesAt50( + const maxMiles = maxMilesAtCap( parsedAmountOut, ETH_DECIMALS, true, @@ -291,7 +306,7 @@ describe("maxAchievableMiles is consistent with the forward formula", () => { gasCostEth: 0, sweepMultiplier: 1, } - const max = maxMilesAt50( + const max = maxMilesAtCap( outputInEth, ETH_DECIMALS, true, @@ -315,7 +330,7 @@ describe("maxAchievableMiles is consistent with the forward formula", () => { // → totals 0.74e-3 ETH, dwarfing the 0.5×0.000667 = 3.3e-4 ETH ceiling. const usdcOut = 1.95 // $1.95 const outputInEth = (usdcOut * 1) / 3000 - const max = maxMilesAt50( + const max = maxMilesAtCap( usdcOut, USDC_DECIMALS, false, @@ -382,3 +397,173 @@ describe("drift sensitivity", () => { expect(Math.abs(mA - mB)).toBeLessThan(Math.max(mA, mB)) }) }) + +// ────────────────────────────────────────────────────────────────────────── +// Operator-tunable cap (Edge Config: `miles_calc_max_slippage_pct`) +// +// The miles calculator's slippage ceiling is read from Edge Config by +// `useEstimatedMiles` so operators can tune it without redeploying. These +// tests exercise the math at non-default caps to confirm: +// • lowering the cap shrinks `maxAchievableMiles` proportionally +// • the inverse rejects targets that would have been reachable at 50% +// • the FLOOR_EPSILON tolerance still admits exactly-max targets at +// any cap value +// ────────────────────────────────────────────────────────────────────────── +describe("operator-tunable slippage cap", () => { + it("maxMilesAtCap shrinks roughly linearly when the cap drops 50% → 25%", () => { + // ETH-output, ETH-path. Routing premium fixed at 0.2% of output so the + // diff comes purely from the slippage term in + // surplus = barterPreGas − parsedOut × (1 − cap/100) + const outputInEth = 0.5 + const barterPreGas = wei(outputInEth * 0.998) + const at50 = maxMilesAtCap( + outputInEth, + ETH_DECIMALS, + true, + null, + null, + barterPreGas, + outputInEth, + DEFAULT_COSTS, + 50 + )! + const at25 = maxMilesAtCap( + outputInEth, + ETH_DECIMALS, + true, + null, + null, + barterPreGas, + outputInEth, + DEFAULT_COSTS, + 25 + )! + // surplus(50%) = 0.998·X − 0.5·X = 0.498·X + // surplus(25%) = 0.998·X − 0.75·X = 0.248·X + // ratio ≈ 0.498 → at25 ≈ 0.498 × at50 (within floor() rounding) + expect(at25).toBeGreaterThan(0) + expect(at25).toBeLessThan(at50) + expect(at25 / at50).toBeCloseTo(0.248 / 0.498, 1) + }) + + it("maxMilesAtCap=10 collapses to a small fraction of the 50% value", () => { + const outputInEth = 1 + const barterPreGas = wei(outputInEth * 0.999) + const at50 = maxMilesAtCap( + outputInEth, + ETH_DECIMALS, + true, + null, + null, + barterPreGas, + outputInEth, + DEFAULT_COSTS, + 50 + )! + const at10 = maxMilesAtCap( + outputInEth, + ETH_DECIMALS, + true, + null, + null, + barterPreGas, + outputInEth, + DEFAULT_COSTS, + 10 + )! + // surplus(50%)/surplus(10%) ≈ (0.999−0.5)/(0.999−0.9) = 0.499/0.099 ≈ 5× + expect(at50 / at10).toBeCloseTo(0.499 / 0.099, 0) + }) + + it("milesToSlippage rejects a target that needs > cap + tolerance", () => { + // At cap=25, a target that requires ~30% slippage is rejected, even + // though it would have been reachable when the cap was 50. + const outputInEth = 1 + const lastEffectiveRate = 0.005 // 0.5% routing premium baked in + // Target sized so requiredRaw lands ~30% — well past 25 + 0.5 tolerance. + const target = forwardMiles(outputInEth * 0.3, DEFAULT_COSTS) + expect(milesToSlippage(target, outputInEth, 1, lastEffectiveRate, DEFAULT_COSTS, 0.5, 25)) + .toBeNull() + // Same target IS reachable when the cap is the default 50. + expect( + milesToSlippage(target, outputInEth, 1, lastEffectiveRate, DEFAULT_COSTS, 0.5, 50) + ).not.toBeNull() + }) + + it("milesToSlippage admits exactly-max target at a custom cap (FLOOR_EPSILON tolerance)", () => { + // The forward at cap = 25 gives some max M; the inverse called with M + // must succeed (clamped to 25), not return null. This is the bug the + // tolerance window was added to fix; needs to hold at any cap value. + for (const cap of [10, 25, 33, 50]) { + const outputInEth = 0.5 + const barterPreGas = wei(outputInEth * 0.999) + const max = maxMilesAtCap( + outputInEth, + ETH_DECIMALS, + true, + null, + null, + barterPreGas, + outputInEth, + DEFAULT_COSTS, + cap + )! + // Use the routing-premium based effective rate the forward would have + // observed at the user's current slippage (1%). + const lastEffectiveRate = + computeSurplusEth({ + parsedAmountOut: outputInEth, + slippagePct: 1, + barterPreGasOutputAmount: barterPreGas, + toTokenDecimals: ETH_DECIMALS, + isEthOutput: true, + toTokenPrice: null, + ethPrice: null, + })! / outputInEth + const slippage = milesToSlippage( + max, + outputInEth, + 1, + lastEffectiveRate, + DEFAULT_COSTS, + 0.5, + cap + ) + expect(slippage).not.toBeNull() + expect(slippage!).toBeLessThanOrEqual(cap + 1e-9) + } + }) + + it("planner output never exceeds the operator-set cap (fuzzed)", () => { + function mulberry32(seed: number) { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + } + const rng = mulberry32(42) + for (let i = 0; i < 500; i++) { + const cap = 5 + rng() * 45 // 5–50% + const outputInEth = 0.001 + rng() * 5 + const lastEffectiveRate = rng() * 0.05 + const target = Math.floor(rng() * 5000) + if (target <= 0) continue + const s = milesToSlippage( + target, + outputInEth, + 1, + lastEffectiveRate, + DEFAULT_COSTS, + 0.5, + cap + ) + if (s == null) continue + // Final clamped slippage is always ≤ cap (Math.min in the helper). + expect(s).toBeLessThanOrEqual(cap + 1e-9) + } + }) +}) diff --git a/src/hooks/use-estimated-miles.ts b/src/hooks/use-estimated-miles.ts index 9d845adf..b25cb1a9 100644 --- a/src/hooks/use-estimated-miles.ts +++ b/src/hooks/use-estimated-miles.ts @@ -21,6 +21,16 @@ const USER_MEV_SHARE = 0.9 const MILES_PER_ETH = 100_000 /** How often to refresh bid estimate from FastRPC (ms) — roughly 1 block */ const BID_ESTIMATE_POLL_MS = 12_000 +/** + * Default ceiling the calculator's slippage planner is allowed to propose, in + * percent. Used until Edge Config (`miles_calc_max_slippage_pct`) loads and + * as the offline fallback in tests. Mirrors the route default in + * `src/app/api/config/gas-estimate/route.ts`. + */ +export const DEFAULT_MILES_CALC_MAX_SLIPPAGE_PCT = 50 +/** Tolerance above the cap to forgive FLOOR_EPSILON drift when target equals + * `maxAchievableMiles`. Anything beyond this is a real over-target. */ +export const MILES_CALC_SLIPPAGE_TOLERANCE_PCT = 0.5 /** * Compute the on-chain surplus (in ETH) the FastSettlement contract would @@ -189,6 +199,9 @@ export function useEstimatedMiles({ const [avgGasLimit, setAvgGasLimit] = useState(DEFAULT_AVG_GAS_LIMIT) const [avgGasUsed, setAvgGasUsed] = useState(DEFAULT_AVG_GAS_USED) const [surplusRate, setSurplusRate] = useState(DEFAULT_SURPLUS_RATE) + const [milesCalcMaxSlippagePct, setMilesCalcMaxSlippagePct] = useState( + DEFAULT_MILES_CALC_MAX_SLIPPAGE_PCT + ) // Fetch gas estimates and surplus rate from Edge Config (updated daily by cron). // Runs on mount — not gated on `enabled` so data is ready before the first quote arrives. @@ -210,6 +223,12 @@ export function useEstimatedMiles({ if (typeof data.surplusRate === "number" && data.surplusRate > 0) { setSurplusRate(data.surplusRate) } + if ( + typeof data.milesCalcMaxSlippagePct === "number" && + data.milesCalcMaxSlippagePct > 0 + ) { + setMilesCalcMaxSlippagePct(data.milesCalcMaxSlippagePct) + } } } catch (err) { console.warn("[useEstimatedMiles] Failed to fetch estimates:", err) @@ -266,11 +285,13 @@ export function useEstimatedMiles({ const avgGasLimitRef = useRef(DEFAULT_AVG_GAS_LIMIT) const avgGasUsedRef = useRef(DEFAULT_AVG_GAS_USED) const surplusRateRef = useRef(DEFAULT_SURPLUS_RATE) + const milesCalcMaxSlippagePctRef = useRef(DEFAULT_MILES_CALC_MAX_SLIPPAGE_PCT) priorityFeeRef.current = priorityFee baseFeeRef.current = baseFeePerGas avgGasLimitRef.current = avgGasLimit avgGasUsedRef.current = avgGasUsed surplusRateRef.current = surplusRate + milesCalcMaxSlippagePctRef.current = milesCalcMaxSlippagePct // Track last successful miles so transient states don't flash null. const lastMilesRef = useRef(null) @@ -595,7 +616,9 @@ export function useEstimatedMiles({ requiredSlippagePctRaw = (100 * K) / outputInEth } - const SLIPPAGE_MAX = 50 + // Edge-Config-driven cap on what slippage the calc will plan against. + // Read from a ref so daily refreshes don't invalidate this useCallback. + const SLIPPAGE_MAX = milesCalcMaxSlippagePctRef.current // Mirror useSwapSlippage's autoBase floors. const autoBase = isPermitPath ? 1 : 0.5 // Round UP to 0.01% (small step). Ceiling ensures the applied slippage @@ -605,12 +628,15 @@ export function useEstimatedMiles({ const SLIPPAGE_STEP = 0.01 const requiredSlippagePct = Math.ceil(requiredSlippagePctRaw / SLIPPAGE_STEP) * SLIPPAGE_STEP // Tolerance above SLIPPAGE_MAX: when the target equals the displayed - // `maxAchievableMiles` (computed at exactly 50% slippage), the FLOOR_EPSILON - // bump above can push the required raw slippage 0.005–0.05% past 50% - // depending on outputInEth. Without this tolerance, typing the max would - // disable Apply (raw > 50% → null), forcing the user to subtract a mile - // to re-enable. Anything more than 0.5% over is a real over-target. - if (!Number.isFinite(requiredSlippagePct) || requiredSlippagePct > SLIPPAGE_MAX + 0.5) { + // `maxAchievableMiles` (computed at exactly the cap), the FLOOR_EPSILON + // bump above can push the required raw slippage a few hundredths of a + // percent past the cap depending on outputInEth. Without this tolerance, + // typing the max would disable Apply, forcing the user to subtract a + // mile to re-enable. Anything more than the tolerance is a real over-target. + if ( + !Number.isFinite(requiredSlippagePct) || + requiredSlippagePct > SLIPPAGE_MAX + MILES_CALC_SLIPPAGE_TOLERANCE_PCT + ) { return null } @@ -659,11 +685,14 @@ export function useEstimatedMiles({ const totalBidCost = bidCostEth * sweepMultiplier const totalGasCost = gasCostEth * sweepMultiplier - const SLIPPAGE_MAX = 50 - // Use the SAME surplus formula the forward uses, evaluated at s = 50%. + // Edge-Config-driven cap (default 50%). Mirrors what the inverse planner + // uses so the max miles displayed in the badge are exactly what Apply at + // the cap will produce. + const SLIPPAGE_MAX = milesCalcMaxSlippagePct + // Use the SAME surplus formula the forward uses, evaluated at s = SLIPPAGE_MAX. // Guarantees that when the calc proposes the max and the user applies - // it, the forward at slippage=50% produces matching miles in the bar. - // Falls back to the simple (50/100)·outputInEth approximation when + // it, the forward at the same slippage produces matching miles in the bar. + // Falls back to the simple (SLIPPAGE_MAX/100)·outputInEth approximation when // barter routing hasn't been observed yet (cold load). let surplusEth: number | null = null if ( @@ -701,6 +730,7 @@ export function useEstimatedMiles({ barterPreGasOutputAmount, toTokenDecimals, isBarterValidating, + milesCalcMaxSlippagePct, ]) // Hold the previous max while validation is in flight so the displayed From 59abb210be823692ea536308096635bbde408569 Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Mon, 11 May 2026 09:30:44 -0300 Subject: [PATCH 12/13] docs(miles): align learn page with calculator + new estimate states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Apply Miles tooltip links here, but the section referenced a "TBD" UI state that no longer exists and never mentioned the calculator. Replace with the actual states (~N miles / — / Apply Miles), describe the Enable→Apply flow and slippage trade-off, and reframe "swap too small to generate mev" as auto-slippage zero with the calculator as the manual path. Anchor #about-the-miles-estimate preserved. --- content/learn/miles.mdx | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/content/learn/miles.mdx b/content/learn/miles.mdx index f75ac620..a2b922a6 100644 --- a/content/learn/miles.mdx +++ b/content/learn/miles.mdx @@ -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 @@ -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 From 73d2b7092370f5158f0ef37fc0f6a025f6ae25e3 Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Mon, 11 May 2026 09:31:46 -0300 Subject: [PATCH 13/13] style(tests): prettier reflow in miles-math test suite Formatter-only reflow of two assertions in the operator-tunable slippage cap describe block. No logic change. --- src/hooks/__tests__/miles-math.test.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/hooks/__tests__/miles-math.test.ts b/src/hooks/__tests__/miles-math.test.ts index d5a9be12..08b46b83 100644 --- a/src/hooks/__tests__/miles-math.test.ts +++ b/src/hooks/__tests__/miles-math.test.ts @@ -482,8 +482,9 @@ describe("operator-tunable slippage cap", () => { const lastEffectiveRate = 0.005 // 0.5% routing premium baked in // Target sized so requiredRaw lands ~30% — well past 25 + 0.5 tolerance. const target = forwardMiles(outputInEth * 0.3, DEFAULT_COSTS) - expect(milesToSlippage(target, outputInEth, 1, lastEffectiveRate, DEFAULT_COSTS, 0.5, 25)) - .toBeNull() + expect( + milesToSlippage(target, outputInEth, 1, lastEffectiveRate, DEFAULT_COSTS, 0.5, 25) + ).toBeNull() // Same target IS reachable when the cap is the default 50. expect( milesToSlippage(target, outputInEth, 1, lastEffectiveRate, DEFAULT_COSTS, 0.5, 50) @@ -552,15 +553,7 @@ describe("operator-tunable slippage cap", () => { const lastEffectiveRate = rng() * 0.05 const target = Math.floor(rng() * 5000) if (target <= 0) continue - const s = milesToSlippage( - target, - outputInEth, - 1, - lastEffectiveRate, - DEFAULT_COSTS, - 0.5, - cap - ) + const s = milesToSlippage(target, outputInEth, 1, lastEffectiveRate, DEFAULT_COSTS, 0.5, cap) if (s == null) continue // Final clamped slippage is always ≤ cap (Math.min in the helper). expect(s).toBeLessThanOrEqual(cap + 1e-9)