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/5] =?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/5] 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 c94e2ea3ba4c327ffed22af391b682dec8cdd699 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:16:55 +0200 Subject: [PATCH 3/5] fix: revalidate hl-cohort + hl-history on aggregate purge --- src/app/api/internal/revalidate-aggregate/route.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/api/internal/revalidate-aggregate/route.ts b/src/app/api/internal/revalidate-aggregate/route.ts index 39d221d9..0fdde6a4 100644 --- a/src/app/api/internal/revalidate-aggregate/route.ts +++ b/src/app/api/internal/revalidate-aggregate/route.ts @@ -44,5 +44,10 @@ export async function POST(req: Request): Promise { // worker publish. revalidateTag("benchmarks", "default"); revalidateTag("data-api-cohort", "default"); - return NextResponse.json({ revalidated: true, tags: ["bench-aggregate", "benchmarks", "data-api-cohort"] }); + // Invalidate HL cohort caches (builder stats, history blob, leaderboard) + // so new builders added to Prometheus (e.g. fomo) surface on the next + // request without waiting for the 1h unstable_cache TTL to expire. + revalidateTag("hl-cohort", "default"); + revalidateTag("hl-history", "default"); + return NextResponse.json({ revalidated: true, tags: ["bench-aggregate", "benchmarks", "data-api-cohort", "hl-cohort", "hl-history"] }); } From 414f7fef46dda5a5401a1076fbd9860ccd6e5f8e Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:21:49 +0200 Subject: [PATCH 4/5] fix(app-store-ratings): last_over_time[31m] on reviews panel for 7d+ staleness --- benchmarks/app-store-ratings.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/app-store-ratings.yml b/benchmarks/app-store-ratings.yml index 478c87f4..75eb14d9 100644 --- a/benchmarks/app-store-ratings.yml +++ b/benchmarks/app-store-ratings.yml @@ -70,7 +70,7 @@ metric_panels: - id: reviews label: Review count label_key: app - metric: app_store_reviews_total + metric: last_over_time(app_store_reviews_total{}[31m]) unit: count higher_is_better: true description: "Total number of user ratings submitted on the US App Store. More reviews = more statistically reliable average. Robinhood leads with 4.8M+ ratings, Coinbase follows at 1.8M+." From 2369c31baeca6d6afbf34f9847a75a2af351f3b1 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:23:20 +0200 Subject: [PATCH 5/5] Revert "fix(app-store-ratings): last_over_time[31m] on reviews panel for 7d+ staleness" This reverts commit 414f7fef46dda5a5401a1076fbd9860ccd6e5f8e. --- benchmarks/app-store-ratings.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/app-store-ratings.yml b/benchmarks/app-store-ratings.yml index 75eb14d9..478c87f4 100644 --- a/benchmarks/app-store-ratings.yml +++ b/benchmarks/app-store-ratings.yml @@ -70,7 +70,7 @@ metric_panels: - id: reviews label: Review count label_key: app - metric: last_over_time(app_store_reviews_total{}[31m]) + metric: app_store_reviews_total unit: count higher_is_better: true description: "Total number of user ratings submitted on the US App Store. More reviews = more statistically reliable average. Robinhood leads with 4.8M+ ratings, Coinbase follows at 1.8M+."