Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions docs/edge-config.md
Original file line number Diff line number Diff line change
@@ -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/<name>/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/<name>/route.ts` and register it in `vercel.json`.
2 changes: 1 addition & 1 deletion src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions src/app/api/config/pro-threshold/route.ts
Original file line number Diff line number Diff line change
@@ -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<number>("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" } }
)
}
}
88 changes: 88 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,94 @@ All colors MUST be HSL.
}
}

/* Pro Mode — rotating border highlight on the Pro pill */
@property --pro-border-angle {
syntax: "<angle>";
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%,
Expand Down
44 changes: 42 additions & 2 deletions src/components/modals/SwapConfirmationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -134,8 +137,8 @@ function InfoRow({ label, value, tooltip, valueClassName }: InfoRowProps) {
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-gray-500 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[200px] bg-[#1c2128] border-white/10">
<p className="text-xs text-gray-300">{tooltip}</p>
<TooltipContent side="top" className="max-w-[280px] bg-[#1c2128] border-white/10">
<p className="text-xs text-gray-300 leading-relaxed">{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
Expand Down Expand Up @@ -213,6 +216,7 @@ function SwapConfirmationModal({
onApprove,
approveTokenSymbol,
estimatedMiles: estimatedMilesLive,
isProMode: isProModeLive = false,
onRetryWithSlippage,
autoExecute = false,
onAutoExecuteConsumed,
Expand Down Expand Up @@ -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)

Expand All @@ -263,6 +268,7 @@ function SwapConfirmationModal({
fromTokenPrice: fromTokenPriceLive,
toTokenPrice: toTokenPriceLive,
estimatedMiles: estimatedMilesLive,
isProMode: isProModeLive,
}
} else if (!open && wasOpenRef.current) {
// Modal just closed — clear snapshot
Expand All @@ -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()

Expand Down Expand Up @@ -329,6 +336,7 @@ function SwapConfirmationModal({
minAmountOut,
slippage,
deadline,
proMode: isProMode,
onSuccess: () => {
setClearSwapState(true)
if (refreshBalances) {
Expand Down Expand Up @@ -905,6 +913,38 @@ function SwapConfirmationModal({
: "Estimated gas fee for this transaction"
}
/>
{isProMode && (
<InfoRow
label="Execution"
value={
<span className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-lg bg-primary/10 text-primary text-[13px] font-semibold">
<Zap className="h-3.5 w-3.5 text-primary fill-primary" />
Pro
</span>
}
tooltip={
<>
Guarantees your transaction lands in the top 10% of the block, reducing
reordering slippage from MEV bots.
<br />
<br />
<span className="text-gray-400">
Available for swaps ≥ $250 USD. Smaller trades receive standard block
placement.
</span>
<br />
<a
href="/learn/pro-swaps"
target="_blank"
rel="noreferrer"
className="text-primary hover:text-primary/80 underline underline-offset-2"
>
Learn how Pro works →
</a>
</>
}
/>
)}
{!isWrap && !isUnwrap && estimatedMiles != null && (
<InfoRow
label="Est. miles earned"
Expand Down
4 changes: 3 additions & 1 deletion src/components/swap/ActionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface ActionButtonProps {
isUnwrap: boolean
handleSwapClick: () => void
isNonceLoading?: boolean
isProMode?: boolean
}

const ActionButtonComponent: React.FC<ActionButtonProps> = ({
Expand All @@ -54,6 +55,7 @@ const ActionButtonComponent: React.FC<ActionButtonProps> = ({
isUnwrap,
handleSwapClick,
isNonceLoading = false,
isProMode = false,
}) => {
const { status } = useAccount()
const { isPreApproved, isLoading: isWhitelistLoading } = useGateStatus()
Expand Down Expand Up @@ -263,7 +265,7 @@ const ActionButtonComponent: React.FC<ActionButtonProps> = ({
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"}
</Button>
)}
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/swap/ExchangeRate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ const ExchangeRateComponent: React.FC<ExchangeRateProps> = ({
/>
</div>

{/* RIGHT SECTION: MILES ESTIMATE / PRICE IMPACT */}
{/* RIGHT SECTION: PRO BADGE / MILES ESTIMATE / PRICE IMPACT */}
{!isWrapUnwrap && (
<div className="flex items-center justify-between sm:justify-start gap-2 whitespace-nowrap">
{estimatedMiles != null && (
Expand Down
Loading
Loading