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/(app)/layout.tsx b/src/app/(app)/layout.tsx index ced837f9..f53e77a7 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/api/config/pro-threshold/route.ts b/src/app/api/config/pro-threshold/route.ts new file mode 100644 index 00000000..e85c5a92 --- /dev/null +++ b/src/app/api/config/pro-threshold/route.ts @@ -0,0 +1,24 @@ +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/app/globals.css b/src/app/globals.css index 31a0abdc..8b7b4a93 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -267,6 +267,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 180b7f19..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" @@ -99,6 +100,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. */ @@ -134,8 +137,8 @@ function InfoRow({ label, value, tooltip, valueClassName }: InfoRowProps) { - -

{tooltip}

+ +

{tooltip}

@@ -213,6 +216,7 @@ function SwapConfirmationModal({ onApprove, approveTokenSymbol, estimatedMiles: estimatedMilesLive, + isProMode: isProModeLive = false, onRetryWithSlippage, autoExecute = false, onAutoExecuteConsumed, @@ -240,6 +244,7 @@ function SwapConfirmationModal({ fromTokenPrice: number | null | undefined toTokenPrice: number | null | undefined estimatedMiles: number | null | undefined + isProMode: boolean } | null>(null) const wasOpenRef = useRef(open) @@ -263,6 +268,7 @@ function SwapConfirmationModal({ fromTokenPrice: fromTokenPriceLive, toTokenPrice: toTokenPriceLive, estimatedMiles: estimatedMilesLive, + isProMode: isProModeLive, } } else if (!open && wasOpenRef.current) { // Modal just closed — clear snapshot @@ -288,6 +294,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() @@ -329,6 +336,7 @@ function SwapConfirmationModal({ minAmountOut, slippage, deadline, + proMode: isProMode, onSuccess: () => { setClearSwapState(true) if (refreshBalances) { @@ -905,6 +913,38 @@ function SwapConfirmationModal({ : "Estimated gas fee for this transaction" } /> + {isProMode && ( + + + Pro + + } + 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 d4f0b701..7bf23bd1 100644 --- a/src/components/swap/ExchangeRate.tsx +++ b/src/components/swap/ExchangeRate.tsx @@ -160,7 +160,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 de9719e0..947a375b 100644 --- a/src/components/swap/SwapForm.tsx +++ b/src/components/swap/SwapForm.tsx @@ -12,6 +12,7 @@ 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 { useProThreshold } from "@/hooks/use-pro-threshold" import { SwapInterface } from "./SwapInterface" @@ -106,6 +107,57 @@ export function SwapForm() { const [isFromSelectorOpen, setIsFromSelectorOpen] = useState(false) const [isToSelectorOpen, setIsToSelectorOpen] = useState(false) + // ------- Pro Mode (top 10% block placement) ------- + const proMinUsd = useProThreshold() + 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 && Math.round(sellUsdValue) >= proMinUsd + + // 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 = Math.round(sellUsdValue) >= proMinUsd + 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) + 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) @@ -219,6 +271,12 @@ export function SwapForm() { barterUnavailable={form.barterUnavailable} isBarterValidating={form.isBarterValidating} estimatedMiles={estimatedMiles} + // Pro Mode + isProMode={isProMode} + proEligible={proEligible} + proJustActivated={proJustActivated} + onTogglePro={handleTogglePro} + proMinUsd={proMinUsd} /> {/* From Token Selector Modal */} @@ -294,6 +352,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 22025e94..8e8009fc 100644 --- a/src/components/swap/SwapInterface.tsx +++ b/src/components/swap/SwapInterface.tsx @@ -103,6 +103,13 @@ interface SwapInterfaceProps { barterUnavailable: boolean isBarterValidating: boolean estimatedMiles?: number | null + + // Pro Mode + isProMode: boolean + proEligible: boolean + proJustActivated: boolean + onTogglePro: () => void + proMinUsd: number } export const SwapInterface: React.FC = (props) => { @@ -175,6 +182,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. @@ -188,6 +200,11 @@ export const SwapInterface: React.FC = (props) => { internalDeadline={internalDeadline} setInternalDeadline={setInternalDeadline} isMounted={isMounted} + isProMode={props.isProMode} + proEligible={props.proEligible} + proJustActivated={props.proJustActivated} + onTogglePro={props.onTogglePro} + proMinUsd={props.proMinUsd} mode={slippageMode} setMode={setSlippageMode} customMin={slippageCustomMin} @@ -201,6 +218,7 @@ export const SwapInterface: React.FC = (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..8bc5e47b 100644 --- a/src/components/swap/SwitchButton.tsx +++ b/src/components/swap/SwitchButton.tsx @@ -2,7 +2,11 @@ import { ArrowDown } from "lucide-react" -export const SwitchButton = ({ handleSwitch }: { handleSwitch: () => void }) => { +interface SwitchButtonProps { + handleSwitch: () => void +} + +export const SwitchButton = ({ handleSwitch }: SwitchButtonProps) => { return (
- + {isProMode ? "Pro Swap" : "Swap"} - -
-
-
-
- {WARNING_MESSAGE} -
-
-
-
- -
-
-
- - Max slippage - - - - - - - -

- Maximum price movement allowed before transaction reverts. Auto adjusts to - cover routing and gas costs. Min {autoBase}%, max 50%. -

-
-
-
-
- -
-
- - -
+ aria-hidden="true" + /> + + + +

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

+

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

+ + Learn how Pro works → + +
+ + + )} + { + // Commit on close so an empty/invalid value flips back to auto even + // if the input's blur event was pre-empted by Radix unmounting content. + if (!open && mode === "custom") commitSlippage() + setIsSettingsOpen(open) + }} + > + + + + + +
+
+
+
+ + {WARNING_MESSAGE} +
-
-
- Swap deadline - - - - - - +
+
+ + Max slippage + + + + + + + +

+ Maximum price movement allowed before transaction reverts. Auto adjusts to + cover routing and gas costs. Min {autoBase}%, max 50%. +

+
+
+
+
+ +
+
+ + +
+ +
+
+ handleSlippageChange(e.target.value)} + onBlur={() => { + if (mode === "custom") commitSlippage() + }} + className={cn( + "w-14 bg-transparent text-right text-[15px] font-medium outline-none focus:ring-0 text-white", + mode === "auto" ? "cursor-default" : "cursor-text" + )} + /> + % +
+
+
-
- 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-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 +} diff --git a/src/hooks/use-swap-confirmation.ts b/src/hooks/use-swap-confirmation.ts index 502cd39c..93765573 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 { reportClientError } from "@/lib/report-client-error" import type { Token } from "@/types/swap" @@ -21,6 +22,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. */ @@ -42,6 +45,7 @@ export function useSwapConfirmation({ slippage, deadline, onSuccess, + proMode, }: UseSwapConfirmationParams) { const { isConnected, address } = useAccount() const publicClient = usePublicClient({ chainId: mainnet.id }) @@ -243,9 +247,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 f5b1e5b4..6408962a 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 Fast Miles UI: estimated miles on the swap form and confirmation modal, and the miles badge in the app header. Does NOT control the per-swap miles table on the dashboard — use `show_miles_dashboard_table` for that. */ show_miles_estimate: true, + /** When true, enables Pro mode (top 10% block placement) toggle on the swap interface. */ + pro_mode: true, /** Gates the per-swap miles table (UserSwapsTable) on the dashboard in production only. On local dev (NODE_ENV !== "production") and Vercel preview deploys (NEXT_PUBLIC_VERCEL_ENV === "preview") the table is always visible regardless of this flag, so in-progress work can be reviewed without flipping the flag. Production is the sole environment gated by this flag. */ show_miles_dashboard_table: 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. */ 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