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
15 changes: 13 additions & 2 deletions src/app/api/aggregate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,17 @@
// of kv.openchainbench.com to get near-zero latency from any Vercel
// function in the same region.
export const runtime = "nodejs";
// ISR: generate once, serve from Vercel Data Cache for 60 s, regenerate
// in background. Keeps s-maxage in the response (Next.js strips it when
// the route uses cache:"no-store" fetches internally, which makes the
// route dynamic and prevents CDN caching — removing that flag fixes it).
export const revalidate = 60;

const UPSTREAM = "https://kv.openchainbench.com/aggregate/latest.json";
const UPSTREAM_TIMEOUT_MS = 25_000;
// Paris VPS → IAD1 with 7.3 MB payload: measured 37-49 s uncompressed.
// With Accept-Encoding:gzip Caddy compresses to ~2.3 MB in ~18 s. Set
// the timeout to 70 s so cold-cache first requests always complete.
const UPSTREAM_TIMEOUT_MS = 70_000;

export async function GET() {
// Retry once: some Vercel function instances can't reach the Paris VPS
Expand All @@ -28,7 +36,10 @@ export async function GET() {
upstream = await fetch(UPSTREAM, {
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
headers: { "Accept-Encoding": "gzip, br" },
cache: "no-store",
// No cache:"no-store" here: that flag opts the route into
// dynamic mode and causes Next.js to strip s-maxage from our
// response headers, breaking Vercel CDN caching. The ISR
// revalidate=60 above handles freshness instead.
});
} catch (err) {
lastErr = String(err);
Expand Down
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>
);
}
125 changes: 83 additions & 42 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 @@ -523,10 +523,7 @@ function WalletSide({
<div className="space-y-3">
<div className="flex items-center gap-2.5">
<VenueLogo slug={venue.slug} size={24} />
<div>
<p className="font-bold text-sm text-ink">{venue.name}</p>
<p className="text-[11px] text-ink-faint mt-0.5">No activity found</p>
</div>
<p className="font-bold text-sm text-ink">{venue.name}</p>
</div>
<div className="bg-ink/4 rounded-xl p-3 space-y-1">
<p className="text-[10px] uppercase tracking-[0.14em] text-ink-faint">
Expand Down Expand Up @@ -594,32 +591,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 +991,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 +1038,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 +1058,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 +1072,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 +1180,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
6 changes: 5 additions & 1 deletion src/lib/aggregate-blob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ const DEFAULT_URL =
process.env.VERCEL_ENV
? "https://openchainbench.com/api/aggregate"
: "https://kv.openchainbench.com/aggregate/latest.json";
const FETCH_TIMEOUT_MS = 20_000;
// Needs to be > the Paris VPS response time (measured 18 s compressed,
// 37-49 s uncompressed). Set to 65 s so cold-cache ISR misses on
// /api/aggregate have enough headroom to complete before we give up and
// fall through to the Redis fan-out path.
const FETCH_TIMEOUT_MS = 65_000;
const MIN_BENCHES = 40;

type AggregateEnvelope = {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/sitemap-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ async function buildFullSitemap(): Promise<MetadataRoute.Sitemap> {
export async function buildSitemap(): Promise<MetadataRoute.Sitemap> {
try {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("sitemap build timeout")), 45_000),
setTimeout(() => reject(new Error("sitemap build timeout")), 120_000),
);
return await Promise.race([buildFullSitemap(), timeout]);
} catch (err) {
Expand Down
Loading