diff --git a/src/app/api/fee-compare/route.ts b/src/app/api/fee-compare/route.ts index 79016d237..60aebfef9 100644 --- a/src/app/api/fee-compare/route.ts +++ b/src/app/api/fee-compare/route.ts @@ -215,13 +215,14 @@ export async function GET(req: Request) { })); // 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; + gainsEquivForHl += data.notional * (gainsRate / 2); hlNotionalOnGains += data.notional; hlFeesOnGainsCoins += data.fees; } @@ -232,7 +233,8 @@ export async function GET(req: Request) { const gainsSizeUsdc = usdcLogs.reduce((s, l) => s + Number(l.posSize) / 1e6, 0); const hlRoundTrip = HL_TAKER_PER_SIDE * 2; - const hlEquivForGains = gainsSizeUsdc * hlRoundTrip; + // 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(), diff --git a/src/components/fee-compare-client.tsx b/src/components/fee-compare-client.tsx index 23d6d37d6..7aad9ced6 100644 --- a/src/components/fee-compare-client.tsx +++ b/src/components/fee-compare-client.tsx @@ -1,12 +1,21 @@ "use client"; import { useState } from "react"; +import Image from "next/image"; import { - ArrowRight, Loader2, AlertCircle, TrendingDown, TrendingUp, - ChevronDown, ChevronUp, + ArrowRight, + Loader2, + AlertCircle, + Zap, + ChevronDown, + ChevronUp, } from "lucide-react"; -type Fill = { +// ────────────────────────────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────────────────────────────── + +type FillRow = { time: number; coin: string; dir: string; @@ -27,10 +36,9 @@ type TopCoin = { gainsRoundTripRate: number | null; }; -type Result = { +type FeeCompareResult = { wallet: string; days: number; - generatedAt: number; hl: { fills: number; notionalUsd: number; @@ -39,7 +47,7 @@ type Result = { netCostUsd: number; avgFeeRateBps: number; topCoins: TopCoin[]; - recentFills: Fill[]; + recentFills: FillRow[]; }; gains: { events: number; @@ -60,173 +68,220 @@ type Result = { gainsFeeRates: Record; }; -function usd(n: number, dec = 2) { - if (Math.abs(n) >= 10000) return "$" + Math.round(n).toLocaleString("en-US"); - if (Math.abs(n) >= 100) return "$" + n.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 }); - return "$" + n.toLocaleString("en-US", { minimumFractionDigits: dec, maximumFractionDigits: dec }); +// ────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────── + +function fmt(n: number, decimals = 2) { + return n.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); } -function pct(rate: number) { - return (rate * 100).toFixed(3) + "%"; +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 bps(rate: number) { - return (rate * 10000).toFixed(2) + " bps"; +function fmtBps(rate: number) { + return fmt(rate * 10000, 2) + " bps"; } -function fmtDate(ts: number) { - return new Date(ts).toLocaleDateString("en-US", { - month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", +function fmtDate(ms: number) { + return new Date(ms).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", }); } -function Kpi({ label, value, sub, green, red }: { - label: string; value: string; sub?: string; green?: boolean; red?: boolean; -}) { +// ────────────────────────────────────────────────────────────────────── +// Atoms +// ────────────────────────────────────────────────────────────────────── + +function PlatformLogo({ name, size = 28 }: { name: "hl" | "gains"; size?: number }) { return ( -
-

{label}

-

- {value} -

- {sub &&

{sub}

} -
+ {name ); } -function HlSection({ hl, comparison, gainsFeeRates }: { - hl: Result["hl"]; comparison: Result["comparison"]; gainsFeeRates: Record; -}) { - const [showAll, setShowAll] = useState(false); - const fills = showAll ? hl.recentFills : hl.recentFills.slice(0, 10); - const saved = comparison.hlSavedVsGains; - const multiple = comparison.hlCheaperMultiple; +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-400" : "bg-red-400/12 text-red-400" + : isLong ? "bg-emerald-500/8 text-emerald-500/70" : "bg-red-400/8 text-red-400/70"; + return ( + + {dir} + + ); +} +function MakerBadge() { return ( -
-
-
-

Hyperliquid

-

- {hl.fills} fills · {pct(hl.avgFeeRateBps / 10000)} avg fee rate -

-
-
-

{usd(hl.feesUsd)}

-

trade fees paid

-
-
+ + maker + + ); +} + +// ────────────────────────────────────────────────────────────────────── +// SummaryVsCard +// ────────────────────────────────────────────────────────────────────── + +function SummaryVsCard({ result }: { result: FeeCompareResult }) { + const { hl, gains, comparison } = result; + const hasHl = hl.fills > 0; + const hasGains = gains.events > 0; + const hlWins = comparison.hlSavedVsGains > 1; + const gainsWins = comparison.gainsSavedVsHl > 1; -
- - - = 0 ? "+" : "") + usd(hl.fundingUsd)} - sub={hl.fundingUsd >= 0 ? "received" : "paid"} - green={hl.fundingUsd > 0.01} - red={hl.fundingUsd < -0.01} - /> - + if (!hasHl && !hasGains) { + return ( +
+

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

+ ); + } - {saved > 0.5 && multiple && ( -
- -
-

- HL saved {usd(saved)} vs Gains — {multiple.toFixed(2)}x cheaper -

-

- Same {usd(comparison.hlNotionalOnGains, 0)} notional at live Gains rates ( - {Object.entries(gainsFeeRates).map(([c, r]) => `${c}: ${pct(r)}`).join(", ")} - ) = {usd(comparison.gainsEquivForHlNotional)} -

+ return ( +
+
+ {/* HL side */} +
+
+ +
+

Hyperliquid

+ {hasHl &&

{hl.fills} fills

} +
+ {hlWins && ( + + Cheaper + + )}
+ {hasHl ? ( +
+
+

{fmtUsd(hl.feesUsd)}

+

{fmt(hl.avgFeeRateBps, 2)} bps avg

+
+
+
+

Volume

+

{fmtUsd(hl.notionalUsd)}

+
+
+

Net cost

+

{fmtUsd(hl.netCostUsd)}

+

fees minus funding

+
+
+
+ ) : ( +

No activity

+ )}
- )} - {hl.recentFills.length > 0 && ( -
-

- All trades ({hl.recentFills.length}) -

-
- - - - - - - - - - - - - - {fills.map((f, i) => { - const gainsDelta = f.gainsPerSide !== null ? f.gainsPerSide - f.hlFee : null; - const isOpen = f.closedPnl === 0; - return ( - - - - - - - - - - ); - })} - -
DateMarketDirectionNotionalHL feeGains equivPnL
- {fmtDate(f.time)} - - {f.coin} - {f.isTaker ? "taker" : "maker"} - - - {f.dir} - - {usd(f.notional, 0)}{usd(f.hlFee, 4)} - {f.gainsPerSide !== null ? ( - - {usd(f.gainsPerSide, 4)} - {gainsDelta !== null && gainsDelta > 0.0005 && ( - - −{usd(gainsDelta, 3)} saved - - )} - - ) : ( - not on Gains - )} - - {!isOpen ? ( - 0 ? "text-emerald-500" : "text-red-400"}> - {f.closedPnl > 0 ? "+" : ""}{usd(f.closedPnl, 2)} - - ) : ( - open - )} -
+ {/* VS divider */} +
+
+ VS +
+
+ + {/* Gains side */} +
+
+ +
+

Gains.trade

+ {hasGains &&

{gains.events} trades

} +
+ {gainsWins && ( + + Cheaper + + )}
+ {hasGains ? ( +
+
+

{fmtUsd(gains.feesUsdc)}

+

{fmt(gains.avgFeeRateBps, 2)} bps avg

+
+
+
+

Volume

+

{fmtUsd(gains.positionSizeUsdc)}

+
+
+

Events

+

{gains.events}

+

USDC collateral

+
+
+
+ ) : ( +

No activity

+ )} +
+
- {hl.recentFills.length > 10 && ( - + {/* Verdict bar */} + {(hasHl || hasGains) && ( +
+ {hasHl && comparison.hlNotionalOnGains > 0 && ( +
+

+ HL trades on Gains-listed coins at live Gains rates +

+ {comparison.hlSavedVsGains > 1 ? ( +

+ HL saved {fmtUsd(comparison.hlSavedVsGains)} vs Gains + {comparison.hlCheaperMultiple && ( + ({fmt(comparison.hlCheaperMultiple, 1)}x cheaper) + )} +

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

+ HL overpaid {fmtUsd(Math.abs(comparison.hlSavedVsGains))} vs Gains +

+ ) : ( +

Roughly equal cost

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

+ Gains trades at HL taker ({fmtBps(comparison.hlRoundTripRate)} RT) +

+ {comparison.gainsSavedVsHl < -1 ? ( +

+ HL would save {fmtUsd(Math.abs(comparison.gainsSavedVsHl))} +

+ ) : comparison.gainsSavedVsHl > 1 ? ( +

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

+ ) : ( +

Roughly equal cost

+ )} +
)}
)} @@ -234,103 +289,205 @@ function HlSection({ hl, comparison, gainsFeeRates }: { ); } -function GainsSection({ gains, comparison }: { gains: Result["gains"]; comparison: Result["comparison"] }) { - const delta = comparison.gainsSavedVsHl; - return ( -
-
-
-

Gains.trade (Arbitrum)

-

- {gains.events} USDC events · {pct(gains.avgFeeRateBps / 10000)} avg -

-
-
-

{usd(gains.feesUsdc)}

-

trade fees paid

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

Top markets

- - {Math.abs(delta) > 0.5 && ( -
- {delta < 0 - ? - : - } -
- {delta < 0 ? ( -

- HL would have saved {usd(Math.abs(delta))} -

+
+ {topCoins.map((c) => ( +
+ {c.coin} + {c.fills} fills +
+
+
+
+
+ {fmtUsd(c.notional)} + {fmtUsd(c.fees)} + {c.gainsRoundTripRate !== null ? ( + + Gains {fmtBps(c.gainsRoundTripRate)} RT + ) : ( -

- Gains saved {usd(delta)} vs HL -

+ not on Gains )} -

- HL standard taker ({pct(comparison.hlRoundTripRate)} round-trip) on{" "} - {usd(gains.positionSizeUsdc, 0)} = {usd(comparison.hlEquivForGainsVolume)} vs{" "} - {usd(gains.feesUsdc)} paid on Gains -

+ ))} +
+
+ ); +} + +// ────────────────────────────────────────────────────────────────────── +// HlTradeTable +// ────────────────────────────────────────────────────────────────────── + +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

+
-

- Source: FeesProcessed events on{" "} - 0xFF16…7f169 (Arbitrum). USDC collateral - only. Open positions without a matching close are counted as single events. -

+
+ + + + + + + + + + + + + + + {rows.map((f, i) => { + const gainsFee = f.gainsPerSide; + const saved = gainsFee !== null ? gainsFee - f.hlFee : null; + const isOpen = f.closedPnl === 0 && !f.dir.toLowerCase().includes("close"); + return ( + + + + + + + + + + + ); + })} + +
DateMarketDirectionNotionalHL feeGains equivSavedPnL
{fmtDate(f.time)} +
+ {f.coin} + {!f.isTaker && } +
+
{fmtUsd(f.notional)}{fmtUsd(f.hlFee)} + {gainsFee !== null ? fmtUsd(gainsFee) : } + + {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 && ( + + )}
); } -function Results({ result }: { result: Result }) { - const hasHl = result.hl.fills > 0; - const hasGains = result.gains.events > 0; +// ────────────────────────────────────────────────────────────────────── +// GainsCard +// ────────────────────────────────────────────────────────────────────── - if (!hasHl && !hasGains) { - return ( -
-

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

-

Try a longer period or verify the address.

+function GainsCard({ gains, comparison }: { gains: FeeCompareResult["gains"]; comparison: FeeCompareResult["comparison"] }) { + return ( +
+
+ +

Gains.trade on-chain

+ Arbitrum
- ); - } +
+ {[ + { label: "Fees paid", value: fmtUsd(gains.feesUsdc), sub: fmt(gains.avgFeeRateBps, 2) + " bps avg" }, + { label: "Position size", value: fmtUsd(gains.positionSizeUsdc), sub: `${gains.events} events` }, + { label: "HL equiv cost", value: fmtUsd(comparison.hlEquivForGainsVolume), sub: fmtBps(comparison.hlRoundTripRate) + " taker RT" }, + { + label: comparison.gainsSavedVsHl < -1 ? "HL saves" : "Gains saves", + value: Math.abs(comparison.gainsSavedVsHl) > 1 ? fmtUsd(Math.abs(comparison.gainsSavedVsHl)) : "≈ $0", + accent: comparison.gainsSavedVsHl < -1, + }, + ].map((s) => ( +
+

{s.label}

+

{s.value}

+ {s.sub &&

{s.sub}

} +
+ ))} +
+
+ ); +} +// ────────────────────────────────────────────────────────────────────── +// Results +// ────────────────────────────────────────────────────────────────────── + +function Results({ result }: { result: FeeCompareResult }) { return ( -
- {hasHl && ( -
- -
- )} - {hasGains && ( -
- -
- )} -

- HL fees: real data from Hyperliquid fills API. Gains fees: real on-chain FeesProcessed - events. Cross-platform estimates use live Gains fee schedule and HL public taker rate. - Funding excluded from cross-platform estimates. +

+ + {result.hl.topCoins.length > 0 && } + {result.hl.recentFills.length > 0 && } + {result.gains.events > 0 && } +

+ HL fees: exact fills from Hyperliquid API. Gains fees: on-chain{" "} + FeesProcessed events from{" "} + 0xFF16...7f169 (Arbitrum). Gains simulation uses live + rates from backend-arbitrum.gains.trade. HL 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 [result, setResult] = useState(null); const [error, setError] = useState(null); async function analyze() { @@ -346,10 +503,10 @@ export function FeeCompareClient() { 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." : (d.error ?? "Something went wrong.")); + setError(res.status === 429 ? "Rate limited — wait a moment and try again." : (d.error ?? "Something went wrong.")); return; } - setResult(await res.json()); + setResult(await res.json() as FeeCompareResult); } catch { setError("Network error — check your connection."); } finally { @@ -359,7 +516,7 @@ export function FeeCompareClient() { return (
-
+
@@ -386,10 +543,8 @@ export function FeeCompareClient() { key={d} type="button" onClick={() => setDays(d)} - className={`rounded-md border px-2.5 py-1 text-[11px] font-sans font-medium uppercase tracking-[0.1em] transition-all ${ - days === d - ? "border-ink bg-ink text-paper" - : "border-ink/15 bg-paper text-ink hover:border-ink/40" + className={`rounded-md border px-2.5 py-1 text-[11px] font-medium uppercase tracking-[0.1em] transition-all ${ + days === d ? "border-ink bg-ink text-paper" : "border-ink/15 bg-paper text-ink hover:border-ink/40" }`} > {d}d @@ -400,10 +555,10 @@ export function FeeCompareClient() { type="button" onClick={analyze} disabled={loading} - className="ml-auto flex items-center gap-2 rounded-lg bg-ink px-4 py-2 text-sm font-medium text-paper disabled:opacity-50 hover:opacity-90 transition-opacity" + className="ml-auto flex items-center gap-2 rounded-xl bg-ink px-5 py-2.5 text-sm font-medium text-paper disabled:opacity-50 hover:opacity-90 transition-opacity" > {loading ? : } - {loading ? "Analyzing..." : "Analyze"} + {loading ? "Analyzing..." : "Analyze wallet"}