Skip to content
Merged
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
78 changes: 78 additions & 0 deletions src/app/fee-compare/[venueA]/[venueB]/[wallet]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { pageMetadata } from "@/lib/page-metadata";
import { FeeCompareClient } from "@/components/fee-compare-client";

const VALID_SLUGS = ["hyperliquid", "gains", "dydx", "gmx-v2", "paradex", "edgex"] as const;
type VenueSlug = (typeof VALID_SLUGS)[number];

const VENUE_NAMES: Record<VenueSlug, string> = {
hyperliquid: "Hyperliquid",
gains: "Gains",
dydx: "dYdX v4",
"gmx-v2": "GMX v2",
paradex: "Paradex",
edgex: "EdgeX",
};

function short(addr: string) {
return `${addr.slice(0, 6)}…${addr.slice(-4)}`;
}

type Props = { params: Promise<{ venueA: string; venueB: string; wallet: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { venueA, venueB, wallet } = await params;
if (!VALID_SLUGS.includes(venueA as VenueSlug) || !VALID_SLUGS.includes(venueB as VenueSlug)) {
return {};
}
const a = VENUE_NAMES[venueA as VenueSlug];
const b = VENUE_NAMES[venueB as VenueSlug];
return pageMetadata({
path: `/fee-compare/${venueA}/${venueB}/${wallet}`,
title: `${a} vs ${b} fees — ${short(wallet)}`,
description: `Fee comparison for wallet ${wallet} on ${a} vs ${b}. Live on-chain data, no API key.`,
});
}

export default async function FeeCompareWalletPage({ params }: Props) {
const { venueA, venueB, wallet } = await params;

if (
!VALID_SLUGS.includes(venueA as VenueSlug) ||
!VALID_SLUGS.includes(venueB as VenueSlug) ||
venueA === venueB ||
!/^0x[0-9a-fA-F]{40}$/.test(wallet)
) {
notFound();
}

const a = VENUE_NAMES[venueA as VenueSlug];
const b = VENUE_NAMES[venueB as VenueSlug];

return (
<article className="mx-auto max-w-[720px] px-4 sm:px-6 py-10 sm:py-14">
<p className="font-sans text-[11px] uppercase tracking-[0.18em] text-ink-faint mb-3">
Fee comparison
</p>
<h1 className="display text-3xl sm:text-4xl text-ink leading-[1.05]">
{a} vs {b}
</h1>
<p className="mt-3 font-mono text-sm text-ink-faint break-all">{wallet}</p>
<p className="mt-3 max-w-2xl text-base text-ink-soft leading-snug">
Actual fees paid vs what the same trades would have cost on the other platform.
</p>
<p className="mt-2 text-sm text-ink-faint">
Live on-chain data. No API key required.
</p>

<div className="mt-8">
<FeeCompareClient
initialVenueA={venueA as VenueSlug}
initialVenueB={venueB as VenueSlug}
initialWallet={wallet}
/>
</div>
</article>
);
}
120 changes: 82 additions & 38 deletions src/components/fee-compare-client.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import Image from "next/image";
import {
ArrowRight,
Expand Down Expand Up @@ -594,32 +594,48 @@ function WalletSide({
</div>

{/* Venue-specific extra stats */}
{venue.slug === "hyperliquid" && (
<div className="grid grid-cols-2 gap-2">
<div className="bg-ink/4 rounded-xl p-3">
<p className="text-[10px] uppercase tracking-[0.14em] text-ink-faint">Volume</p>
<p className="font-mono font-semibold text-ink mt-1 text-sm">
{fmtUsd(volume)}
</p>
</div>
<div className="bg-ink/4 rounded-xl p-3">
<p className="text-[10px] uppercase tracking-[0.14em] text-ink-faint">Net cost</p>
<p className="font-mono font-semibold text-ink mt-1 text-sm">
{fmtUsd((w as HlWalletData).netCostUsd)}
</p>
{Math.abs((w as HlWalletData).fundingUsd) > 0.5 ? (
<p className="text-[10px] text-ink-faint/60 mt-0.5">
fees {fmtUsd(fees)}{" "}
{(w as HlWalletData).fundingUsd > 0
? `+ ${fmtUsd((w as HlWalletData).fundingUsd)} rcvd`
: `– ${fmtUsd(Math.abs((w as HlWalletData).fundingUsd))} paid`}
</p>
) : (
<p className="text-[10px] text-ink-faint/60 mt-0.5">after funding</p>
)}
{venue.slug === "hyperliquid" && (() => {
const hlW = w as HlWalletData;
const hasFunding = Math.abs(hlW.fundingUsd) > 0.5;
return (
<div className="bg-ink/4 rounded-xl p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-[10px] uppercase tracking-[0.14em] text-ink-faint">Volume</p>
<p className="font-mono text-xs font-semibold text-ink">{fmtUsd(volume)}</p>
</div>
<div className="border-t border-ink/8 pt-2 space-y-1.5">
<div className="flex items-center justify-between">
<p className="text-[11px] text-ink-faint">Trading fees</p>
<p className="font-mono text-xs font-semibold text-ink">
{hlW.feesUsd < -0.01
? <span className="text-emerald-500">+{fmtUsd(Math.abs(hlW.feesUsd))}</span>
: fmtUsd(hlW.feesUsd)}
</p>
</div>
{hasFunding && (
<div className="flex items-center justify-between">
<p className="text-[11px] text-ink-faint">
Funding {hlW.fundingUsd > 0 ? "received" : "paid"}
</p>
<p className={`font-mono text-xs font-semibold ${hlW.fundingUsd > 0 ? "text-emerald-500" : "text-red-400"}`}>
{hlW.fundingUsd > 0
? `+${fmtUsd(hlW.fundingUsd)}`
: `−${fmtUsd(Math.abs(hlW.fundingUsd))}`}
</p>
</div>
)}
<div className="flex items-center justify-between border-t border-ink/8 pt-1.5">
<p className="text-[11px] font-semibold text-ink-soft">Net cost</p>
<p className={`font-mono text-xs font-bold ${hlW.netCostUsd < -0.01 ? "text-emerald-500" : "text-ink"}`}>
{hlW.netCostUsd < -0.01
? `+${fmtUsd(Math.abs(hlW.netCostUsd))}`
: fmtUsd(hlW.netCostUsd)}
</p>
</div>
</div>
</div>
</div>
)}
);
})()}
{venue.slug !== "hyperliquid" && (
<div className="bg-ink/4 rounded-xl p-3">
<p className="text-[10px] uppercase tracking-[0.14em] text-ink-faint">Volume</p>
Expand Down Expand Up @@ -978,15 +994,36 @@ function Results({ result }: { result: FeeCompareResult }) {
// FeeCompareClient
// ──────────────────────────────────────────────────────────────────────

export function FeeCompareClient() {
const [venueA, setVenueA] = useState<VenueSlug>("hyperliquid");
const [venueB, setVenueB] = useState<VenueSlug>("gains");
const [wallet, setWallet] = useState("");
type InitialProps = {
initialVenueA?: VenueSlug;
initialVenueB?: VenueSlug;
initialWallet?: string;
initialDays?: number;
};

export function FeeCompareClient({
initialVenueA = "hyperliquid",
initialVenueB = "gains",
initialWallet = "",
initialDays = 90,
}: InitialProps = {}) {
const [venueA, setVenueA] = useState<VenueSlug>(initialVenueA);
const [venueB, setVenueB] = useState<VenueSlug>(initialVenueB);
const [wallet, setWallet] = useState(initialWallet);
const [dydxAddress, setDydxAddress] = useState("");
const [days, setDays] = useState(90);
const [days, setDays] = useState(initialDays);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<FeeCompareResult | null>(null);
const [error, setError] = useState<string | null>(null);
const didAutoSubmit = useRef(false);

useEffect(() => {
if (!didAutoSubmit.current && initialWallet) {
didAutoSubmit.current = true;
analyze(initialVenueA, initialVenueB, initialWallet, initialDays);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const needsEvmWallet =
EVM_WALLET_VENUES.includes(venueA) || EVM_WALLET_VENUES.includes(venueB);
Expand All @@ -1004,15 +1041,17 @@ export function FeeCompareClient() {
setResult(null);
}

async function analyze() {
const trimmed = wallet.trim();
async function analyze(overrideVenueA?: VenueSlug, overrideVenueB?: VenueSlug, overrideWallet?: string, overrideDays?: number) {
const va = overrideVenueA ?? venueA;
const vb = overrideVenueB ?? venueB;
const trimmed = (overrideWallet ?? wallet).trim();
const dydxTrimmed = dydxAddress.trim();

if (needsEvmWallet && trimmed && !/^0x[0-9a-fA-F]{40}$/.test(trimmed)) {
if ((EVM_WALLET_VENUES.includes(va) || EVM_WALLET_VENUES.includes(vb)) && trimmed && !/^0x[0-9a-fA-F]{40}$/.test(trimmed)) {
setError("Enter a valid Ethereum address (0x...)");
return;
}
if (needsDydxAddr && dydxTrimmed && !/^dydx1[a-z0-9]{38}$/.test(dydxTrimmed)) {
if ((va === "dydx" || vb === "dydx") && dydxTrimmed && !/^dydx1[a-z0-9]{38}$/.test(dydxTrimmed)) {
setError("Enter a valid dYdX address (dydx1...)");
return;
}
Expand All @@ -1022,7 +1061,7 @@ export function FeeCompareClient() {
setResult(null);

try {
const params = new URLSearchParams({ venueA, venueB, days: String(days) });
const params = new URLSearchParams({ venueA: va, venueB: vb, days: String(overrideDays ?? days) });
if (trimmed) params.set("wallet", trimmed);
if (dydxTrimmed) params.set("dydxAddress", dydxTrimmed);

Expand All @@ -1036,7 +1075,12 @@ export function FeeCompareClient() {
);
return;
}
setResult((await res.json()) as FeeCompareResult);
const data = (await res.json()) as FeeCompareResult;
setResult(data);
if (trimmed) {
const newPath = `/fee-compare/${va}/${vb}/${trimmed}`;
window.history.pushState({}, "", newPath);
}
} catch {
setError("Network error — check your connection.");
} finally {
Expand Down Expand Up @@ -1139,7 +1183,7 @@ export function FeeCompareClient() {
</div>
<button
type="button"
onClick={analyze}
onClick={() => analyze()}
disabled={loading}
className="ml-auto flex items-center gap-2 rounded-xl bg-ink px-5 py-2.5 text-sm font-semibold text-paper disabled:opacity-50 hover:opacity-90 transition-opacity"
>
Expand Down
Loading