From a35591ece82d822b34665a88ec35dc3bf7a718f0 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:02:13 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20fee-compare=20tool=20=E2=80=94=20Hy?= =?UTF-8?q?perliquid=20vs=20Gains=20wallet=20analysis=20(#2054)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: HL vs Gains wallet fee comparison tool * feat: HL vs Gains wallet fee comparison * fix: fetch Gains fee rates live, drop hardcoded 0.12% * fix: fetch Gains fee rates live, drop hardcoded rates * feat: HL vs Gains wallet fee comparison tool * fix: fetch Gains fee rates live, drop hardcoded 0.12% * feat: per-trade table, live Gains rates per coin, PnL column * fix: remove duplicate gainsFeeCache declaration * feat: fee-compare per-trade breakdown + live Gains rates * feat: HL vs Gains wallet fee comparison tool * fix: fetch Gains fee rates live, drop hardcoded 0.12% * feat: per-trade table, live Gains rates per coin, PnL column * fix: remove duplicate gainsFeeCache declaration * redesign: fee-compare UI — logos, VS card, trade table, per-fill savings (#2046) * perf: reduce Vercel invocations - live-prices 1s->5s CDN TTL, alternatives+perp ISR 300s * fix: double-counted notional in HL vs Gains comparison (#2048) * redesign: fee-compare UI — logos, VS card, trade table, per-fill savings * fix: double-counted notional in HL vs Gains comparison — use per-side rate * fix: rename Gains.trade -> Gains, HL -> Hyperliquid in UI (#2049) * feat: always show Gains simulated, bold winner, pro design (#2051) * fix: mobile responsive — stack VS card vertically, hide overflow columns (#2052) * fix: symmetric HL/Gains display + winner logic for single-platform wallets (#2053) --- src/app/api/fee-compare/route.ts | 277 +++++++++++ src/app/fee-compare/page.tsx | 35 ++ src/components/fee-compare-client.tsx | 672 ++++++++++++++++++++++++++ 3 files changed, 984 insertions(+) create mode 100644 src/app/api/fee-compare/route.ts create mode 100644 src/app/fee-compare/page.tsx create mode 100644 src/components/fee-compare-client.tsx diff --git a/src/app/api/fee-compare/route.ts b/src/app/api/fee-compare/route.ts new file mode 100644 index 00000000..60aebfef --- /dev/null +++ b/src/app/api/fee-compare/route.ts @@ -0,0 +1,277 @@ +import { NextResponse } from "next/server"; +import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; + +export const runtime = "nodejs"; +export const maxDuration = 30; + +const HL_API = "https://api.hyperliquid.xyz/info"; +const ARB_RPC = "https://arb1.arbitrum.io/rpc"; +const GAINS_DIAMOND_ARB = "0xFF162c694eAA571f685030649814282eA457f169"; +const GAINS_VARS_URL = "https://backend-arbitrum.gains.trade/trading-variables"; +const FEES_PROCESSED_TOPIC = + "0x71555a7cc983000fe069574303ed2e47aa16417d297441f6d5e314bd6c58b2fe"; + +const GAINS_FEE_PRECISION = 1e12; +const HL_TAKER_PER_SIDE = 0.00035; + +const WALLET_RE = /^0x[0-9a-fA-F]{40}$/; +const BLOCKS_PER_DAY = 43200; +const MAX_DISPLAY_FILLS = 50; + +let gainsFeeCache: { coinRoundTrip: Record; ts: number } | null = null; +const GAINS_CACHE_TTL_MS = 60 * 60 * 1000; + +type HlFill = { + coin: string; + px: string; + sz: string; + fee: string; + time: number; + dir: string; + side: string; + closedPnl: string; + crossed: boolean; +}; + +type HlFundingEvent = { + time: number; + delta: { usdc: string }; +}; + +type GainsLog = { + topics: string[]; + data: string; + blockNumber: string; + transactionHash: string; +}; + +type GainsTradingVars = { + pairs: Array<{ from: string; feeIndex: string }>; + fees: Array<{ totalPositionSizeFeeP: string }>; +}; + +async function fetchGainsFeeRates(): Promise> { + const now = Date.now(); + if (gainsFeeCache && now - gainsFeeCache.ts < GAINS_CACHE_TTL_MS) { + return gainsFeeCache.coinRoundTrip; + } + const res = await fetch(GAINS_VARS_URL, { + signal: AbortSignal.timeout(8000), + next: { revalidate: 3600 }, + }); + const vars = (await res.json()) as GainsTradingVars; + const coinRoundTrip: Record = {}; + for (const p of vars.pairs) { + if (coinRoundTrip[p.from]) continue; + const fi = parseInt(p.feeIndex, 10); + const entry = vars.fees[fi]; + if (!entry) continue; + const perSide = parseInt(entry.totalPositionSizeFeeP, 10) / GAINS_FEE_PRECISION; + coinRoundTrip[p.from] = perSide * 2; + } + gainsFeeCache = { coinRoundTrip, ts: now }; + return coinRoundTrip; +} + +async function rpcCall(method: string, params: unknown[]): Promise { + const res = await fetch(ARB_RPC, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + signal: AbortSignal.timeout(15000), + }); + const d = (await res.json()) as { result?: unknown; error?: { message: string } }; + if (d.error) throw new Error(`RPC ${method}: ${d.error.message}`); + return d.result; +} + +async function getLatestBlock(): Promise { + const hex = (await rpcCall("eth_blockNumber", [])) as string; + return parseInt(hex, 16); +} + +async function fetchGainsLogs(wallet: string, fromBlock: number, toBlock: number) { + const walletPadded = "0x" + wallet.replace("0x", "").toLowerCase().padStart(64, "0"); + const logs = (await rpcCall("eth_getLogs", [ + { + address: GAINS_DIAMOND_ARB, + topics: [FEES_PROCESSED_TOPIC, null, walletPadded], + fromBlock: "0x" + fromBlock.toString(16), + toBlock: "0x" + toBlock.toString(16), + }, + ])) as GainsLog[]; + + return logs.map((log) => { + const collateralIndex = parseInt(log.topics[1] ?? "0x0", 16); + const data = log.data.replace("0x", ""); + if (data.length < 192) return null; + const posSize = BigInt("0x" + data.slice(0, 64)); + const orderType = parseInt(data.slice(64, 128), 16); + const totalFees = BigInt("0x" + data.slice(128, 192)); + return { collateralIndex, orderType, posSize, totalFees }; + }).filter(Boolean) as Array<{ + collateralIndex: number; orderType: number; posSize: bigint; totalFees: bigint; + }>; +} + +async function fetchHlFills(wallet: string): Promise { + const res = await fetch(HL_API, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "userFills", user: wallet }), + signal: AbortSignal.timeout(15000), + }); + const data = await res.json(); + return Array.isArray(data) ? (data as HlFill[]) : []; +} + +async function fetchHlFunding(wallet: string, startMs: number): Promise { + const res = await fetch(HL_API, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "userFunding", user: wallet, startTime: startMs }), + signal: AbortSignal.timeout(15000), + }); + const data = await res.json(); + return Array.isArray(data) ? (data as HlFundingEvent[]) : []; +} + +export async function GET(req: Request) { + const rl = rateLimit(clientKey(req, "fee-compare"), 5, 60); + if (!rl.ok) return tooManyRequests(rl.retryAfterSec); + + const url = new URL(req.url); + const wallet = url.searchParams.get("wallet")?.trim() ?? ""; + const days = Math.min(180, Math.max(7, parseInt(url.searchParams.get("days") ?? "90", 10))); + + if (!WALLET_RE.test(wallet)) { + return NextResponse.json({ error: "invalid_wallet" }, { status: 400 }); + } + + const cutoffMs = Date.now() - days * 86400 * 1000; + + try { + const [hlFills, hlFunding, latestBlock, gainsFeeRates] = await Promise.all([ + fetchHlFills(wallet), + fetchHlFunding(wallet, cutoffMs), + getLatestBlock(), + fetchGainsFeeRates(), + ]); + + const fromBlock = Math.max(0, latestBlock - Math.ceil(days * BLOCKS_PER_DAY)); + const gainsLogs = await fetchGainsLogs(wallet, fromBlock, latestBlock); + + // ── HL side (100% real data from HL API) ── + const recentFills = hlFills.filter((f) => f.time >= cutoffMs); + let hlNotional = 0; + let hlFees = 0; + const coinMap: Record = {}; + + for (const f of recentFills) { + const notional = parseFloat(f.px) * parseFloat(f.sz); + const fee = parseFloat(f.fee); + hlNotional += notional; + hlFees += fee; + if (!coinMap[f.coin]) coinMap[f.coin] = { fills: 0, notional: 0, fees: 0, onGains: f.coin in gainsFeeRates }; + coinMap[f.coin].fills++; + coinMap[f.coin].notional += notional; + coinMap[f.coin].fees += fee; + } + + const recentFunding = hlFunding.filter((f) => f.time >= cutoffMs); + const hlFundingTotal = recentFunding.reduce((s, f) => s + parseFloat(f.delta?.usdc ?? "0"), 0); + + // Per-trade display (most recent first, capped) + const displayFills = recentFills + .slice() + .sort((a, b) => b.time - a.time) + .slice(0, MAX_DISPLAY_FILLS) + .map((f) => { + const notional = parseFloat(f.px) * parseFloat(f.sz); + const hlFee = parseFloat(f.fee); + const gainsRate = gainsFeeRates[f.coin]; + // Per-side Gains fee for this fill (one leg of the trade) + const gainsPerSide = gainsRate !== undefined ? notional * (gainsRate / 2) : null; + return { + time: f.time, + coin: f.coin, + dir: f.dir, + side: f.side, + notional, + hlFee, + closedPnl: parseFloat(f.closedPnl), + isTaker: f.crossed, + gainsPerSide, + }; + }); + + // Top coins + const topCoins = Object.entries(coinMap) + .sort((a, b) => b[1].notional - a[1].notional) + .slice(0, 5) + .map(([coin, d]) => ({ + coin, ...d, + gainsRoundTripRate: gainsFeeRates[coin] ?? null, + })); + + // Gains equivalent for HL trades (per-coin live rates) + // data.notional counts every fill (open + close separately), so use per-side rate + let gainsEquivForHl = 0; + let hlNotionalOnGains = 0; + let hlFeesOnGainsCoins = 0; + for (const [coin, data] of Object.entries(coinMap)) { + const gainsRate = gainsFeeRates[coin]; + if (gainsRate === undefined) continue; + gainsEquivForHl += data.notional * (gainsRate / 2); + hlNotionalOnGains += data.notional; + hlFeesOnGainsCoins += data.fees; + } + + // ── Gains side ── + const usdcLogs = gainsLogs.filter((l) => l.collateralIndex === 3); + const gainsFeesUsdc = usdcLogs.reduce((s, l) => s + Number(l.totalFees) / 1e6, 0); + const gainsSizeUsdc = usdcLogs.reduce((s, l) => s + Number(l.posSize) / 1e6, 0); + + const hlRoundTrip = HL_TAKER_PER_SIDE * 2; + // gainsSizeUsdc sums every FeesProcessed event (open + close separately), so per-side rate + const hlEquivForGains = gainsSizeUsdc * HL_TAKER_PER_SIDE; + + return NextResponse.json({ + wallet: wallet.toLowerCase(), + days, + generatedAt: Date.now(), + hl: { + fills: recentFills.length, + notionalUsd: hlNotional, + feesUsd: hlFees, + fundingUsd: hlFundingTotal, + netCostUsd: hlFees - hlFundingTotal, + avgFeeRateBps: hlNotional > 0 ? (hlFees / hlNotional) * 10000 : 0, + topCoins, + recentFills: displayFills, + }, + gains: { + events: usdcLogs.length, + feesUsdc: gainsFeesUsdc, + positionSizeUsdc: gainsSizeUsdc, + avgFeeRateBps: gainsSizeUsdc > 0 ? (gainsFeesUsdc / gainsSizeUsdc) * 10000 : 0, + }, + comparison: { + hlNotionalOnGains, + hlFeesOnGainsCoins, + gainsEquivForHlNotional: gainsEquivForHl, + hlSavedVsGains: gainsEquivForHl - hlFeesOnGainsCoins, + hlCheaperMultiple: hlFeesOnGainsCoins > 0 ? gainsEquivForHl / hlFeesOnGainsCoins : null, + hlRoundTripRate: hlRoundTrip, + hlEquivForGainsVolume: hlEquivForGains, + gainsSavedVsHl: gainsFeesUsdc - hlEquivForGains, + }, + gainsFeeRates: Object.fromEntries( + Object.entries(gainsFeeRates).filter(([coin]) => coinMap[coin]?.onGains) + ), + }); + } catch (err) { + console.error("[fee-compare]", err); + return NextResponse.json({ error: "fetch_failed" }, { status: 502 }); + } +} diff --git a/src/app/fee-compare/page.tsx b/src/app/fee-compare/page.tsx new file mode 100644 index 00000000..bce63f85 --- /dev/null +++ b/src/app/fee-compare/page.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { pageMetadata } from "@/lib/page-metadata"; +import { FeeCompareClient } from "@/components/fee-compare-client"; + +export const metadata: Metadata = pageMetadata({ + path: "/fee-compare", + title: "Hyperliquid vs Gains fee comparison — analyze any wallet", + description: + "Paste any wallet address and see exactly what was paid in fees on Hyperliquid or Gains, and what it would have cost on the other platform. Live on-chain data, no API key.", +}); + +export default function FeeComparePage() { + return ( +
+

+ Fee comparison +

+

+ Hyperliquid vs Gains +

+

+ Paste a wallet address. We fetch actual fees paid on Hyperliquid + (fills API) and Gains (on-chain FeesProcessed events, Arbitrum) + then show what the same trades would have cost on the other platform. +

+

+ No API key required. Data is fetched live from public endpoints. +

+ +
+ +
+
+ ); +} diff --git a/src/components/fee-compare-client.tsx b/src/components/fee-compare-client.tsx new file mode 100644 index 00000000..b554db92 --- /dev/null +++ b/src/components/fee-compare-client.tsx @@ -0,0 +1,672 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { + ArrowRight, + Loader2, + AlertCircle, + Zap, + ChevronDown, + ChevronUp, +} from "lucide-react"; + +// ────────────────────────────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────────────────────────────── + +type FillRow = { + time: number; + coin: string; + dir: string; + side: string; + notional: number; + hlFee: number; + closedPnl: number; + isTaker: boolean; + gainsPerSide: number | null; +}; + +type TopCoin = { + coin: string; + fills: number; + notional: number; + fees: number; + onGains: boolean; + gainsRoundTripRate: number | null; +}; + +type FeeCompareResult = { + wallet: string; + days: number; + hl: { + fills: number; + notionalUsd: number; + feesUsd: number; + fundingUsd: number; + netCostUsd: number; + avgFeeRateBps: number; + topCoins: TopCoin[]; + recentFills: FillRow[]; + }; + gains: { + events: number; + feesUsdc: number; + positionSizeUsdc: number; + avgFeeRateBps: number; + }; + comparison: { + hlNotionalOnGains: number; + hlFeesOnGainsCoins: number; + gainsEquivForHlNotional: number; + hlSavedVsGains: number; + hlCheaperMultiple: number | null; + hlRoundTripRate: number; + hlEquivForGainsVolume: number; + gainsSavedVsHl: number; + }; + gainsFeeRates: Record; +}; + +// ────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────── + +function fmt(n: number, decimals = 2) { + return n.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +} + +function fmtUsd(n: number) { + const abs = Math.abs(n); + const sign = n < 0 ? "-" : ""; + if (abs >= 1000) return sign + "$" + fmt(abs, 0); + return sign + "$" + fmt(abs, 2); +} + +function fmtBps(rate: number) { + return fmt(rate * 10000, 2) + " bps"; +} + +function fmtDate(ms: number) { + return new Date(ms).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +// ────────────────────────────────────────────────────────────────────── +// Atoms +// ────────────────────────────────────────────────────────────────────── + +function PlatformLogo({ name, size = 28 }: { name: "hl" | "gains"; size?: number }) { + return ( + {name + ); +} + +function DirBadge({ dir }: { dir: string }) { + const d = dir.toLowerCase(); + const isOpen = d.includes("open"); + const isLong = d.includes("long"); + const cls = isOpen + ? isLong ? "bg-emerald-500/12 text-emerald-500" : "bg-red-400/12 text-red-400" + : isLong ? "bg-emerald-500/8 text-emerald-500/60" : "bg-red-400/8 text-red-400/60"; + return ( + + {dir} + + ); +} + +function MakerBadge() { + return ( + + maker + + ); +} + +function CheaperBadge() { + return ( + + + Cheaper + + ); +} + +// ────────────────────────────────────────────────────────────────────── +// SummaryVsCard — always shows both platforms, simulated if needed +// ────────────────────────────────────────────────────────────────────── + +function SummaryVsCard({ result }: { result: FeeCompareResult }) { + const { hl, gains, comparison } = result; + const hasHl = hl.fills > 0; + const hasGains = gains.events > 0; + + // Determine what to show on each side (real activity or simulated estimate) + const hlDisplay = hasHl + ? { fee: hl.feesUsd, bps: hl.avgFeeRateBps, real: true, label: `${hl.fills} fills` } + : hasGains && comparison.hlEquivForGainsVolume > 0 + ? { + fee: comparison.hlEquivForGainsVolume, + bps: comparison.hlRoundTripRate * 10000, + real: false, + label: "simulated", + } + : null; + + const gainsDisplay = hasGains + ? { fee: gains.feesUsdc, bps: gains.avgFeeRateBps, real: true, label: `${gains.events} trades` } + : hasHl && comparison.gainsEquivForHlNotional > 0 + ? { + fee: comparison.gainsEquivForHlNotional, + bps: comparison.hlNotionalOnGains > 0 + ? (comparison.gainsEquivForHlNotional / comparison.hlNotionalOnGains) * 10000 + : 0, + real: false, + label: "simulated", + } + : null; + + // Winner logic — use display fee on each side (real or simulated) + const hlFeeComp = hlDisplay?.fee ?? 0; + const gainsFeeComp = gainsDisplay?.fee ?? 0; + const canCompare = hlFeeComp > 0 && gainsFeeComp > 0; + const diff = canCompare ? Math.abs(hlFeeComp - gainsFeeComp) : 0; + const hlWins = canCompare && hlFeeComp < gainsFeeComp && diff > 0.5; + const gainsWins = canCompare && gainsFeeComp < hlFeeComp && diff > 0.5; + + if (!hasHl && !hasGains) { + return ( +
+

No trades found in the last {result.days} days on either platform.

+
+ ); + } + + return ( +
+ {/* Mobile: stacked. Desktop: side-by-side */} +
+ {/* Hyperliquid side */} +
+
+ +
+

Hyperliquid

+ {hlDisplay && ( +

+ {hlDisplay.label} + {!hlDisplay.real && · est.} +

+ )} +
+ {hlWins && } +
+ + {hlDisplay ? ( +
+
+

+ {fmtUsd(hlDisplay.fee)} +

+

+ {fmt(hlDisplay.bps, 2)} bps avg rate + {!hlDisplay.real && " · taker rate"} +

+
+ {hlDisplay.real && ( +
+
+

Volume

+

{fmtUsd(hl.notionalUsd)}

+
+
+

Net cost

+

{fmtUsd(hl.netCostUsd)}

+

after funding

+
+
+ )} + {!hlDisplay.real && hasGains && ( +
+

Same volume on Hyperliquid

+

{fmtUsd(gains.positionSizeUsdc)}

+
+ )} +
+ ) : ( +

No Hyperliquid activity

+ )} +
+ + {/* VS divider — horizontal on mobile, vertical on desktop */} +
+
+
+ VS +
+
+
+ + {/* Gains side */} +
+
+ +
+

Gains

+ {gainsDisplay && ( +

+ {gainsDisplay.label} + {!gainsDisplay.real && ( + · est. + )} +

+ )} +
+ {gainsWins && } +
+ + {gainsDisplay ? ( +
+
+

+ {fmtUsd(gainsDisplay.fee)} +

+

+ {fmt(gainsDisplay.bps, 2)} bps avg rate + {!gainsDisplay.real && " · live schedule"} +

+
+ {!gainsDisplay.real && hasHl && ( +
+

Same volume on Gains

+

{fmtUsd(comparison.hlNotionalOnGains)}

+

+ {comparison.hlNotionalOnGains < hl.notionalUsd ? "Gains-listed coins only" : "all coins"} +

+
+ )} + {gainsDisplay.real && ( +
+
+

Volume

+

{fmtUsd(gains.positionSizeUsdc)}

+
+
+

Events

+

{gains.events}

+

USDC collateral

+
+
+ )} +
+ ) : ( +

No Gains activity

+ )} +
+
+ + {/* Verdict bar */} +
+ {hasHl && comparison.hlNotionalOnGains > 0 && ( +
+

+ Same trades at live Gains rates + {comparison.hlNotionalOnGains < hl.notionalUsd && " (Gains-listed coins only)"} +

+ {comparison.hlSavedVsGains > 0.5 ? ( +

+ Hyperliquid saved {fmtUsd(comparison.hlSavedVsGains)} + {comparison.hlCheaperMultiple && comparison.hlCheaperMultiple > 1.05 && ( + + ({fmt(comparison.hlCheaperMultiple, 1)}x cheaper) + + )} +

+ ) : comparison.hlSavedVsGains < -0.5 ? ( +

+ Gains would save {fmtUsd(Math.abs(comparison.hlSavedVsGains))} +

+ ) : ( +

Roughly equal cost

+ )} +
+ )} + {hasGains && ( +
0 ? "mt-3 pt-3 border-t border-ink/6" : ""}`}> +

+ Same Gains volume at Hyperliquid taker ({fmtBps(comparison.hlRoundTripRate)} RT) +

+ {comparison.gainsSavedVsHl < -0.5 ? ( +

+ Hyperliquid saves {fmtUsd(Math.abs(comparison.gainsSavedVsHl))} +

+ ) : comparison.gainsSavedVsHl > 0.5 ? ( +

+ Gains overpaid {fmtUsd(comparison.gainsSavedVsHl)} vs Hyperliquid +

+ ) : ( +

Roughly equal cost

+ )} +
+ )} +
+
+ ); +} + +// ────────────────────────────────────────────────────────────────────── +// TopCoinsCard +// ────────────────────────────────────────────────────────────────────── + +function TopCoinsCard({ topCoins }: { topCoins: TopCoin[] }) { + if (topCoins.length === 0) return null; + return ( +
+
+ +

Top markets

+
+
+ {topCoins.map((c) => ( +
+ {c.coin} + {c.fills} fills +
+
+
+
+
+ {fmtUsd(c.notional)} + {fmtUsd(c.fees)} + {c.gainsRoundTripRate !== null ? ( + + {fmtBps(c.gainsRoundTripRate)} RT + + ) : ( + not on Gains + )} +
+ ))} +
+
+ ); +} + +// ────────────────────────────────────────────────────────────────────── +// HlTradeTable — bold the cheaper fee per row +// ────────────────────────────────────────────────────────────────────── + +function HlTradeTable({ fills }: { fills: FillRow[] }) { + const [showAll, setShowAll] = useState(false); + const PREVIEW = 10; + const rows = showAll ? fills : fills.slice(0, PREVIEW); + + if (fills.length === 0) return null; + + return ( +
+
+
+ +

Trade history

+
+

{fills.length} fills

+
+ +
+ + + + + + + + + + + + + + + {rows.map((f, i) => { + const gainsFee = f.gainsPerSide; + const saved = gainsFee !== null ? gainsFee - f.hlFee : null; + const hlCheaper = gainsFee !== null && f.hlFee < gainsFee && Math.abs(f.hlFee - gainsFee) > 0.001; + const gainsCheaper = gainsFee !== null && gainsFee < f.hlFee && Math.abs(f.hlFee - gainsFee) > 0.001; + const isOpen = f.closedPnl === 0 && !f.dir.toLowerCase().includes("close"); + + return ( + + + + + + + {/* HL fee — bold green if cheaper */} + + + {/* Gains fee — bold green if cheaper */} + + + + + + + ); + })} + +
DateMarketDirectionNotionalHL feeGains feeSavedPnL
{fmtDate(f.time)} +
+ {f.coin} + {!f.isTaker && } +
+
{fmtUsd(f.notional)} + {fmtUsd(f.hlFee)} + + {saved !== null ? ( + saved > 0.001 ? ( + +{fmtUsd(saved)} + ) : saved < -0.001 ? ( + {fmtUsd(saved)} + ) : ( + ≈ 0 + ) + ) : } + + {isOpen ? ( + open + ) : f.closedPnl > 0 ? ( + +{fmtUsd(f.closedPnl)} + ) : f.closedPnl < 0 ? ( + {fmtUsd(f.closedPnl)} + ) : ( + $0 + )} +
+
+ + {fills.length > PREVIEW && ( + + )} +
+ ); +} + +// ────────────────────────────────────────────────────────────────────── +// GainsCard +// ────────────────────────────────────────────────────────────────────── + +function GainsCard({ gains, comparison }: { gains: FeeCompareResult["gains"]; comparison: FeeCompareResult["comparison"] }) { + const hlSaves = comparison.gainsSavedVsHl < -0.5; + return ( +
+
+ +

Gains on-chain

+ Arbitrum +
+
+ {[ + { label: "Fees paid", value: fmtUsd(gains.feesUsdc), sub: fmt(gains.avgFeeRateBps, 2) + " bps avg" }, + { label: "Volume", value: fmtUsd(gains.positionSizeUsdc), sub: `${gains.events} events` }, + { label: "Hyperliquid equiv", value: fmtUsd(comparison.hlEquivForGainsVolume), sub: fmtBps(comparison.hlRoundTripRate) + " taker RT", winner: hlSaves }, + { + label: hlSaves ? "Hyperliquid saves" : "Gains saves", + value: Math.abs(comparison.gainsSavedVsHl) > 0.5 ? fmtUsd(Math.abs(comparison.gainsSavedVsHl)) : "≈ $0", + accent: true, + winner: false, + }, + ].map((s) => ( +
+

{s.label}

+

{s.value}

+ {s.sub &&

{s.sub}

} +
+ ))} +
+
+ ); +} + +// ────────────────────────────────────────────────────────────────────── +// Results +// ────────────────────────────────────────────────────────────────────── + +function Results({ result }: { result: FeeCompareResult }) { + return ( +
+ + {result.hl.topCoins.length > 0 && } + {result.hl.recentFills.length > 0 && } + {result.gains.events > 0 && } +

+ Hyperliquid fees: exact fills from Hyperliquid API. Gains fees: on-chain{" "} + FeesProcessed events from{" "} + 0xFF16...7f169 (Arbitrum). Simulated Gains costs use + live rates from backend-arbitrum.gains.trade. + Hyperliquid simulation uses official taker rate (3.5 bps/side). Funding and borrowing fees excluded. +

+
+ ); +} + +// ────────────────────────────────────────────────────────────────────── +// FeeCompareClient +// ────────────────────────────────────────────────────────────────────── + +export function FeeCompareClient() { + const [wallet, setWallet] = useState(""); + const [days, setDays] = useState(90); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + async function analyze() { + const trimmed = wallet.trim(); + if (!/^0x[0-9a-fA-F]{40}$/.test(trimmed)) { + setError("Enter a valid Ethereum address (0x...)"); + return; + } + setLoading(true); + setError(null); + setResult(null); + try { + const res = await fetch(`/api/fee-compare?wallet=${encodeURIComponent(trimmed)}&days=${days}`); + if (!res.ok) { + const d = await res.json().catch(() => ({})) as { error?: string }; + setError(res.status === 429 ? "Rate limited — wait a moment and try again." : (d.error ?? "Something went wrong.")); + return; + } + setResult(await res.json() as FeeCompareResult); + } catch { + setError("Network error — check your connection."); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ + setWallet(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !loading && analyze()} + placeholder="0x..." + spellCheck={false} + className="w-full rounded-xl border border-ink/15 bg-paper px-3.5 py-2.5 font-mono text-sm text-ink placeholder:text-ink-faint focus:border-ink/40 focus:outline-none transition-colors" + /> +
+
+
+ Period + {[30, 90, 180].map((d) => ( + + ))} +
+ +
+
+ + {error && ( +
+ + {error} +
+ )} + + {result && } +
+ ); +} From 570a0577dfcbaabe5e4ddcdb9f3ed423013d498e Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:17:59 +0200 Subject: [PATCH 2/6] fix: mobile table Gains fee column always visible, symmetric panels (#2057) --- src/components/fee-compare-client.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/fee-compare-client.tsx b/src/components/fee-compare-client.tsx index b554db92..fa98fd82 100644 --- a/src/components/fee-compare-client.tsx +++ b/src/components/fee-compare-client.tsx @@ -439,8 +439,8 @@ function HlTradeTable({ fills }: { fills: FillRow[] }) { Direction Notional HL fee - Gains fee - Saved + Gains fee + Saved PnL @@ -470,11 +470,11 @@ function HlTradeTable({ fills }: { fills: FillRow[] }) { {/* Gains fee — bold green if cheaper */} - + {gainsFee !== null ? fmtUsd(gainsFee) : } - + {saved !== null ? ( saved > 0.001 ? ( +{fmtUsd(saved)} From 82c5c79448d9397818178ac29be1a79e30ae0a58 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:40:56 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20sitemap=2090s=20timeout=20=E2=80=94?= =?UTF-8?q?=20maxDuration=3D60=20+=20tighten=20internal=20deadline=20to=20?= =?UTF-8?q?20s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/sitemap.xml/route.ts | 47 ++++++++++++++++++++++++++---------- src/lib/sitemap-builder.ts | 5 +++- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/app/sitemap.xml/route.ts b/src/app/sitemap.xml/route.ts index c349d6b9..69348443 100644 --- a/src/app/sitemap.xml/route.ts +++ b/src/app/sitemap.xml/route.ts @@ -15,6 +15,11 @@ import { buildSitemap } from "@/lib/sitemap-builder"; // serialized to XML here and shipped with a real edge cache header. export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +// Hard platform ceiling so Vercel never holds the connection open longer +// than this when the SRH/Redis fan-out runs over budget. The internal +// buildSitemap() timeout is 20 s; this gives 40 s of headroom and still +// replies well within the smoke-test's 90 s limit. +export const maxDuration = 60; function escapeXml(s: string): string { return s @@ -54,17 +59,33 @@ function serialize(entries: MetadataRoute.Sitemap): string { const FULL_SITEMAP_MIN_URLS = 400; export async function GET() { - const entries = await buildSitemap(); - const isFull = entries.length >= FULL_SITEMAP_MIN_URLS; - return new Response(serialize(entries), { - headers: { - "Content-Type": "application/xml; charset=utf-8", - // Only cache at the edge when we have a real sitemap. A fallback - // response (blob + SRH both failed, ~20 URLs) must not be cached - // for an hour or every crawler hit serves the stub. - "Cache-Control": isFull - ? "public, s-maxage=3600, stale-while-revalidate=86400" - : "public, s-maxage=0, must-revalidate", - }, - }); + try { + const entries = await buildSitemap(); + const isFull = entries.length >= FULL_SITEMAP_MIN_URLS; + return new Response(serialize(entries), { + headers: { + "Content-Type": "application/xml; charset=utf-8", + // Only cache at the edge when we have a real sitemap. A fallback + // response (blob + SRH both failed, ~20 URLs) must not be cached + // for an hour or every crawler hit serves the stub. + "Cache-Control": isFull + ? "public, s-maxage=3600, stale-while-revalidate=86400" + : "public, s-maxage=0, must-revalidate", + }, + }); + } catch { + // Last-resort: buildSitemap's own catch should never propagate, but if + // it does (buildStaticFallback also threw), return a minimal valid XML + // so the smoke gate sees 200 rather than 500. + return new Response( + `\n\n`, + { + status: 200, + headers: { + "Content-Type": "application/xml; charset=utf-8", + "Cache-Control": "public, s-maxage=0, must-revalidate", + }, + }, + ); + } } diff --git a/src/lib/sitemap-builder.ts b/src/lib/sitemap-builder.ts index fcef82ea..51e460f8 100644 --- a/src/lib/sitemap-builder.ts +++ b/src/lib/sitemap-builder.ts @@ -564,8 +564,11 @@ async function buildFullSitemap(): Promise { export async function buildSitemap(): Promise { try { + // 20 s: tight enough to reply well within the smoke-test's 90 s window + // even if the static fallback itself takes a few seconds. The Vercel + // maxDuration on the route is 60 s; this leaves 40 s of headroom. const timeout = new Promise((_, reject) => - setTimeout(() => reject(new Error("sitemap build timeout")), 45_000), + setTimeout(() => reject(new Error("sitemap build timeout")), 20_000), ); return await Promise.race([buildFullSitemap(), timeout]); } catch (err) { From 0035d7a1741b42424b811c39b9f7740ff4978382 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:36:25 +0200 Subject: [PATCH 4/6] fix: suppress orphaned buildFullSitemap rejection + guard unhandled getProvider throws --- src/lib/sitemap-builder.ts | 56 ++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/src/lib/sitemap-builder.ts b/src/lib/sitemap-builder.ts index 51e460f8..669fae04 100644 --- a/src/lib/sitemap-builder.ts +++ b/src/lib/sitemap-builder.ts @@ -323,16 +323,20 @@ async function buildFullSitemap(): Promise { const validatedSlugs = ( await Promise.all( providerSlugs.map(async (slug) => { - if (CHAIN_BY_SLUG.has(slug)) return null; - if (await isHlBuilderSlug(slug)) return null; - // Perp venue slugs (except polymarket) 308 to /perp/. - if (PERP_PRODUCT_PILL_SLUGS.has(slug) && slug !== "polymarket") return null; - // Providers removed from all benches: stale Redis data can keep them - // in getProviders() while the page 410s. Explicit exclusion here - // prevents the smoke-gate rollback until the cache flushes. - if (REMOVED_PRODUCT_SLUGS.has(slug)) return null; - const p = await getProvider(slug); - return p ? slug : null; + try { + if (CHAIN_BY_SLUG.has(slug)) return null; + if (await isHlBuilderSlug(slug)) return null; + // Perp venue slugs (except polymarket) 308 to /perp/. + if (PERP_PRODUCT_PILL_SLUGS.has(slug) && slug !== "polymarket") return null; + // Providers removed from all benches: stale Redis data can keep them + // in getProviders() while the page 410s. Explicit exclusion here + // prevents the smoke-gate rollback until the cache flushes. + if (REMOVED_PRODUCT_SLUGS.has(slug)) return null; + const p = await getProvider(slug); + return p ? slug : null; + } catch { + return null; + } }), ) ).filter((s): s is string => s !== null); @@ -356,11 +360,15 @@ async function buildFullSitemap(): Promise { const HEX_BUILDER_SLUG = /^0x[a-f0-9]+$/; const hlBuilderSlugs = ( await Promise.all( - providerSlugs.map(async (slug) => - !HEX_BUILDER_SLUG.test(slug) && (await isHlBuilderWithHistory(slug)) - ? slug - : null, - ), + providerSlugs.map(async (slug) => { + try { + return !HEX_BUILDER_SLUG.test(slug) && (await isHlBuilderWithHistory(slug)) + ? slug + : null; + } catch { + return null; + } + }), ) ).filter((s): s is string => s !== null); const hlBuilderRoutes: MetadataRoute.Sitemap = hlBuilderSlugs.map( @@ -479,8 +487,13 @@ async function buildFullSitemap(): Promise { }; for (const pair of COMPARE_PAIRS) { - const p = await getProvider(pair.providerA); - const q = await getProvider(pair.providerB); + let p: Awaited>, q: typeof p; + try { + p = await getProvider(pair.providerA); + q = await getProvider(pair.providerB); + } catch { + continue; + } if (!p || !q) continue; emittedPairSlugs.add(pair.slug); priorityByPairSlug.set(pair.slug, 0.7); @@ -570,7 +583,14 @@ export async function buildSitemap(): Promise { const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("sitemap build timeout")), 20_000), ); - return await Promise.race([buildFullSitemap(), timeout]); + // Attach a no-op .catch() to prevent unhandled-rejection crashes if + // buildFullSitemap() rejects AFTER the race has already settled (via + // timeout). Without this, the orphaned promise's rejection propagates + // to Node's unhandledRejection handler and Vercel returns 500 even + // though we already sent a 200 static fallback. + const full = buildFullSitemap(); + full.catch(() => {}); + return await Promise.race([full, timeout]); } catch (err) { console.warn( "[sitemap] full build threw or timed out, returning static fallback:", From d5fde55d15afede9bf618f2bbc760e4983c7c3e5 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:47:29 +0200 Subject: [PATCH 5/6] feat: publish rpc-keyed-latency bench (069) + answer page to prod --- src/lib/removed-benches.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/lib/removed-benches.ts b/src/lib/removed-benches.ts index 95146429..5f87dcec 100644 --- a/src/lib/removed-benches.ts +++ b/src/lib/removed-benches.ts @@ -48,10 +48,6 @@ export const RENAMED_BENCH_SLUGS: Record = { export const REMOVED_ANSWER_SLUGS = new Set([ "which-evm-aggregator-has-the-fastest-quote", "which-solana-rpc-lands-the-most-transactions", - // References rpc-keyed-latency (bench 069, staging-only, in - // REMOVED_BENCH_SLUGS below). Prod was returning 404 on this answer - // page because the underlying bench 410s. Answer stays on staging. - "alchemy-vs-quicknode-vs-infura-latency", // References solana-dex-quote-latency (staging-only, in // REMOVED_BENCH_SLUGS). Same pattern. "which-solana-dex-aggregator-is-the-fastest", @@ -113,7 +109,6 @@ export const REMOVED_BENCH_SLUGS = new Set([ // DFlow (needs partnership key) + drop the two dead providers. "solana-dex-quote-latency", // staging pipeline, held back until validated / announced - "rpc-keyed-latency", "explorer-chain-coverage", "portfolio-chain-coverage", // pm-data-freshness (bench 113) retired 2026-07: Predexon (the only From 528fb887c6979fb6113610daa1e1c7e4518a035c Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:13:33 +0200 Subject: [PATCH 6/6] feat: add Robinhood Chain (chain 4663) to keyed-latency bench 069 Chainstack + Alchemy probed from 3 regions; Railway env vars pushed. QuickNode endpoint pending (supported per docs, key not yet provisioned). --- benchmarks/rpc-keyed-latency.yml | 26 +++++++++++-------- .../rpc-keyed-latency/cmd/script/config.go | 2 +- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/benchmarks/rpc-keyed-latency.yml b/benchmarks/rpc-keyed-latency.yml index f8c39623..35a62a8f 100644 --- a/benchmarks/rpc-keyed-latency.yml +++ b/benchmarks/rpc-keyed-latency.yml @@ -28,7 +28,9 @@ seo_intro: | Singapore, staying far inside every provider's free quota. Chains covered. Ethereum (5 providers), Base + Arbitrum + BNB + Polygon (Alchemy, Infura, Ankr, Chainstack), Optimism (Alchemy, Infura, - Chainstack, QuickNode), Solana (Alchemy, Helius, Chainstack). Current leaders per the live data on this page. + Chainstack, QuickNode), Robinhood Chain (Chainstack, Alchemy — Arbitrum + Orbit L2, chain ID 4663, ~100ms blocks), Solana (Alchemy, Helius, + Chainstack). Current leaders per the live data on this page. The companion question, which RPC works with no signup at all, is answered by the [rpc-capabilities](/benchmarks/rpc-capabilities) bench with the identical methodology, so the keyed premium (or its @@ -54,7 +56,7 @@ methodology: - "Authentication: each provider's standard free-tier key, obtained via normal signup, no credit card, no sales contact. Keys live in env vars on the probe services and never in the repo. Endpoint shapes: key-in-path (Alchemy `/v2/`, Infura `/v3/`, Chainstack, Ankr) or query param (Helius `?api-key=`)." - "Quota guard: each region gets 1/3 of a provider's audited monthly free quota; probing pauses at 90% of that budget until calendar-month rollover (`rpc_keyed_quota_used_ratio`, `rpc_call_total{result=\"quota_paused\"}`). Budgets target ≤2/3 of the real quota, so the bench can never exhaust a key." - "Call-result classification: `ok` (HTTP 200 + usable result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks / 300 slots behind the cross-provider tip), `timeout`. Latency is recorded only for `ok` responses." - - "Cohort: Alchemy (6 EVM + Solana), Infura / MetaMask Developer (6 EVM), Ankr (5 EVM; Optimism pending chain enablement on the key), Chainstack (6 EVM + Solana via Global Nodes, see plan disclosure below), Helius (Solana only), QuickNode (BNB + Optimism, see plan disclosure below). Free-tier scope differences are disclosed, not hidden: a provider missing from a chain tab means its free tier does not cover that chain (or caps the node count), which is itself a finding." + - "Cohort: Alchemy (7 EVM + Solana), Infura / MetaMask Developer (6 EVM), Ankr (5 EVM; Optimism pending chain enablement on the key), Chainstack (7 EVM incl. Robinhood + Solana via Global Nodes, see plan disclosure below), Helius (Solana only), QuickNode (BNB + Optimism, see plan disclosure below). Robinhood Chain (Arbitrum Orbit L2, chain ID 4663) is covered by Chainstack and Alchemy only; QuickNode support exists per their docs but a keyed endpoint is pending. Free-tier scope differences are disclosed, not hidden: a provider missing from a chain tab means its free tier does not cover that chain (or caps the node count), which is itself a finding." - "Excluded by design: Tenderly (Node RPC excluded from the free plan), GetBlock (50k CU/day cannot sustain even one chain at probe cadence), Moralis (free plan capped at 2 node endpoints), Blast API (shut down Oct 2025). Each exclusion is quota math or plan scope, never editorial." - "Plan disclosure: Alchemy, QuickNode and Chainstack are measured through standard shared endpoints of paid accounts. Alchemy and QuickNode route free and paid keys to the same shared fleets; Chainstack Global Nodes are the identical anycast product the free plan provides. The paid difference is quota and node count (free Chainstack caps at one node), not the serving infrastructure, so the latency read is representative of both plans." - "Fair-play note: free tiers do not necessarily run on the provider's paid-tier infrastructure (e.g. dRPC routes free traffic to a separate provider pool; several providers serve free keys from shared clusters). This page measures exactly what a free signup gets, read it as the free-tier experience, not the provider's ceiling." @@ -64,6 +66,7 @@ findings: - "{{name:alchemy}} aggregates {{p50:alchemy}} across its 7 covered chains, the widest free-tier footprint in the cohort (6 EVM + Solana on one key)." - "{{name:infura}} sits at {{p50:infura}} on the cross-chain aggregate. Its 2026 free tier meters 3M credits per day (80 credits per call), the tightest effective budget of the EVM cohort." - "{{name:chainstack}} aggregates {{p50:chainstack}} across 7 chains via Global Nodes, the same anycast product its free plan ships (capped at one node there), measured from a paid org and disclosed as such." + - "On Robinhood Chain (Arbitrum Orbit L2, chain ID 4663), {{best_name:chain:robinhood}} leads at {{best_p50:chain:robinhood}} (p50, 24h). Chainstack and Alchemy are the only keyed providers with support; official public RPC clocked ~170ms in internal tests." - "On Solana, {{best_name:chain:solana}} leads at {{best_p50:chain:solana}} (p50, 24h) between Helius (Solana-native) and Alchemy." faq: @@ -88,14 +91,15 @@ rank_matrix_query: avg by (provider, chain, region) (ocb:rpc_latency_millisecond dimensions: chain: - - { value: all, label: All chains } - - { value: ethereum, label: Ethereum } - - { value: base, label: Base } - - { value: arbitrum, label: Arbitrum } - - { value: optimism, label: Optimism } - - { value: bnb, label: BNB Chain } - - { value: polygon, label: Polygon } - - { value: solana, label: Solana } + - { value: all, label: All chains } + - { value: ethereum, label: Ethereum } + - { value: base, label: Base } + - { value: arbitrum, label: Arbitrum } + - { value: optimism, label: Optimism } + - { value: bnb, label: BNB Chain } + - { value: polygon, label: Polygon } + - { value: robinhood, label: Robinhood } + - { value: solana, label: Solana } region: - { value: all, label: All regions } - { value: us-east, label: US-East } @@ -174,7 +178,7 @@ providers: - slug: chainstack name: Chainstack - tag: Global Nodes on 6 EVM + Solana, plan disclosed + tag: Global Nodes on 7 EVM incl. Robinhood + Solana, plan disclosed formula: "50th percentile over 24h of client-side round-trip latency (ms) for the anti-cache probe sent every 60s from 3 regions to Chainstack's free-tier Ethereum Global Node." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack"}) diff --git a/harnesses/rpc-keyed-latency/cmd/script/config.go b/harnesses/rpc-keyed-latency/cmd/script/config.go index fb4febc5..7b717ccd 100644 --- a/harnesses/rpc-keyed-latency/cmd/script/config.go +++ b/harnesses/rpc-keyed-latency/cmd/script/config.go @@ -37,7 +37,7 @@ func intervalMultFor(provider string) int { } return 1 } -var chainsEVM = []string{"ethereum", "base", "arbitrum", "optimism", "bnb", "polygon"} +var chainsEVM = []string{"ethereum", "base", "arbitrum", "optimism", "bnb", "polygon", "robinhood"} func endpoints() []Endpoint { var out []Endpoint