From eaaba2605ded221250c98a103f2f0f6fc9dd4fce Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:41:47 +0200 Subject: [PATCH 1/3] feat: shareable wallet URLs + HL fee/funding breakdown - /fee-compare/[venueA]/[venueB]/[wallet] dynamic route auto-submits on load - pushState updates URL after analyze so any comparison is shareable - FeeCompareClient accepts initialVenueA/B/Wallet/Days props - HL wallet side: replace 2-cell grid with inline fee/funding/net breakdown --- .../[venueA]/[venueB]/[wallet]/page.tsx | 78 ++++++++++++ src/components/fee-compare-client.tsx | 120 ++++++++++++------ 2 files changed, 160 insertions(+), 38 deletions(-) create mode 100644 src/app/fee-compare/[venueA]/[venueB]/[wallet]/page.tsx diff --git a/src/app/fee-compare/[venueA]/[venueB]/[wallet]/page.tsx b/src/app/fee-compare/[venueA]/[venueB]/[wallet]/page.tsx new file mode 100644 index 00000000..a3a51dd4 --- /dev/null +++ b/src/app/fee-compare/[venueA]/[venueB]/[wallet]/page.tsx @@ -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 = { + 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 { + 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 ( +
+

+ Fee comparison +

+

+ {a} vs {b} +

+

{wallet}

+

+ Actual fees paid vs what the same trades would have cost on the other platform. +

+

+ Live on-chain data. No API key required. +

+ +
+ +
+
+ ); +} diff --git a/src/components/fee-compare-client.tsx b/src/components/fee-compare-client.tsx index 8ee1279d..87797c56 100644 --- a/src/components/fee-compare-client.tsx +++ b/src/components/fee-compare-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import Image from "next/image"; import { ArrowRight, @@ -594,32 +594,48 @@ function WalletSide({ {/* Venue-specific extra stats */} - {venue.slug === "hyperliquid" && ( -
-
-

Volume

-

- {fmtUsd(volume)} -

-
-
-

Net cost

-

- {fmtUsd((w as HlWalletData).netCostUsd)} -

- {Math.abs((w as HlWalletData).fundingUsd) > 0.5 ? ( -

- fees {fmtUsd(fees)}{" "} - {(w as HlWalletData).fundingUsd > 0 - ? `+ ${fmtUsd((w as HlWalletData).fundingUsd)} rcvd` - : `– ${fmtUsd(Math.abs((w as HlWalletData).fundingUsd))} paid`} -

- ) : ( -

after funding

- )} + {venue.slug === "hyperliquid" && (() => { + const hlW = w as HlWalletData; + const hasFunding = Math.abs(hlW.fundingUsd) > 0.5; + return ( +
+
+

Volume

+

{fmtUsd(volume)}

+
+
+
+

Trading fees

+

+ {hlW.feesUsd < -0.01 + ? +{fmtUsd(Math.abs(hlW.feesUsd))} + : fmtUsd(hlW.feesUsd)} +

+
+ {hasFunding && ( +
+

+ Funding {hlW.fundingUsd > 0 ? "received" : "paid"} +

+

0 ? "text-emerald-500" : "text-red-400"}`}> + {hlW.fundingUsd > 0 + ? `+${fmtUsd(hlW.fundingUsd)}` + : `−${fmtUsd(Math.abs(hlW.fundingUsd))}`} +

+
+ )} +
+

Net cost

+

+ {hlW.netCostUsd < -0.01 + ? `+${fmtUsd(Math.abs(hlW.netCostUsd))}` + : fmtUsd(hlW.netCostUsd)} +

+
+
-
- )} + ); + })()} {venue.slug !== "hyperliquid" && (

Volume

@@ -978,15 +994,36 @@ function Results({ result }: { result: FeeCompareResult }) { // FeeCompareClient // ────────────────────────────────────────────────────────────────────── -export function FeeCompareClient() { - const [venueA, setVenueA] = useState("hyperliquid"); - const [venueB, setVenueB] = useState("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(initialVenueA); + const [venueB, setVenueB] = useState(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(null); const [error, setError] = useState(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); @@ -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; } @@ -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); @@ -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 { @@ -1139,7 +1183,7 @@ export function FeeCompareClient() {