From d74567c14ea5a551bd2caeb5aa277bb8e01f01b4 Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Thu, 16 Apr 2026 21:43:09 -0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(swap):=20add=20Pro=20mode=20=E2=80=94?= =?UTF-8?q?=20top=2010%=20block=20placement=20for=20large=20swaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Pro" execution mode that sends a `topPercentile: 10` field on the /fastswap intent path, requesting top-of-block placement from mev-commit providers. Auto-engages at ≥$250 sell-side USD on permit-path swaps; user can toggle off. ETH-path swaps are not eligible. UI: Pro toggle pill with rotating border highlight in the header row, "Pro Swap" title, "Swap (Pro)" CTA label, Pro-branded switch button, SellCard top-edge gradient, celebration particle burst + sound on auto-engage, and "Execution: Pro — Top 10%" row in the confirmation modal. Also fixes a stale-price bug in useTokenPrice where switching tokens kept the previous token's USD price during the fetch window, causing false Pro triggers and incorrect USD displays. --- src/app/(app)/layout.tsx | 2 +- src/app/globals.css | 88 ++++++ .../modals/SwapConfirmationModal.tsx | 38 ++- src/components/swap/ActionButton.tsx | 4 +- src/components/swap/ExchangeRate.tsx | 2 +- src/components/swap/ProEngageCelebration.tsx | 122 +++++++++ src/components/swap/SellCard.tsx | 13 +- src/components/swap/SwapForm.tsx | 59 ++++ src/components/swap/SwapInterface.tsx | 19 +- src/components/swap/SwitchButton.tsx | 25 +- src/components/swap/TransactionSettings.tsx | 255 +++++++++++------- src/hooks/use-swap-confirmation.ts | 7 +- src/hooks/use-token-price.ts | 7 + src/lib/feature-flags.ts | 2 + src/lib/pro-mode.ts | 16 ++ 15 files changed, 552 insertions(+), 107 deletions(-) create mode 100644 src/components/swap/ProEngageCelebration.tsx create mode 100644 src/lib/pro-mode.ts diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index bc43706c..52c50d74 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -42,7 +42,7 @@ function AppLayoutContent({ children }: { children: React.ReactNode }) { const isGateRoute = pathname === "/" // Hide the app header on the gate route until the user clicks through to swap - const hideLayout = isGateRoute && FEATURE_FLAGS.swapPrivateMode && !passedGate + const hideLayout = isMounted && isGateRoute && FEATURE_FLAGS.swapPrivateMode && !passedGate // Wallet detection const isMetaMask = isMetaMaskWallet(connector) diff --git a/src/app/globals.css b/src/app/globals.css index ecac7a28..1431624f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -266,6 +266,94 @@ All colors MUST be HSL. } } +/* Pro Mode — rotating border highlight on the Pro pill */ +@property --pro-border-angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} + +@keyframes pro-border-spin { + to { + --pro-border-angle: 360deg; + } +} + +.pro-border-glow { + position: relative; + overflow: visible; +} + +.pro-border-glow::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + pointer-events: none; + background: conic-gradient( + from var(--pro-border-angle), + transparent 0deg, + transparent 250deg, + rgba(56, 139, 253, 0.9) 300deg, + transparent 350deg, + transparent 360deg + ); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + animation: pro-border-spin 2s linear infinite; +} + +/* Pro Mode — shake animation for the toggle pill on auto-engage */ +@keyframes pro-shake { + 0%, + 100% { + transform: translateX(0); + } + 10% { + transform: translateX(-3px); + } + 20% { + transform: translateX(3px); + } + 30% { + transform: translateX(-2px); + } + 40% { + transform: translateX(2px); + } + 50% { + transform: translateX(-1px); + } + 60% { + transform: translateX(1px); + } + 70% { + transform: translateX(0); + } +} + +.animate-pro-shake { + animation: pro-shake 600ms cubic-bezier(0.36, 0.07, 0.19, 0.97); +} + +/* Pro Mode — auto-engage flash on the swap interface */ +@keyframes pro-flash { + 0% { + opacity: 0.18; + } + 100% { + opacity: 0; + } +} + +.animate-pro-flash { + animation: pro-flash 1.2s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + /* Blinking cursor animation for input fields */ @keyframes blink-cursor { 0%, diff --git a/src/components/modals/SwapConfirmationModal.tsx b/src/components/modals/SwapConfirmationModal.tsx index 6ea8b9c1..bd8fbeee 100644 --- a/src/components/modals/SwapConfirmationModal.tsx +++ b/src/components/modals/SwapConfirmationModal.tsx @@ -97,6 +97,8 @@ interface SwapConfirmationModalProps { approveTokenSymbol?: string /** Estimated Fast Miles earned from this swap */ estimatedMiles?: number | null + /** Whether Pro mode (top 10% block placement) is active for this swap */ + isProMode?: 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. */ @@ -132,8 +134,8 @@ function InfoRow({ label, value, tooltip, valueClassName }: InfoRowProps) { - -

{tooltip}

+ +

{tooltip}

@@ -210,6 +212,7 @@ function SwapConfirmationModal({ onApprove, approveTokenSymbol, estimatedMiles: estimatedMilesLive, + isProMode: isProModeLive = false, onRetryWithSlippage, autoExecute = false, onAutoExecuteConsumed, @@ -236,6 +239,7 @@ function SwapConfirmationModal({ fromTokenPrice: number | null | undefined toTokenPrice: number | null | undefined estimatedMiles: number | null | undefined + isProMode: boolean } | null>(null) const wasOpenRef = useRef(open) @@ -258,6 +262,7 @@ function SwapConfirmationModal({ fromTokenPrice: fromTokenPriceLive, toTokenPrice: toTokenPriceLive, estimatedMiles: estimatedMilesLive, + isProMode: isProModeLive, } } else if (!open && wasOpenRef.current) { // Modal just closed — clear snapshot @@ -282,6 +287,7 @@ function SwapConfirmationModal({ const fromTokenPrice = snapshotRef.current?.fromTokenPrice ?? fromTokenPriceLive const toTokenPrice = snapshotRef.current?.toTokenPrice ?? toTokenPriceLive const estimatedMiles = snapshotRef.current?.estimatedMiles ?? estimatedMilesLive + const isProMode = snapshotRef.current?.isProMode ?? isProModeLive // --- EXTERNAL HOOKS --- const { chain: signerChain, isConnected } = useAccount() @@ -323,6 +329,7 @@ function SwapConfirmationModal({ minAmountOut, slippage, deadline, + proMode: isProMode, onSuccess: () => { setClearSwapState(true) if (refreshBalances) { @@ -899,6 +906,33 @@ function SwapConfirmationModal({ : "Estimated gas fee for this transaction" } /> + {isProMode && ( + Pro — Top 10%} + tooltip={ + <> + Guarantees your transaction lands in the top 10% of the block, reducing + reordering slippage from MEV bots. +
+
+ + Available for swaps ≥ $250 USD. Smaller trades receive standard block + placement. + +
+ + Learn how Pro works → + + + } + /> + )} {!isWrap && !isUnwrap && estimatedMiles != null && ( void isNonceLoading?: boolean + isProMode?: boolean } const ActionButtonComponent: React.FC = ({ @@ -54,6 +55,7 @@ const ActionButtonComponent: React.FC = ({ isUnwrap, handleSwapClick, isNonceLoading = false, + isProMode = false, }) => { const { status } = useAccount() const { isPreApproved, isLoading: isWhitelistLoading } = useGateStatus() @@ -263,7 +265,7 @@ const ActionButtonComponent: React.FC = ({ onClick={handleSwapClick} className="w-full h-12 sm:h-[54px] rounded-xl sm:rounded-2xl font-bold text-base sm:text-lg bg-gradient-to-r from-pink-500 to-primary hover:opacity-90 transition-all active:scale-[0.98]" > - {isWrap ? "Wrap" : isUnwrap ? "Unwrap" : "Swap"} + {isWrap ? "Wrap" : isUnwrap ? "Unwrap" : isProMode ? "Swap (Pro)" : "Swap"} )} diff --git a/src/components/swap/ExchangeRate.tsx b/src/components/swap/ExchangeRate.tsx index 91b18dea..bd4348ef 100644 --- a/src/components/swap/ExchangeRate.tsx +++ b/src/components/swap/ExchangeRate.tsx @@ -152,7 +152,7 @@ const ExchangeRateComponent: React.FC = ({ /> - {/* RIGHT SECTION: MILES ESTIMATE / PRICE IMPACT */} + {/* RIGHT SECTION: PRO BADGE / MILES ESTIMATE / PRICE IMPACT */} {!isWrapUnwrap && (
{estimatedMiles != null && ( diff --git a/src/components/swap/ProEngageCelebration.tsx b/src/components/swap/ProEngageCelebration.tsx new file mode 100644 index 00000000..9aed5446 --- /dev/null +++ b/src/components/swap/ProEngageCelebration.tsx @@ -0,0 +1,122 @@ +"use client" + +import { useEffect, useState, useRef } from "react" +import { motion, AnimatePresence } from "motion/react" + +/** + * Small particle burst + glow that fires when Pro mode auto-engages. + * Similar to PreconfirmCelebration but smaller, fewer particles, and + * uses pink/primary colors to match the CTA gradient. + */ + +interface Spark { + id: number + angle: number + distance: number + size: number + delay: number + duration: number + color: string +} + +const PRO_SPARK_COLORS = [ + "#ec4899", // pink-500 + "#f472b6", // pink-400 + "#3b82f6", // blue-500 + "#60a5fa", // blue-400 + "#a78bfa", // violet-400 + "#ffffff", // white +] + +function generateSparks(count: number): Spark[] { + return Array.from({ length: count }, (_, i) => ({ + id: i, + angle: (360 / count) * i + (Math.random() * 40 - 20), + distance: 24 + Math.random() * 20, + size: 1.5 + Math.random() * 2, + delay: Math.random() * 0.06, + duration: 0.4 + Math.random() * 0.2, + color: PRO_SPARK_COLORS[Math.floor(Math.random() * PRO_SPARK_COLORS.length)], + })) +} + +export function ProEngageCelebration({ active }: { active: boolean }) { + const [sparks] = useState(() => generateSparks(10)) + const [show, setShow] = useState(false) + const hasPlayed = useRef(false) + + useEffect(() => { + if (active && !hasPlayed.current) { + hasPlayed.current = true + setShow(true) + const timer = setTimeout(() => setShow(false), 1000) + return () => clearTimeout(timer) + } + if (!active) { + hasPlayed.current = false + } + }, [active]) + + return ( + + {show && ( +
+ {/* Central flash */} + + + {/* Spark particles */} + {sparks.map((s) => { + const rad = (s.angle * Math.PI) / 180 + const x = Math.cos(rad) * s.distance + const y = Math.sin(rad) * s.distance + return ( + + ) + })} + + {/* Ring pulse */} + +
+ )} +
+ ) +} diff --git a/src/components/swap/SellCard.tsx b/src/components/swap/SellCard.tsx index dde599dc..293edf0d 100644 --- a/src/components/swap/SellCard.tsx +++ b/src/components/swap/SellCard.tsx @@ -22,6 +22,7 @@ import { ZERO_ADDRESS } from "@/lib/swap-constants" import { TokenAvatar } from "@/components/swap/TokenAvatar" interface SellCardProps { + isProMode?: boolean // Token & Balance Data fromToken: Token | null amount: string @@ -52,6 +53,7 @@ interface SellCardProps { } const SellCardComponent: React.FC = ({ + isProMode = false, fromToken, amount, sellDisplayValue, @@ -125,7 +127,16 @@ const SellCardComponent: React.FC = ({ } return ( -
+
+ {isProMode && ( +
+ )} {/* Header: Label and Balance Information */}
diff --git a/src/components/swap/SwapForm.tsx b/src/components/swap/SwapForm.tsx index 2c210c9d..f70dd918 100644 --- a/src/components/swap/SwapForm.tsx +++ b/src/components/swap/SwapForm.tsx @@ -12,6 +12,8 @@ import { useSwapForm } from "@/hooks/use-swap-form" import { useBroadcastGasPrice } from "@/hooks/use-broadcast-gas-price" import { useEstimatedMiles } from "@/hooks/use-estimated-miles" import { FEATURE_FLAGS } from "@/lib/feature-flags" +import { PRO_MODE_MIN_USD } from "@/lib/pro-mode" +import { playPreconfirmSound } from "@/lib/preconfirm-sound" import { SwapInterface } from "./SwapInterface" @@ -104,6 +106,57 @@ export function SwapForm() { const [isFromSelectorOpen, setIsFromSelectorOpen] = useState(false) const [isToSelectorOpen, setIsToSelectorOpen] = useState(false) + // ------- Pro Mode (top 10% block placement) ------- + const [proManualOverride, setProManualOverride] = useState(null) + const [proJustActivated, setProJustActivated] = useState(false) + + const sellAmountNum = parseFloat(form.amount || "0") + const sellUsdValue = + !isNaN(sellAmountNum) && sellAmountNum > 0 && form.fromPrice && form.fromPrice > 0 + ? sellAmountNum * form.fromPrice + : 0 + + const proEligible = !!isPermitPath && !form.isWrapUnwrap && FEATURE_FLAGS.pro_mode + const proAutoOn = proEligible && sellUsdValue >= PRO_MODE_MIN_USD + + // Reset manual override when crossing the threshold so the auto-trigger + // fires fresh each time. + const prevProAutoOn = useRef(proAutoOn) + useEffect(() => { + if (prevProAutoOn.current !== proAutoOn) { + prevProAutoOn.current = proAutoOn + setProManualOverride(null) + } + }, [proAutoOn]) + + const meetsThreshold = sellUsdValue >= PRO_MODE_MIN_USD + const isProMode = + proEligible && + meetsThreshold && + proManualOverride !== false && + (proManualOverride === true || proAutoOn) + + // Flash animation when Pro auto-engages + const prevIsProMode = useRef(isProMode) + useEffect(() => { + if (isProMode && !prevIsProMode.current && proManualOverride === null) { + setProJustActivated(true) + playPreconfirmSound() + const t = setTimeout(() => setProJustActivated(false), 1500) + return () => clearTimeout(t) + } + prevIsProMode.current = isProMode + }, [isProMode, proManualOverride]) + + const handleTogglePro = () => { + if (!proEligible || !meetsThreshold) return + if (isProMode) { + setProManualOverride(false) + } else { + setProManualOverride(true) + } + } + // Input Refs const sellInputRef = useRef(null) const buyInputRef = useRef(null) @@ -210,6 +263,11 @@ export function SwapForm() { barterUnavailable={form.barterUnavailable} isBarterValidating={form.isBarterValidating} estimatedMiles={estimatedMiles} + // Pro Mode + isProMode={isProMode} + proEligible={proEligible} + proJustActivated={proJustActivated} + onTogglePro={handleTogglePro} /> {/* From Token Selector Modal */} @@ -284,6 +342,7 @@ export function SwapForm() { }} autoExecute={autoExecuteSwap} onAutoExecuteConsumed={() => setAutoExecuteSwap(false)} + isProMode={isProMode} /> )}
diff --git a/src/components/swap/SwapInterface.tsx b/src/components/swap/SwapInterface.tsx index 1de30ef8..e864f087 100644 --- a/src/components/swap/SwapInterface.tsx +++ b/src/components/swap/SwapInterface.tsx @@ -95,6 +95,12 @@ interface SwapInterfaceProps { barterUnavailable: boolean isBarterValidating: boolean estimatedMiles?: number | null + + // Pro Mode + isProMode: boolean + proEligible: boolean + proJustActivated: boolean + onTogglePro: () => void } export const SwapInterface: React.FC = (props) => { @@ -160,6 +166,11 @@ export const SwapInterface: React.FC = (props) => { return (
+ {/* Pro Mode auto-engage flash */} + {props.proJustActivated && ( +
+ )} + {/* 1. HEADER & SETTINGS Handles the "Swap" title and the configuration popover. Lazy-loaded to reduce initial TBT. @@ -172,6 +183,10 @@ export const SwapInterface: React.FC = (props) => { internalDeadline={internalDeadline} setInternalDeadline={setInternalDeadline} isMounted={isMounted} + isProMode={props.isProMode} + proEligible={props.proEligible} + proJustActivated={props.proJustActivated} + onTogglePro={props.onTogglePro} /> {/* 2. CORE SWAP CARDS @@ -179,6 +194,7 @@ export const SwapInterface: React.FC = (props) => { */}
= (props) => { />
- +
= (props) => { isUnwrap={isUnwrap} handleSwapClick={handleSwapClick} isNonceLoading={isNonceLoading} + isProMode={props.isProMode} /> diff --git a/src/components/swap/SwitchButton.tsx b/src/components/swap/SwitchButton.tsx index fc57c89a..b88e5f59 100644 --- a/src/components/swap/SwitchButton.tsx +++ b/src/components/swap/SwitchButton.tsx @@ -1,8 +1,29 @@ "use client" -import { ArrowDown } from "lucide-react" +import { ArrowDown, Zap } from "lucide-react" +import { cn } from "@/lib/utils" + +interface SwitchButtonProps { + handleSwitch: () => void + isProMode?: boolean +} + +export const SwitchButton = ({ handleSwitch, isProMode = false }: SwitchButtonProps) => { + if (isProMode) { + return ( +
+ +
+ ) + } -export const SwitchButton = ({ handleSwitch }: { handleSwitch: () => void }) => { return (
+ + +

+ Guarantees your transaction lands in the top 10% of the block, reducing reordering + slippage from MEV bots. +

+

+ Available for swaps ≥ $250 USD. Smaller trades receive standard block placement. +

+ + Learn how Pro works → + +
+ + + )} + + + + + - -
- - + +
+
+
+ Max slippage + + + + + + +

+ Maximum price movement allowed before transaction reverts. Max 2%. +

+
+
+
+
- -
-
-
- Max slippage - - - - - - -

- Maximum price movement allowed before transaction reverts. Max 2%. -

-
-
-
+
+
+ { + const val = e.target.value.replace(/[^0-9.]/g, "") + handleSlippageChange(val) + }} + className="w-12 bg-transparent text-right text-[15px] font-medium outline-none focus:ring-0 cursor-text text-white" + /> + % +
+
-
-
+
+
+ Swap deadline + + + + + + +

+ Transaction will revert if not confirmed within this time +

+
+
+
+
+ +
{ - const val = e.target.value.replace(/[^0-9.]/g, "") - handleSlippageChange(val) - }} - className="w-12 bg-transparent text-right text-[15px] font-medium outline-none focus:ring-0 cursor-text text-white" + value={internalDeadline} + onChange={(e) => setInternalDeadline(Number(e.target.value.replace(/\D/g, "")))} + className="w-8 bg-transparent text-center text-[15px] text-white font-medium outline-none focus:ring-0" /> - % + minutes
- -
-
- Swap deadline - - - - - - -

- Transaction will revert if not confirmed within this time -

-
-
-
-
- -
- setInternalDeadline(Number(e.target.value.replace(/\D/g, "")))} - className="w-8 bg-transparent text-center text-[15px] text-white font-medium outline-none focus:ring-0" - /> - minutes -
-
-
- - + + +
) } diff --git a/src/hooks/use-swap-confirmation.ts b/src/hooks/use-swap-confirmation.ts index a3ebd5ef..a9021ffb 100644 --- a/src/hooks/use-swap-confirmation.ts +++ b/src/hooks/use-swap-confirmation.ts @@ -9,6 +9,7 @@ import { useSwapIntent } from "@/hooks/use-swap-intent" import { usePermit2Nonce } from "@/hooks/use-permit2-nonce" import { ZERO_ADDRESS, WETH_ADDRESS } from "@/lib/swap-constants" import { FASTSWAP_API_BASE } from "@/lib/network-config" +import { TOP_OF_BLOCK_PERCENTILE } from "@/lib/pro-mode" import { fetchEthPathTxAndEstimate } from "@/lib/eth-path-tx" import type { Token } from "@/types/swap" @@ -20,6 +21,8 @@ interface UseSwapConfirmationParams { slippage: string deadline: number onSuccess?: () => void + /** When true, routes the permit-path submission through the Pro endpoint (top 10% block placement). */ + proMode?: boolean } /** Options for confirmSwap. Used by Permit path to show toast before relayer returns. */ @@ -41,6 +44,7 @@ export function useSwapConfirmation({ slippage, deadline, onSuccess, + proMode, }: UseSwapConfirmationParams) { const { isConnected, address } = useAccount() const publicClient = usePublicClient({ chainId: mainnet.id }) @@ -229,9 +233,10 @@ export function useSwapConfirmation({ nonce: intentData.intent.nonce.toString(), signature: intentData.signature, slippage: (parseFloat(slippage || "0.5") || 0.5).toFixed(1), + ...(proMode ? { topPercentile: TOP_OF_BLOCK_PERCENTILE } : {}), } - // Call FastRPC directly — CORS allows it, skip Vercel serverless proxy + // Call FastRPC directly — CORS allows it, skip Vercel serverless proxy. const resp = await fetch(`${FASTSWAP_API_BASE}/fastswap`, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/src/hooks/use-token-price.ts b/src/hooks/use-token-price.ts index af51b220..0b88b19a 100644 --- a/src/hooks/use-token-price.ts +++ b/src/hooks/use-token-price.ts @@ -22,6 +22,13 @@ export function useTokenPrice(symbols: string | string[]): TokenPriceResult { const symbolArray = Array.isArray(symbols) ? symbols : [symbols] const isSingle = !Array.isArray(symbols) + // Clear stale price immediately when the symbol changes so downstream + // consumers (USD display, Pro mode trigger) never see the old token's price. + const symbolKey = symbolArray.join(",") + useEffect(() => { + setPrice(null) + }, [symbolKey]) + const fetchPrice = useCallback(async () => { if (symbolArray.length === 0 || symbolArray.some((s) => !s)) { setPrice(null) diff --git a/src/lib/feature-flags.ts b/src/lib/feature-flags.ts index df58f8f2..a9f42682 100644 --- a/src/lib/feature-flags.ts +++ b/src/lib/feature-flags.ts @@ -8,6 +8,8 @@ export const FEATURE_FLAGS = { swapPrivateMode: true, /** When true, show estimated Fast Miles earned on the swap form and confirmation modal. */ show_miles_estimate: true, + /** When true, enables Pro mode (top 10% block placement) toggle on the swap interface. */ + pro_mode: true, /** When true, the quote hook always returns no liquidity, forcing the "This trade cannot be completed right now" button and "Why am I seeing this?" explainer link to appear for any token pair. Set to false for production. */ test_no_liquidity: false, /** When true, show referral counts on the Miles leaderboard (sub-text under wallets, "Your Referrals" stat, progress line) and show the Referral Leaders card on the stats page. Set to false to hide until Fuul attribution data is verified accurate. */ diff --git a/src/lib/pro-mode.ts b/src/lib/pro-mode.ts new file mode 100644 index 00000000..bc16435b --- /dev/null +++ b/src/lib/pro-mode.ts @@ -0,0 +1,16 @@ +/** + * Pro Mode — Top 10% Block Placement + * + * When active, the /fastswap POST body includes a `topPercentile` field + * that tells the executor to attach a top-of-block placement constraint + * to the mev-commit bid. + * + * Only applies to the intent/permit path (ERC20 swaps). ETH-path swaps + * go through the user's wallet and aren't eligible. + */ + +/** Sell-side USD value at which Pro mode auto-engages. */ +export const PRO_MODE_MIN_USD = 250 + +/** The percentile to request — 10 = top 10% of block. Tunable in prod. */ +export const TOP_OF_BLOCK_PERCENTILE = 10 From fe6ebb678e1a506217503b1f6715554fa60601a8 Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Fri, 17 Apr 2026 12:45:03 -0300 Subject: [PATCH 2/5] feat(config): read Pro mode threshold from edge config Add edge config key `pro_mode_min_usd` (default 250) so the Pro mode threshold can be tuned in prod without a deploy. New API route at /api/config/pro-threshold, client hook useProThreshold(), and docs covering all edge config keys. --- docs/edge-config.md | 88 +++++++++++++++++++++++ src/app/api/config/pro-threshold/route.ts | 25 +++++++ src/components/swap/SwapForm.tsx | 7 +- src/hooks/use-pro-threshold.ts | 25 +++++++ 4 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 docs/edge-config.md create mode 100644 src/app/api/config/pro-threshold/route.ts create mode 100644 src/hooks/use-pro-threshold.ts diff --git a/docs/edge-config.md b/docs/edge-config.md new file mode 100644 index 00000000..902b5455 --- /dev/null +++ b/docs/edge-config.md @@ -0,0 +1,88 @@ +# Edge Config + +Runtime configuration values stored in [Vercel Edge Config](https://vercel.com/docs/storage/edge-config). Read at the edge with ~0ms latency — no cold starts, no database round-trips. + +Values can be changed in the Vercel dashboard without a deploy. The frontend reads them via internal API routes (`/api/config/*`), cached 60s with 5-minute stale-while-revalidate. + +## Keys + +### `authorized_wallets` + +- **Type:** `string[]` +- **Used by:** `src/middleware.ts` +- **Purpose:** Wallet addresses authorized to bypass gating or access restricted features. Checked in middleware before route handlers execute. + +### `tx_confirmation_timeout_ms` + +- **Type:** `number` +- **Default:** `60000` (60 seconds) +- **Used by:** `src/hooks/use-wait-for-tx-confirmation.ts` via `/api/config/tx-timeout` +- **Purpose:** Maximum time (ms) to wait for a transaction to be preconfirmed or confirmed before giving up and showing an error. Increase if network is congested and preconfirmations are slow. + +### `leaderboard_poll_interval_ms` + +- **Type:** `number` +- **Default:** `15000` (15 seconds) +- **Used by:** `src/hooks/use-fuul-miles-leaderboard.ts` via `/api/config/leaderboard-poll` +- **Purpose:** How often the leaderboard refetches miles data. Lower values mean fresher data but more API load on the Fuul endpoint. + +### `miles_estimate_gas_limit_average` + +- **Type:** `number` +- **Default:** `450000` +- **Updated by:** Daily cron (`/api/cron/update-edge-config/miles-estimate-gas`) +- **Used by:** `src/hooks/use-estimated-miles.ts` via `/api/config/gas-estimate` +- **Purpose:** Average gas limit across recent FastSwap transactions. Used in the miles estimation formula to calculate bid cost. Updated daily from the last 200 on-chain transactions. + +### `miles_estimate_gas_used_average` + +- **Type:** `number` +- **Default:** `180000` +- **Updated by:** Daily cron (`/api/cron/update-edge-config/miles-estimate-gas`) +- **Used by:** `src/hooks/use-estimated-miles.ts` via `/api/config/gas-estimate` +- **Purpose:** Average gas actually consumed per FastSwap transaction. Used alongside gas limit to refine the miles estimate. Updated daily. + +### `miles_estimate_surplus_rate` + +- **Type:** `number` +- **Default:** `0.0056` +- **Updated by:** Daily cron (`/api/cron/update-edge-config/miles-estimate-gas`) +- **Used by:** `src/hooks/use-surplus-rate.ts` via `/api/config/gas-estimate` +- **Purpose:** p25 surplus rate (ETH per unit output) observed across recent swaps. Controls how aggressively the miles estimator credits MEV redistribution. Updated daily. + +### `miles_estimate_fee_percentile` + +- **Type:** `number` +- **Default:** `55` +- **Used by:** `src/hooks/use-estimated-miles.ts` via `/api/config/fee-percentile` +- **Purpose:** Percentile of recent priority fees used to estimate the bid cost component of miles. Higher values are more conservative (assume higher fees, estimate fewer miles). + +### `quote_guard_divergence_threshold_pct` + +- **Type:** `number` +- **Default:** `25` +- **Used by:** `src/lib/quote-guard.ts` via `/api/config/quote-guard` +- **Purpose:** Maximum allowed percentage divergence between Barter and Uniswap quotes before the guard rejects the quote. Prevents the user from executing a swap where the two pricing sources disagree significantly — protects against stale or manipulated quotes. + +### `quote_guard_treasury_margin_pct` + +- **Type:** `number` +- **Default:** `1.5` +- **Used by:** `src/lib/quote-guard.ts` via `/api/config/quote-guard` +- **Purpose:** Additional margin (%) added to the treasury's side of the quote guard calculation. Accounts for gas costs and executor overhead that the treasury absorbs. Increasing this makes the guard more permissive. + +### `pro_mode_min_usd` + +- **Type:** `number` +- **Default:** `250` +- **Used by:** `src/components/swap/SwapForm.tsx` via `/api/config/pro-threshold` +- **Purpose:** Minimum sell-side USD value required for Pro mode (top 10% block placement) to auto-engage. Swaps below this threshold don't qualify — the backend doesn't enforce this yet, so the frontend gates it. Change this to adjust who gets Pro mode without a deploy. + +## Adding a new key + +1. Set the value in the [Vercel Edge Config dashboard](https://vercel.com/dashboard/stores) +2. Create an API route at `src/app/api/config//route.ts` (use `export const runtime = "edge"` and `get()` from `@vercel/edge-config`) +3. Create a client hook or utility in `src/hooks/` that fetches from the route and falls back to a hardcoded default +4. Use the hook in your component — never read edge config directly from client code + +If the value should update automatically, add a cron job at `src/app/api/cron/update-edge-config//route.ts` and register it in `vercel.json`. diff --git a/src/app/api/config/pro-threshold/route.ts b/src/app/api/config/pro-threshold/route.ts new file mode 100644 index 00000000..30b39f00 --- /dev/null +++ b/src/app/api/config/pro-threshold/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server" +import { get } from "@vercel/edge-config" +import { PRO_MODE_MIN_USD } from "@/lib/pro-mode" + +export const runtime = "edge" + +export async function GET() { + try { + const threshold = await get("pro_mode_min_usd") + + return NextResponse.json( + { + minUsd: + typeof threshold === "number" && threshold > 0 ? threshold : PRO_MODE_MIN_USD, + }, + { headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300" } } + ) + } catch (error) { + console.error("[pro-threshold] Edge Config read failed:", error) + return NextResponse.json( + { minUsd: PRO_MODE_MIN_USD }, + { headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300" } } + ) + } +} diff --git a/src/components/swap/SwapForm.tsx b/src/components/swap/SwapForm.tsx index f70dd918..272a921b 100644 --- a/src/components/swap/SwapForm.tsx +++ b/src/components/swap/SwapForm.tsx @@ -12,8 +12,8 @@ import { useSwapForm } from "@/hooks/use-swap-form" import { useBroadcastGasPrice } from "@/hooks/use-broadcast-gas-price" import { useEstimatedMiles } from "@/hooks/use-estimated-miles" import { FEATURE_FLAGS } from "@/lib/feature-flags" -import { PRO_MODE_MIN_USD } from "@/lib/pro-mode" import { playPreconfirmSound } from "@/lib/preconfirm-sound" +import { useProThreshold } from "@/hooks/use-pro-threshold" import { SwapInterface } from "./SwapInterface" @@ -107,6 +107,7 @@ export function SwapForm() { const [isToSelectorOpen, setIsToSelectorOpen] = useState(false) // ------- Pro Mode (top 10% block placement) ------- + const proMinUsd = useProThreshold() const [proManualOverride, setProManualOverride] = useState(null) const [proJustActivated, setProJustActivated] = useState(false) @@ -117,7 +118,7 @@ export function SwapForm() { : 0 const proEligible = !!isPermitPath && !form.isWrapUnwrap && FEATURE_FLAGS.pro_mode - const proAutoOn = proEligible && sellUsdValue >= PRO_MODE_MIN_USD + const proAutoOn = proEligible && sellUsdValue >= proMinUsd // Reset manual override when crossing the threshold so the auto-trigger // fires fresh each time. @@ -129,7 +130,7 @@ export function SwapForm() { } }, [proAutoOn]) - const meetsThreshold = sellUsdValue >= PRO_MODE_MIN_USD + const meetsThreshold = sellUsdValue >= proMinUsd const isProMode = proEligible && meetsThreshold && diff --git a/src/hooks/use-pro-threshold.ts b/src/hooks/use-pro-threshold.ts new file mode 100644 index 00000000..b6e481b3 --- /dev/null +++ b/src/hooks/use-pro-threshold.ts @@ -0,0 +1,25 @@ +"use client" + +import { useState, useEffect } from "react" +import { PRO_MODE_MIN_USD } from "@/lib/pro-mode" + +/** + * Fetches the Pro mode USD threshold from edge config. + * Falls back to the hardcoded default if the fetch fails. + */ +export function useProThreshold(): number { + const [minUsd, setMinUsd] = useState(PRO_MODE_MIN_USD) + + useEffect(() => { + fetch("/api/config/pro-threshold") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (typeof data?.minUsd === "number" && data.minUsd > 0) { + setMinUsd(data.minUsd) + } + }) + .catch(() => {}) + }, []) + + return minUsd +} From 640a46e67b127208e55cf06b3fedc2cf0508fef0 Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Fri, 17 Apr 2026 13:01:54 -0300 Subject: [PATCH 3/5] fix(pro): round USD value before threshold comparison Token prices from the API can be slightly below $1 (e.g., USDC at $0.9998), causing the sell USD value to land just under the threshold (19.996 < 20). Round to the nearest dollar before comparing. --- src/components/swap/SwapForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/swap/SwapForm.tsx b/src/components/swap/SwapForm.tsx index 272a921b..307a6370 100644 --- a/src/components/swap/SwapForm.tsx +++ b/src/components/swap/SwapForm.tsx @@ -118,7 +118,7 @@ export function SwapForm() { : 0 const proEligible = !!isPermitPath && !form.isWrapUnwrap && FEATURE_FLAGS.pro_mode - const proAutoOn = proEligible && sellUsdValue >= proMinUsd + const proAutoOn = proEligible && Math.round(sellUsdValue) >= proMinUsd // Reset manual override when crossing the threshold so the auto-trigger // fires fresh each time. @@ -130,7 +130,7 @@ export function SwapForm() { } }, [proAutoOn]) - const meetsThreshold = sellUsdValue >= proMinUsd + const meetsThreshold = Math.round(sellUsdValue) >= proMinUsd const isProMode = proEligible && meetsThreshold && From 0b4cd34cee949dad3d40efca961755224df6c16b Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Thu, 30 Apr 2026 18:44:08 -0300 Subject: [PATCH 4/5] fix(pro): use edge-config threshold in Pro tooltip copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "≥ $250 USD" tooltip text was hardcoded; the live threshold already comes from edge config via useProThreshold. Thread proMinUsd through SwapInterface → TransactionSettings so the tooltip reflects the configured value. --- src/components/swap/SwapForm.tsx | 1 + src/components/swap/SwapInterface.tsx | 2 ++ src/components/swap/TransactionSettings.tsx | 5 ++++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/swap/SwapForm.tsx b/src/components/swap/SwapForm.tsx index a7558d45..e17987c3 100644 --- a/src/components/swap/SwapForm.tsx +++ b/src/components/swap/SwapForm.tsx @@ -278,6 +278,7 @@ export function SwapForm() { proEligible={proEligible} proJustActivated={proJustActivated} onTogglePro={handleTogglePro} + proMinUsd={proMinUsd} /> {/* From Token Selector Modal */} diff --git a/src/components/swap/SwapInterface.tsx b/src/components/swap/SwapInterface.tsx index a5ff4bef..401c82da 100644 --- a/src/components/swap/SwapInterface.tsx +++ b/src/components/swap/SwapInterface.tsx @@ -109,6 +109,7 @@ interface SwapInterfaceProps { proEligible: boolean proJustActivated: boolean onTogglePro: () => void + proMinUsd: number } export const SwapInterface: React.FC = (props) => { @@ -203,6 +204,7 @@ export const SwapInterface: React.FC = (props) => { proEligible={props.proEligible} proJustActivated={props.proJustActivated} onTogglePro={props.onTogglePro} + proMinUsd={props.proMinUsd} mode={slippageMode} setMode={setSlippageMode} customMin={slippageCustomMin} diff --git a/src/components/swap/TransactionSettings.tsx b/src/components/swap/TransactionSettings.tsx index 6ac4c59b..4557f499 100644 --- a/src/components/swap/TransactionSettings.tsx +++ b/src/components/swap/TransactionSettings.tsx @@ -21,6 +21,7 @@ interface TransactionSettingsProps { proEligible: boolean proJustActivated: boolean onTogglePro: () => void + proMinUsd: number mode: SlippageMode setMode: (mode: SlippageMode) => void customMin: number @@ -44,6 +45,7 @@ const TransactionSettingsComponent: React.FC = ({ proEligible, proJustActivated, onTogglePro, + proMinUsd, mode, setMode, customMin, @@ -95,7 +97,8 @@ const TransactionSettingsComponent: React.FC = ({ slippage from MEV bots.

- Available for swaps ≥ $250 USD. Smaller trades receive standard block placement. + Available for swaps ≥ ${proMinUsd.toLocaleString()} USD. Smaller trades receive + standard block placement.

Date: Thu, 7 May 2026 16:59:11 -0300 Subject: [PATCH 5/5] =?UTF-8?q?refactor(pro):=20tighten=20Pro=20UX=20?= =?UTF-8?q?=E2=80=94=20drop=20sound,=20redundant=20pill,=20add=20info=20af?= =?UTF-8?q?fordance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove preconfirm sound on Pro auto-engage; keep visual flash. The sound still plays on actual preconfirms (SwapToast). - Drop the middle "Pro" pill on the switch button; the toggle pill at the top and the "Swap (Pro)" CTA already convey active state. - Add an info icon inside the Pro toggle so the tooltip is discoverable. - Restyle the review modal Pro indicator as a matching pill (Zap + Pro). --- .../modals/SwapConfirmationModal.tsx | 8 ++++++- src/components/swap/SwapForm.tsx | 2 -- src/components/swap/SwapInterface.tsx | 2 +- src/components/swap/SwitchButton.tsx | 21 ++----------------- src/components/swap/TransactionSettings.tsx | 7 +++++++ 5 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/components/modals/SwapConfirmationModal.tsx b/src/components/modals/SwapConfirmationModal.tsx index 01a7f1e1..f22643b5 100644 --- a/src/components/modals/SwapConfirmationModal.tsx +++ b/src/components/modals/SwapConfirmationModal.tsx @@ -25,6 +25,7 @@ import { ChevronRight, Copy, Check, + Zap, } from "lucide-react" import type { Token } from "@/types/swap" import { useWethWrapUnwrap } from "@/hooks/use-weth-wrap-unwrap" @@ -915,7 +916,12 @@ function SwapConfirmationModal({ {isProMode && ( Pro — Top 10%} + value={ + + + Pro + + } tooltip={ <> Guarantees your transaction lands in the top 10% of the block, reducing diff --git a/src/components/swap/SwapForm.tsx b/src/components/swap/SwapForm.tsx index e17987c3..947a375b 100644 --- a/src/components/swap/SwapForm.tsx +++ b/src/components/swap/SwapForm.tsx @@ -12,7 +12,6 @@ import { useSwapForm } from "@/hooks/use-swap-form" import { useBroadcastGasPrice } from "@/hooks/use-broadcast-gas-price" import { useEstimatedMiles } from "@/hooks/use-estimated-miles" import { FEATURE_FLAGS } from "@/lib/feature-flags" -import { playPreconfirmSound } from "@/lib/preconfirm-sound" import { useProThreshold } from "@/hooks/use-pro-threshold" import { SwapInterface } from "./SwapInterface" @@ -144,7 +143,6 @@ export function SwapForm() { useEffect(() => { if (isProMode && !prevIsProMode.current && proManualOverride === null) { setProJustActivated(true) - playPreconfirmSound() const t = setTimeout(() => setProJustActivated(false), 1500) return () => clearTimeout(t) } diff --git a/src/components/swap/SwapInterface.tsx b/src/components/swap/SwapInterface.tsx index 401c82da..8e8009fc 100644 --- a/src/components/swap/SwapInterface.tsx +++ b/src/components/swap/SwapInterface.tsx @@ -246,7 +246,7 @@ export const SwapInterface: React.FC = (props) => { />
- +
void - isProMode?: boolean } -export const SwitchButton = ({ handleSwitch, isProMode = false }: SwitchButtonProps) => { - if (isProMode) { - return ( -
- -
- ) - } - +export const SwitchButton = ({ handleSwitch }: SwitchButtonProps) => { return (