diff --git a/src/components/swap/SwapForm.tsx b/src/components/swap/SwapForm.tsx index 6bd1016f..3afd3ce3 100644 --- a/src/components/swap/SwapForm.tsx +++ b/src/components/swap/SwapForm.tsx @@ -58,6 +58,7 @@ export function SwapForm() { slippage: form.slippage, toTokenDecimals: form.toToken?.decimals ?? null, barterPreGasOutputAmount: form.barterPreGasOutputAmount, + barterGasEstimation: form.barterGasEstimation, toTokenPrice: form.toPrice, ethPrice: form.ethPrice, isEthOutput, diff --git a/src/hooks/__tests__/miles-math.test.ts b/src/hooks/__tests__/miles-math.test.ts index c8c1dfbd..806df26f 100644 --- a/src/hooks/__tests__/miles-math.test.ts +++ b/src/hooks/__tests__/miles-math.test.ts @@ -12,7 +12,7 @@ */ import { describe, it, expect } from "vitest" -import { computeSurplusEth } from "../use-estimated-miles" +import { computeSurplusEth, predictGasLimit } from "../use-estimated-miles" // ────────────────────────────────────────────────────────────────────────── // Constants — must match use-estimated-miles.ts @@ -562,3 +562,89 @@ describe("operator-tunable slippage cap", () => { } }) }) + +// ────────────────────────────────────────────────────────────────────────── +// predictGasLimit — per-swap gasLimit prediction, mirrors the backend's +// `mev-commit/tools/preconf-rpc/fastswap/fastswap.go` formula. Frontend uses +// the same numbers so the bid the user sees and the bid the executor submits +// match. Floor at 400_000 was added in backend commit b2d13572 to avoid +// EIP-150 OOG on simple routes; the frontend mirrors it. +// ────────────────────────────────────────────────────────────────────────── +describe("predictGasLimit", () => { + const FALLBACK_AVG = 450_000n + const WRAPPER_PERMIT = 135_000n + const WRAPPER_ETH = 152_000n + const FLOOR = 400_000n + + it("permit path with barter present, scaled above floor", () => { + // 200k × 2.5 = 500k + 135k = 635k > 400k → 635k + expect(predictGasLimit(200_000, true, FALLBACK_AVG)).toBe(500_000n + WRAPPER_PERMIT) + }) + + it("ETH path with barter present, scaled above floor", () => { + // 200k × 2.5 = 500k + 152k = 652k > 400k → 652k + expect(predictGasLimit(200_000, false, FALLBACK_AVG)).toBe(500_000n + WRAPPER_ETH) + }) + + it("permit path scaled below floor → returns floor", () => { + // 50k × 2.5 = 125k + 135k = 260k < 400k → 400k + expect(predictGasLimit(50_000, true, FALLBACK_AVG)).toBe(FLOOR) + }) + + it("ETH path scaled below floor → returns floor", () => { + // 50k × 2.5 = 125k + 152k = 277k < 400k → 400k + expect(predictGasLimit(50_000, false, FALLBACK_AVG)).toBe(FLOOR) + }) + + it("permit path right at the floor boundary (260k → 395k → floor)", () => { + // 104k × 2.5 = 260k + 135k = 395k < 400k → 400k + expect(predictGasLimit(104_000, true, FALLBACK_AVG)).toBe(FLOOR) + // 106k × 2.5 = 265k + 135k = 400k = floor → still 400k (>, not >=, so floor) + expect(predictGasLimit(106_000, true, FALLBACK_AVG)).toBe(FLOOR) + // 107k × 2.5 = 267.5k → floor(267500) + 135k = 402_500 > 400_000 → 402_500 + expect(predictGasLimit(107_000, true, FALLBACK_AVG)).toBe(267_500n + WRAPPER_PERMIT) + }) + + it("missing barter (undefined) falls back to avg gas limit", () => { + expect(predictGasLimit(undefined, true, FALLBACK_AVG)).toBe(FALLBACK_AVG) + expect(predictGasLimit(undefined, false, FALLBACK_AVG)).toBe(FALLBACK_AVG) + }) + + it("invalid barter values (NaN, Infinity, 0, negative) fall back to avg", () => { + expect(predictGasLimit(Number.NaN, true, FALLBACK_AVG)).toBe(FALLBACK_AVG) + expect(predictGasLimit(Number.POSITIVE_INFINITY, true, FALLBACK_AVG)).toBe(FALLBACK_AVG) + expect(predictGasLimit(0, true, FALLBACK_AVG)).toBe(FALLBACK_AVG) + expect(predictGasLimit(-100, true, FALLBACK_AVG)).toBe(FALLBACK_AVG) + }) + + it("Math.floor in barter × 2.5 (no rounding up)", () => { + // 100_001 × 2.5 = 250_002.5 → floor = 250_002. + 135k = 385_002 < 400k → 400k + expect(predictGasLimit(100_001, true, FALLBACK_AVG)).toBe(FLOOR) + // 110_001 × 2.5 = 275_002.5 → floor = 275_002. + 135k = 410_002 > 400k → 410_002 + expect(predictGasLimit(110_001, true, FALLBACK_AVG)).toBe(275_002n + WRAPPER_PERMIT) + }) + + it("realistic permit-path swap (barter ~120k → 435k limit)", () => { + // 120_000 × 2.5 = 300_000 + 135_000 = 435_000 + expect(predictGasLimit(120_000, true, FALLBACK_AVG)).toBe(435_000n) + }) + + it("multi-hop swap (barter ~350k → 1.01M limit)", () => { + // 350_000 × 2.5 = 875_000 + 135_000 = 1_010_000 + expect(predictGasLimit(350_000, true, FALLBACK_AVG)).toBe(1_010_000n) + }) + + it("p75 gas-used envelope: predictedGasLimit × 0.77 stays above realized p50", () => { + // p75 of `gas_used / gas_limit` across 46 post-floor permit-path swaps + // (2026-05-11 → 2026-05-13). Picked over mean/p50 so gas cost is rarely + // under-predicted — realized miles meet or exceed the badge estimate. + // Spot-check: applied to a representative predicted limit (~435k for a + // 120k-gas barter route), p75 lands at the upper realized envelope + // (~330k–340k), comfortably above the p50 realized gasUsed of ~295k. + const P75_RATIO = 0.77 + const predictedLimit = predictGasLimit(120_000, true, FALLBACK_AVG) // 435k + const predictedUsed = Math.floor(Number(predictedLimit) * P75_RATIO) + expect(predictedUsed).toBeGreaterThan(295_000) // > realized p50 + expect(predictedUsed).toBeLessThan(360_000) // ≈ realized p75-p80 envelope + }) +}) diff --git a/src/hooks/use-barter-validation.ts b/src/hooks/use-barter-validation.ts index 4da171ba..7ea97926 100644 --- a/src/hooks/use-barter-validation.ts +++ b/src/hooks/use-barter-validation.ts @@ -88,6 +88,14 @@ interface UseBarterValidationReturn { * value (older deployment, or ETH path where they're equal). */ barterPreGasOutputAmount: bigint | undefined + /** + * Barter's raw `gasEstimation` for the current route. Drives the miles + * estimator's per-swap predicted gasLimit (mirrors the backend formula in + * `mev-commit/tools/preconf-rpc/fastswap/fastswap.go`: `max(400_000, + * floor(gasEstimation × 2.5) + wrapper)`). When undefined the estimator + * falls back to the Edge Config rolling average. + */ + barterGasEstimation: number | undefined /** * True when Barter's /route endpoint has failed for the current inputs at least * UNAVAILABLE_ERROR_THRESHOLD times in a row. Callers should block swap submission @@ -125,6 +133,7 @@ export function useBarterValidation({ const [barterPreGasOutputAmount, setBarterPreGasOutputAmount] = useState( undefined ) + const [barterGasEstimation, setBarterGasEstimation] = useState(undefined) const [barterUnavailable, setBarterUnavailable] = useState(false) /** * True when the most recent barter response produced an out-of-band shortfall @@ -157,6 +166,7 @@ export function useBarterValidation({ setSanityGated(false) setBarterAmountOut(undefined) setBarterPreGasOutputAmount(undefined) + setBarterGasEstimation(undefined) setBarterUnavailable(false) setSettled(true) lastSettledKeyRef.current = "" @@ -182,6 +192,7 @@ export function useBarterValidation({ setSanityGated(false) setBarterAmountOut(undefined) setBarterPreGasOutputAmount(undefined) + setBarterGasEstimation(undefined) setBarterUnavailable(false) setSettled(true) lastSettledKeyRef.current = "" @@ -196,6 +207,7 @@ export function useBarterValidation({ setSanityGated(false) setBarterAmountOut(undefined) setBarterPreGasOutputAmount(undefined) + setBarterGasEstimation(undefined) // Do NOT reset barterUnavailable here — if we're in an outage, leaving it true // across input changes avoids "swap button enables for 300ms then blocks again" // flicker. Successful validation below clears it. @@ -238,6 +250,7 @@ export function useBarterValidation({ if (Math.abs(shortfallRaw) > SANITY_GATE_PCT) { setBarterAmountOut(undefined) setBarterPreGasOutputAmount(undefined) + setBarterGasEstimation(undefined) setShortfallPct(0) setSanityGated(true) setBarterUnavailable(false) @@ -248,6 +261,11 @@ export function useBarterValidation({ setBarterAmountOut(barterOut) setBarterPreGasOutputAmount(barterPreGas) + setBarterGasEstimation( + Number.isFinite(route.gasEstimation) && route.gasEstimation > 0 + ? route.gasEstimation + : undefined + ) setShortfallPct(Math.max(0, shortfallRaw)) setSanityGated(false) setBarterUnavailable(false) @@ -273,6 +291,7 @@ export function useBarterValidation({ // and mark settled so the UI stops spinning. setBarterAmountOut(undefined) setBarterPreGasOutputAmount(undefined) + setBarterGasEstimation(undefined) setShortfallPct(0) setBarterUnavailable(true) setSettled(true) @@ -336,6 +355,7 @@ export function useBarterValidation({ isValidating: !isCurrent || !settled, barterAmountOut: isCurrent ? barterAmountOut : undefined, barterPreGasOutputAmount: isCurrent ? barterPreGasOutputAmount : undefined, + barterGasEstimation: isCurrent ? barterGasEstimation : undefined, barterUnavailable: isCurrent && barterUnavailable, } } diff --git a/src/hooks/use-estimated-miles.ts b/src/hooks/use-estimated-miles.ts index 33140fcc..143de295 100644 --- a/src/hooks/use-estimated-miles.ts +++ b/src/hooks/use-estimated-miles.ts @@ -7,6 +7,60 @@ import { RPC_ENDPOINT } from "@/lib/network-config" const DEFAULT_AVG_GAS_LIMIT = 450_000n /** Fallback average gas used for gas cost calculation on permit path (baseFee × gasUsed) */ const DEFAULT_AVG_GAS_USED = 180_000n +/** + * Wrapper overhead constants mirroring the backend's per-path additive in + * `mev-commit/tools/preconf-rpc/fastswap/fastswap.go`: + * permit path: gasLimit = barterGasEstimation × 2.5 + 135_000 + * ETH path: gasLimit = barterGasEstimation × 2.5 + 152_000 + * Kept in lockstep with the backend so the bid the frontend estimates and + * the bid the executor actually submits match line-for-line. + */ +const WRAPPER_OVERHEAD_PERMIT = 135_000n +const WRAPPER_OVERHEAD_ETH = 152_000n +/** Floor enforced by the backend (commit b2d13572) to avoid EIP-150 OOG on + * simple routes. Per-swap `predictedGasLimit` is clamped to at least this. */ +const MIN_GAS_LIMIT = 400_000n +/** Multiplier applied to Barter's `gasEstimation` to mirror the backend's + * safety headroom on the raw routing estimate. */ +const BARTER_GAS_MULTIPLIER = 2.5 +/** + * p75 of realized `gas_used / gas_limit` across 46 post-floor permit-path + * swaps (sampled 2026-05-11 → 2026-05-13). Used to scale the per-swap + * predicted gasLimit into a predicted gasUsed for the user L1 gas term. + * + * Why p75 and not the mean: gas deduction is a one-sided cost — if we + * under-predict gas, miles get over-promised and realized < estimate + * (bad UX). p75 envelopes the upper end of the realized distribution so + * predicted gasCost rarely undershoots actual, keeping miles estimates + * conservative. The realized ratio distribution is tight (stddev/p50 ≈ + * 13%) so the gap between mean and p75 is small (~0.07). + */ +const PREDICTED_GAS_USED_RATIO_P75 = 0.77 + +/** + * Mirrors the backend's gasLimit formula. When `barterGasEstimation` is + * available, scales it the same way the executor will and clamps to the + * 400k floor. When absent (cold load, in-flight validation, ETH path + * before barter settled), falls back to the rolling Edge Config average. + * + * Exported for testing. + */ +export function predictGasLimit( + barterGasEstimation: number | undefined, + isPermitPath: boolean, + fallbackAvgGasLimit: bigint +): bigint { + if ( + barterGasEstimation == null || + !Number.isFinite(barterGasEstimation) || + barterGasEstimation <= 0 + ) { + return fallbackAvgGasLimit + } + const wrapper = isPermitPath ? WRAPPER_OVERHEAD_PERMIT : WRAPPER_OVERHEAD_ETH + const scaled = BigInt(Math.floor(barterGasEstimation * BARTER_GAS_MULTIPLIER)) + wrapper + return scaled > MIN_GAS_LIMIT ? scaled : MIN_GAS_LIMIT +} /** * Fallback priority fee in wei (≈ 0.06 gwei). Matches the rough median value * `mevcommit_estimateBidPricePerGas` returns under normal conditions. Used as @@ -164,6 +218,14 @@ interface UseEstimatedMilesParams { * so the badge has a value to show. */ barterPreGasOutputAmount: bigint | undefined + /** + * Barter's raw `gasEstimation` for the current route. Drives the per-swap + * predicted gasLimit used for both the bid cost (`priorityFee × gasLimit`) + * and the user L1 gas cost on the permit path (`baseFee × predictedGasLimit + * × p75GasUsedRatio`). When undefined the hook falls back to the Edge Config + * rolling gas-limit average, matching the prior behavior. + */ + barterGasEstimation: number | undefined toTokenPrice: number | null ethPrice: number | null isEthOutput: boolean @@ -238,6 +300,7 @@ export function useEstimatedMiles({ slippage, toTokenDecimals, barterPreGasOutputAmount, + barterGasEstimation, toTokenPrice, ethPrice, isEthOutput, @@ -455,15 +518,29 @@ export function useEstimatedMiles({ formulaSource = "edge-config-fallback" } - // Bid cost: priority fee × avg gas limit (bid = priorityFee × txn.Gas()). - // This is the user's single tx bid — additive, not scaled. - const bidCostEth = Number(curPriorityFee * curAvgGasLimit) / 1e18 + // Bid cost: priority fee × per-swap predicted gasLimit. Mirrors the + // backend's submit formula exactly (`mev-commit/tools/preconf-rpc/ + // fastswap/fastswap.go`): `max(400_000, floor(gasEstimation × 2.5) + + // wrapper)`. Falls back to the Edge Config rolling average when barter + // hasn't returned a quote yet, preserving the prior behavior on cold + // load. Bid is additive (single user tx), not scaled. + const predictedGasLimit = predictGasLimit(barterGasEstimation, isPermitPath, curAvgGasLimit) + const bidCostEth = Number(curPriorityFee * predictedGasLimit) / 1e18 // User L1 gas: only deducted when the relayer paid (permit / ERC20 input). // ETH-input swaps go through `executeWithETH` and the user pays out of // their own wallet, so the miles formula does not subtract it. Mirrors // `userPaysGas` in `fastswap-miles/miles.go` exactly. - const gasCostEth = isPermitPath ? Number(curBaseFee * curAvgGasUsed) / 1e18 : 0 + // + // Per-swap predicted gas used = predictedGasLimit × p75 of the realized + // `gas_used / gas_limit` distribution. p75 (under-promise) so gasCost + // is rarely under-predicted — realized miles meet or exceed the badge + // estimate. The realized ratio is tight (stddev/p50 ≈ 13% across recent + // permit-path swaps). + const predictedGasUsed = BigInt( + Math.floor(Number(predictedGasLimit) * PREDICTED_GAS_USED_RATIO_P75) + ) + const gasCostEth = isPermitPath ? Number(curBaseFee * predictedGasUsed) / 1e18 : 0 // Sweep overhead: per-token p25 of realized sweep gas, in ETH, from // Edge Config. The backend's `costEstimator` writes the same value and @@ -501,11 +578,13 @@ export function useEstimatedMiles({ : ` Step 2: MEV pot (Edge Config fallback: surplusRate × output)\n` + ` slippageAmountEth = ${outputInEth.toFixed(6)} × ${curSurplusRate} = ${slippageAmountEth.toFixed(8)} ETH\n`) + `\n` + - ` Step 3: Bid cost (FastRPC bid estimate × avgGasLimit from Edge Config)\n` + - ` bidCostEth = ${curPriorityFee.toString()} wei × ${curAvgGasLimit.toString()} gasLimit / 1e18 = ${bidCostEth.toFixed(8)} ETH\n` + + ` Step 3: Bid cost (FastRPC bid estimate × predictedGasLimit)\n` + + ` predictedGasLimit = ${predictedGasLimit.toString()} ` + + `(${barterGasEstimation != null && barterGasEstimation > 0 ? `barter ${barterGasEstimation} × ${BARTER_GAS_MULTIPLIER} + ${isPermitPath ? WRAPPER_OVERHEAD_PERMIT.toString() : WRAPPER_OVERHEAD_ETH.toString()}, floor ${MIN_GAS_LIMIT.toString()}` : `Edge Config avg fallback`})\n` + + ` bidCostEth = ${curPriorityFee.toString()} wei × ${predictedGasLimit.toString()} / 1e18 = ${bidCostEth.toFixed(8)} ETH\n` + `\n` + ` Step 4: Gas cost${isPermitPath ? " (relayer pays actual gasUsed on permit path)" : " (user pays on ETH path = 0)"}\n` + - ` gasCostEth = ${isPermitPath ? `${curBaseFee.toString()} wei × ${curAvgGasUsed.toString()} gasUsed / 1e18 = ` : ""}${gasCostEth.toFixed(8)} ETH\n` + + ` gasCostEth = ${isPermitPath ? `${curBaseFee.toString()} wei × ${predictedGasUsed.toString()} predictedGasUsed (p75 ratio ${PREDICTED_GAS_USED_RATIO_P75}) / 1e18 = ` : ""}${gasCostEth.toFixed(8)} ETH\n` + (!isEthOutput ? `\n Step 4b: Sweep overhead (non-ETH output, per-token p25 from Edge Config)\n` + ` sweepOverheadEth = ${sweepOverheadEth.toFixed(8)} ETH (token=${outputTokenAddress ?? "unknown"})\n` @@ -530,6 +609,7 @@ export function useEstimatedMiles({ amountOut, slippage, barterPreGasOutputAmount, + barterGasEstimation, toTokenDecimals, enabled, isBarterValidating, @@ -587,8 +667,12 @@ export function useEstimatedMiles({ : formulaicRate if (!Number.isFinite(effectiveSurplusRate) || effectiveSurplusRate <= 0) return null - const bidCostEth = Number(curPriorityFee * curAvgGasLimit) / 1e18 - const gasCostEth = isPermitPath ? Number(curBaseFee * curAvgGasUsed) / 1e18 : 0 + const predictedGasLimit = predictGasLimit(barterGasEstimation, isPermitPath, curAvgGasLimit) + const predictedGasUsed = BigInt( + Math.floor(Number(predictedGasLimit) * PREDICTED_GAS_USED_RATIO_P75) + ) + const bidCostEth = Number(curPriorityFee * predictedGasLimit) / 1e18 + const gasCostEth = isPermitPath ? Number(curBaseFee * predictedGasUsed) / 1e18 : 0 const sweepOverheadEth = isEthOutput ? 0 : sweepOverheadForToken(sweepOverheadByTokenRef.current, outputTokenAddress) @@ -611,6 +695,7 @@ export function useEstimatedMiles({ ethPrice, slippage, barterPreGasOutputAmount, + barterGasEstimation, outputTokenAddress, ] ) @@ -645,8 +730,12 @@ export function useEstimatedMiles({ const curAvgGasLimit = avgGasLimitRef.current const curAvgGasUsed = avgGasUsedRef.current - const bidCostEth = Number(curPriorityFee * curAvgGasLimit) / 1e18 - const gasCostEth = isPermitPath ? Number(curBaseFee * curAvgGasUsed) / 1e18 : 0 + const predictedGasLimit = predictGasLimit(barterGasEstimation, isPermitPath, curAvgGasLimit) + const predictedGasUsed = BigInt( + Math.floor(Number(predictedGasLimit) * PREDICTED_GAS_USED_RATIO_P75) + ) + const bidCostEth = Number(curPriorityFee * predictedGasLimit) / 1e18 + const gasCostEth = isPermitPath ? Number(curBaseFee * predictedGasUsed) / 1e18 : 0 const sweepOverheadEth = isEthOutput ? 0 : sweepOverheadForToken(sweepOverheadByTokenRef.current, outputTokenAddress) @@ -721,7 +810,16 @@ export function useEstimatedMiles({ requiresChange, } }, - [amountOut, slippage, isEthOutput, isPermitPath, toTokenPrice, ethPrice, outputTokenAddress] + [ + amountOut, + slippage, + isEthOutput, + isPermitPath, + toTokenPrice, + ethPrice, + outputTokenAddress, + barterGasEstimation, + ] ) // Upper bound: forward-compute miles at the user's CURRENT swap size @@ -747,8 +845,12 @@ export function useEstimatedMiles({ : (parsedAmountOut * (toTokenPrice as number)) / (ethPrice as number) if (!Number.isFinite(outputInEth) || outputInEth <= 0) return null - const bidCostEth = Number(priorityFee * avgGasLimit) / 1e18 - const gasCostEth = isPermitPath ? Number(baseFeePerGas * avgGasUsed) / 1e18 : 0 + const predictedGasLimit = predictGasLimit(barterGasEstimation, isPermitPath, avgGasLimit) + const predictedGasUsed = BigInt( + Math.floor(Number(predictedGasLimit) * PREDICTED_GAS_USED_RATIO_P75) + ) + const bidCostEth = Number(priorityFee * predictedGasLimit) / 1e18 + const gasCostEth = isPermitPath ? Number(baseFeePerGas * predictedGasUsed) / 1e18 : 0 const sweepOverheadEth = isEthOutput ? 0 : sweepOverheadForToken(sweepOverheadByToken, outputTokenAddress) @@ -796,6 +898,7 @@ export function useEstimatedMiles({ avgGasLimit, avgGasUsed, barterPreGasOutputAmount, + barterGasEstimation, toTokenDecimals, isBarterValidating, milesCalcMaxSlippagePct, diff --git a/src/hooks/use-swap-form.ts b/src/hooks/use-swap-form.ts index 686a7cd3..ac863003 100644 --- a/src/hooks/use-swap-form.ts +++ b/src/hooks/use-swap-form.ts @@ -319,6 +319,7 @@ export function useSwapForm(allTokens: Token[]) { isValidating: isBarterValidating, barterAmountOut, barterPreGasOutputAmount, + barterGasEstimation, barterUnavailable, } = useBarterValidation({ fromToken, @@ -663,6 +664,7 @@ export function useSwapForm(allTokens: Token[]) { hasNoLiquidity, barterAmountTooSmall, barterPreGasOutputAmount, + barterGasEstimation, barterUnavailable, isBarterValidating: debouncedValidating, gasEstimate: isWrapUnwrap ? wrapUnwrapGasEstimate : (displayQuote?.gasEstimate ?? null), diff --git a/src/hooks/use-token-price.ts b/src/hooks/use-token-price.ts index af51b220..20761832 100644 --- a/src/hooks/use-token-price.ts +++ b/src/hooks/use-token-price.ts @@ -8,6 +8,25 @@ interface TokenPriceResult { error: Error | null } +// Per-symbol plausibility bounds. The API has been observed returning ~$1 +// for ETH during transient backend issues, which cascades into miles +// surplus blow-ups (one user saw 30,927 miles instead of ~17). When an +// out-of-range price comes back, skip the update and keep the previous +// good value rather than poisoning every downstream consumer. +const SANE_PRICE_BOUNDS: Record = { + ETH: { min: 100, max: 100_000 }, + WETH: { min: 100, max: 100_000 }, + USDC: { min: 0.5, max: 2 }, + USDT: { min: 0.5, max: 2 }, + DAI: { min: 0.5, max: 2 }, +} + +function isPriceSane(symbol: string, price: number): boolean { + const bounds = SANE_PRICE_BOUNDS[symbol.toUpperCase()] + if (!bounds) return true + return price >= bounds.min && price <= bounds.max +} + /** * Hook to fetch token price(s) from the API * Supports single token or batched fetching for multiple tokens @@ -41,7 +60,13 @@ export function useTokenPrice(symbols: string | string[]): TokenPriceResult { const data = await response.json() if (data.success && data.price) { - setPrice(data.price) + if (isPriceSane(symbolArray[0], data.price)) { + setPrice(data.price) + } else { + console.warn( + `[useTokenPrice] rejected implausible ${symbolArray[0]} price: ${data.price} — keeping previous value` + ) + } } else { setPrice(null) setError(new Error(`Failed to fetch ${symbolArray[0]} price`)) @@ -56,7 +81,13 @@ export function useTokenPrice(symbols: string | string[]): TokenPriceResult { // For now, return the first price (can be extended to return map) const firstResult = results[0] if (firstResult.success && firstResult.price) { - setPrice(firstResult.price) + if (isPriceSane(symbolArray[0], firstResult.price)) { + setPrice(firstResult.price) + } else { + console.warn( + `[useTokenPrice] rejected implausible ${symbolArray[0]} price: ${firstResult.price} — keeping previous value` + ) + } } else { setPrice(null) setError(new Error(`Failed to fetch token prices`))