diff --git a/src/app/hyperliquid/page.tsx b/src/app/hyperliquid/page.tsx
index 215c60f3d..1edc401b4 100644
--- a/src/app/hyperliquid/page.tsx
+++ b/src/app/hyperliquid/page.tsx
@@ -1,6 +1,11 @@
import Link from "next/link";
-import { fetchHlCohort, fetchHlHip3Cohort } from "@/lib/hl-builder-stats";
+import {
+ fetchHlCohort,
+ fetchHlHip3Cohort,
+ fetchHlHistory,
+} from "@/lib/hl-builder-stats";
import { HlHubTabs } from "@/components/hl-hub-tabs";
+import { HlHistoryChart } from "@/components/hl-history-chart";
import { pageMetadata } from "@/lib/page-metadata";
import { safeJsonLd } from "@/lib/jsonld";
@@ -33,9 +38,10 @@ export const metadata: import("next").Metadata = pageMetadata({
export const revalidate = 60;
export default async function HyperliquidHubPage() {
- const [frontends, hip3] = await Promise.all([
+ const [frontends, hip3, history] = await Promise.all([
fetchHlCohort(),
fetchHlHip3Cohort(),
+ fetchHlHistory(),
]);
const breadcrumbLd = {
@@ -160,6 +166,20 @@ export default async function HyperliquidHubPage() {
cohorts publish to the same Prom; the bench pages document
the per-row formulas.
+
+ {history && history.frontends.length > 0 && (
+
+
+ 12-month evolution — top {history.frontends.length} frontends by fees
+
+
+ Rolling 30d fees and volume per frontend, daily-stepped over
+ the last 365 days. Same Prom gauges as the leaderboard above,
+ sampled once per UTC day.
+
+
+
+ )}
>
) : (
diff --git a/src/components/hl-history-chart.tsx b/src/components/hl-history-chart.tsx
new file mode 100644
index 000000000..4ef3a83ef
--- /dev/null
+++ b/src/components/hl-history-chart.tsx
@@ -0,0 +1,479 @@
+"use client";
+
+import { useMemo, useRef, useState } from "react";
+import type {
+ HlHistoryFrontend,
+ HlHistoryPoint,
+ HlHistorySummary,
+} from "@/lib/hl-builder-stats";
+
+/**
+ * 12-month evolution chart for the top ~10 HL frontends. Two toggleable
+ * metrics (fees / volume, both 30d rolling), one line per frontend.
+ * Renders as a single SVG with a shared linear Y scale, so a viewer can
+ * eyeball share-of-market swings without a legend hunt.
+ *
+ * Design goals:
+ * - Stays a single SVG. No recharts / D3. Data volume is ~10 × 365
+ * points, well inside SVG's cheap-to-render window.
+ * - Gaps: `v === null` points break the line rather than dropping to
+ * zero. Matches the harness' "no sample this UTC day" semantic and
+ * keeps early-history cohorts (post-launch) from starting from an
+ * artificial floor.
+ * - Colours: 10-slot OCB palette, cycled if the cohort ever grows past
+ * 10. Hovered line lifts to full opacity; the rest dim.
+ * - Crosshair tooltip lists every frontend's value at the hovered
+ * timestamp, ranked by value so the top of the pack is always at
+ * eye level.
+ */
+
+const COLORS = [
+ "#9d65ff", // violet
+ "#ff8a3d", // orange
+ "#22c55e", // emerald
+ "#38bdf8", // sky
+ "#f43f5e", // rose
+ "#eab308", // amber
+ "#14b8a6", // teal
+ "#a855f7", // fuchsia
+ "#f97316", // deep orange
+ "#0ea5e9", // blue
+];
+
+type Metric = "fees" | "volume";
+
+export function HlHistoryChart({ history }: { history: HlHistorySummary }) {
+ const [metric, setMetric] = useState("fees");
+ const [pinnedSlug, setPinnedSlug] = useState(null);
+
+ const activeFrontends = history.frontends;
+ if (activeFrontends.length === 0) {
+ return (
+
+ No history samples yet — the backfill is still populating.
+
+ );
+ }
+
+ return (
+
+
+
+
+ Last 12 months · rolling 30d
+
+
+ Daily-stepped snapshot of the top {activeFrontends.length} HL
+ frontends
+
+
+
+ setMetric("fees")}
+ className={`px-3 py-1.5 font-medium ${
+ metric === "fees"
+ ? "bg-ink text-paper"
+ : "text-ink-soft hover:bg-paper-soft/60"
+ }`}
+ >
+ Fees 30d
+
+ setMetric("volume")}
+ className={`px-3 py-1.5 font-medium border-l border-ink/15 ${
+ metric === "volume"
+ ? "bg-ink text-paper"
+ : "text-ink-soft hover:bg-paper-soft/60"
+ }`}
+ >
+ Volume 30d
+
+
+
+
+
+
+
+ {activeFrontends.map((f, i) => {
+ const color = COLORS[i % COLORS.length];
+ const pinned = pinnedSlug === f.slug;
+ return (
+ setPinnedSlug(pinned ? null : f.slug)}
+ className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] transition-opacity ${
+ pinnedSlug && !pinned
+ ? "border-ink/8 opacity-50"
+ : "border-ink/15"
+ }`}
+ >
+
+ {f.name}
+
+ );
+ })}
+
+
+ );
+}
+
+function ChartCanvas({
+ frontends,
+ metric,
+ pinnedSlug,
+}: {
+ frontends: HlHistoryFrontend[];
+ metric: Metric;
+ pinnedSlug: string | null;
+}) {
+ const W = 1100;
+ const H = 360;
+ const PAD_L = 68;
+ const PAD_R = 20;
+ const PAD_T = 20;
+ const PAD_B = 44;
+ const plotW = W - PAD_L - PAD_R;
+ const plotH = H - PAD_T - PAD_B;
+
+ const seriesOf = (f: HlHistoryFrontend): HlHistoryPoint[] =>
+ metric === "fees" ? f.fees30d : f.volume30d;
+
+ // Shared time axis: derive from the longest series so a frontend that
+ // launched mid-window still lines up on the shared calendar. The
+ // fallback (used only when every frontend has an empty series) reads
+ // the max `t` we can infer from the first non-empty series; if
+ // everything is empty we degrade to a 0..1 span so the SVG still lays
+ // out without pulling Date.now() inside a render-time hook.
+ const tRange = useMemo(() => {
+ let tMin = Number.POSITIVE_INFINITY;
+ let tMax = Number.NEGATIVE_INFINITY;
+ for (const f of frontends) {
+ for (const p of seriesOf(f)) {
+ if (p.t < tMin) tMin = p.t;
+ if (p.t > tMax) tMax = p.t;
+ }
+ }
+ if (!Number.isFinite(tMin) || !Number.isFinite(tMax) || tMin === tMax) {
+ return { tMin: 0, tMax: 1 };
+ }
+ return { tMin, tMax };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [frontends, metric]);
+
+ const yMax = useMemo(() => {
+ let m = 0;
+ for (const f of frontends) {
+ for (const p of seriesOf(f)) {
+ if (p.v !== null && p.v > m) m = p.v;
+ }
+ }
+ return niceMax(m);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [frontends, metric]);
+
+ const xFor = (t: number) => {
+ const span = tRange.tMax - tRange.tMin || 1;
+ return PAD_L + ((t - tRange.tMin) / span) * plotW;
+ };
+ const yFor = (v: number) =>
+ PAD_T + plotH - (yMax > 0 ? (v / yMax) * plotH : 0);
+
+ // Multi-segment path: break the line whenever we hit a null so the
+ // chart shows gaps rather than a straight fall to zero + spike back.
+ const pathFor = (f: HlHistoryFrontend): string => {
+ const parts: string[] = [];
+ let inSegment = false;
+ for (const p of seriesOf(f)) {
+ if (p.v === null) {
+ inSegment = false;
+ continue;
+ }
+ const cmd = inSegment ? "L" : "M";
+ parts.push(`${cmd} ${xFor(p.t).toFixed(1)} ${yFor(p.v).toFixed(1)}`);
+ inSegment = true;
+ }
+ return parts.join(" ");
+ };
+
+ const ticks = [0, 0.25, 0.5, 0.75, 1];
+ const monthTicks = useMemo(
+ () => buildMonthTicks(tRange.tMin, tRange.tMax),
+ [tRange.tMin, tRange.tMax],
+ );
+
+ const svgRef = useRef(null);
+ const [hoverT, setHoverT] = useState(null);
+
+ const onMove: React.PointerEventHandler = (e) => {
+ const svg = svgRef.current;
+ if (!svg) return;
+ const rect = svg.getBoundingClientRect();
+ const xRatio = (e.clientX - rect.left) / rect.width;
+ const px = xRatio * W;
+ if (px < PAD_L || px > W - PAD_R) {
+ setHoverT(null);
+ return;
+ }
+ const span = tRange.tMax - tRange.tMin;
+ const t = tRange.tMin + ((px - PAD_L) / plotW) * span;
+ setHoverT(t);
+ };
+
+ // Snap hover to nearest sample per frontend for the tooltip readout.
+ const hoverRows = useMemo(() => {
+ if (hoverT === null) return null;
+ const rows: { slug: string; name: string; color: string; v: number | null }[] = [];
+ for (let i = 0; i < frontends.length; i++) {
+ const f = frontends[i];
+ const s = seriesOf(f);
+ if (s.length === 0) {
+ rows.push({ slug: f.slug, name: f.name, color: COLORS[i % COLORS.length], v: null });
+ continue;
+ }
+ let best = s[0];
+ let bestDist = Math.abs(best.t - hoverT);
+ for (const p of s) {
+ const d = Math.abs(p.t - hoverT);
+ if (d < bestDist) {
+ bestDist = d;
+ best = p;
+ }
+ }
+ rows.push({
+ slug: f.slug,
+ name: f.name,
+ color: COLORS[i % COLORS.length],
+ v: best.v,
+ });
+ }
+ rows.sort((a, b) => (b.v ?? -1) - (a.v ?? -1));
+ return rows;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [hoverT, frontends, metric]);
+
+ const hoverX = hoverT !== null ? xFor(hoverT) : null;
+ const hoverDate = hoverT !== null ? formatDate(hoverT) : null;
+
+ return (
+
+
setHoverT(null)}
+ >
+ {ticks.map((t) => {
+ const y = PAD_T + plotH * (1 - t);
+ const v = yMax * t;
+ return (
+
+
+
+ {fmtUSDShort(v)}
+
+
+ );
+ })}
+
+ {monthTicks.map((mt) => {
+ const x = xFor(mt.t);
+ if (x < PAD_L - 1 || x > W - PAD_R + 1) return null;
+ return (
+
+
+
+ {mt.label}
+
+
+ );
+ })}
+
+ {frontends.map((f, i) => {
+ const color = COLORS[i % COLORS.length];
+ const dimmed = pinnedSlug !== null && pinnedSlug !== f.slug;
+ return (
+
+ );
+ })}
+
+ {hoverX !== null && (
+
+ )}
+
+
+ {hoverRows && hoverDate && hoverX !== null && (
+
+ )}
+
+ );
+}
+
+function Tooltip({
+ rows,
+ date,
+ xFrac,
+}: {
+ rows: { slug: string; name: string; color: string; v: number | null }[];
+ date: string;
+ xFrac: number;
+}) {
+ const left = Math.max(4, Math.min(96, xFrac * 100));
+ const flipX = left > 55;
+ return (
+
+
+ {date}
+
+
+ {rows.slice(0, 10).map((r) => (
+
+
+
+ {r.name}
+
+
+ {r.v === null ? "—" : fmtUSDShort(r.v)}
+
+
+ ))}
+
+
+ );
+}
+
+function niceMax(v: number): number {
+ if (v <= 0) return 1;
+ const exp = Math.pow(10, Math.floor(Math.log10(v)));
+ const f = v / exp;
+ if (f <= 1) return exp;
+ if (f <= 2) return 2 * exp;
+ if (f <= 5) return 5 * exp;
+ return 10 * exp;
+}
+
+function fmtUSDShort(v: number): string {
+ if (!Number.isFinite(v) || v === 0) return "$0";
+ const abs = Math.abs(v);
+ if (abs >= 1_000_000_000) return `$${(v / 1_000_000_000).toFixed(1)}B`;
+ if (abs >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M`;
+ if (abs >= 1_000) return `$${(v / 1_000).toFixed(0)}K`;
+ return `$${v.toFixed(0)}`;
+}
+
+function formatDate(t: number): string {
+ const d = new Date(t);
+ return d.toISOString().slice(0, 10);
+}
+
+/** First-of-month labels between two epoch-ms bounds. Keeps the count
+ * bounded (~12 labels) so the axis never crowds. */
+function buildMonthTicks(
+ tMinMs: number,
+ tMaxMs: number,
+): { t: number; label: string }[] {
+ const out: { t: number; label: string }[] = [];
+ const start = new Date(tMinMs);
+ const cursor = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1));
+ const MONTH_NAMES = [
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+ ];
+ while (cursor.getTime() <= tMaxMs) {
+ const t = cursor.getTime();
+ if (t >= tMinMs) {
+ const label =
+ cursor.getUTCMonth() === 0
+ ? `${MONTH_NAMES[0]} ${cursor.getUTCFullYear() % 100}`
+ : MONTH_NAMES[cursor.getUTCMonth()];
+ out.push({ t, label });
+ }
+ cursor.setUTCMonth(cursor.getUTCMonth() + 1);
+ }
+ return out;
+}
diff --git a/src/lib/hl-builder-stats.ts b/src/lib/hl-builder-stats.ts
index 4f1ef6c39..4f67db5f7 100644
--- a/src/lib/hl-builder-stats.ts
+++ b/src/lib/hl-builder-stats.ts
@@ -251,6 +251,31 @@ export type HlHip3Summary = {
* misaligned payload. The cohort-snapshot module appends its own `:v1`. */
const HL_FRONTENDS_KEY = "hl-frontends";
const HL_HIP3_KEY = "hl-hip3";
+const HL_HISTORY_KEY = "hl-history";
+
+/** One evenly-spaced point on a rolling-window history series. `v = null`
+ * means the underlying gauge had no sample at that timestamp (harness
+ * gap, pre-mainnet epoch, ...) — kept as a sentinel so a range's shape
+ * is stable across frontends and the chart can draw a gap instead of
+ * interpolating through zero. */
+export type HlHistoryPoint = { t: number; v: number | null };
+export type HlHistoryFrontend = {
+ slug: string;
+ name: string;
+ /** Rolling 30d USD builder-fee revenue, daily-stepped over ~12 months. */
+ fees30d: HlHistoryPoint[];
+ /** Rolling 30d USD notional volume, daily-stepped over ~12 months. */
+ volume30d: HlHistoryPoint[];
+};
+export type HlHistorySummary = {
+ /** Range fetched in ms (365d). */
+ windowMs: number;
+ /** Step between points in seconds (86400 = 1 day). */
+ step: number;
+ frontends: HlHistoryFrontend[];
+ /** Unix seconds when the summary was assembled. */
+ asOf: number;
+};
/**
* Fetch a leaderboard-ready slice of every tracked HL builder, in 4
@@ -553,12 +578,186 @@ export async function fetchHlHip3Cohort(): Promise {
return fetchHlHip3CohortCached();
}
+/**
+ * Uncached fetch of the 12-month rolling fees + volume series for the
+ * top ~10 Hyperliquid frontends by current fees_30d. Two range queries
+ * per frontend (fees + volume) daily-stepped over 365d. Parallelised
+ * across frontends; the Prom client's global semaphore caps the burst.
+ *
+ * Ranks by an instant vector on `hl_frontend_fees_usd_30d_v2` first so
+ * we only pay for the range queries on frontends that actually made the
+ * cut, and the leaderboard on the chart stays in sync with the /hyperliquid
+ * cohort ordering. Returns null when Prom is unreachable so the reader
+ * can fall through to whatever snapshot Upstash has instead of poisoning
+ * the ISR cache with an empty chart.
+ *
+ * Exported so the worker can call the uncached Prom path directly before
+ * parking the result in Upstash; the public reader (fetchHlHistory) goes
+ * through the snapshot layer + unstable_cache below.
+ */
+export async function fetchHlHistoryFresh(): Promise {
+ const url = promUrl();
+ if (!url) return null;
+ let prom: Prometheus;
+ try {
+ prom = new Prometheus(url);
+ } catch {
+ return null;
+ }
+
+ const specs = await getSpecs();
+ const hl = specs.find((s) => s.slug === "hyperliquid-frontends");
+ const providers = hl?.providers ?? [];
+ const nameBySlug = new Map(providers.map((p) => [p.slug, p.name]));
+
+ const currentFees = await queryVector(prom, `hl_frontend_fees_usd_30d_v2`);
+ if (!currentFees || currentFees.length === 0) return null;
+
+ const sorted = currentFees
+ .filter((r) => Number.isFinite(r.value) && r.value > 0)
+ .sort((a, b) => b.value - a.value)
+ .slice(0, 10);
+
+ const end = Math.floor(Date.now() / 1000);
+ const start = end - 365 * 86400;
+ const step = 86400;
+
+ const frontends = await Promise.all(
+ sorted.map(async (row) => {
+ const frontend = row.labels.builder;
+ if (!frontend) return null;
+ const [feesRange, volumeRange] = await Promise.all([
+ queryRange(
+ prom,
+ `hl_frontend_fees_usd_30d_v2{builder="${frontend}"}`,
+ start,
+ end,
+ step,
+ ),
+ queryRange(
+ prom,
+ `hl_frontend_volume_usd_30d_v2{builder="${frontend}"}`,
+ start,
+ end,
+ step,
+ ),
+ ]);
+ if (!feesRange && !volumeRange) return null;
+ return {
+ slug: frontend,
+ name: nameBySlug.get(frontend) ?? frontend,
+ fees30d: feesRange ?? [],
+ volume30d: volumeRange ?? [],
+ };
+ }),
+ );
+
+ const filtered = frontends.filter(
+ (f): f is HlHistoryFrontend => f !== null,
+ );
+ if (filtered.length === 0) return null;
+
+ return {
+ windowMs: 365 * 86400 * 1000,
+ step,
+ frontends: filtered,
+ asOf: Math.floor(Date.now() / 1000),
+ };
+}
+
+/** Snapshot-first reader for the 12-month history blob. Same Upstash
+ * → live Prom + writeback → null protocol as the two cohort readers.
+ * The historical data changes slowly (daily stepped), so the
+ * unstable_cache TTL is 1h rather than 60 s — no need to fan out
+ * 20 range queries against Prom on every ISR cycle. */
+async function fetchHlHistoryRaw(): Promise {
+ const snapshot = await readCohortSnapshot(HL_HISTORY_KEY);
+ if (snapshot) return snapshot.data;
+ const fresh = await fetchHlHistoryFresh();
+ if (fresh) {
+ try {
+ await writeCohortSnapshot(HL_HISTORY_KEY, fresh);
+ } catch (err) {
+ console.warn(
+ `hl-history writeback failed: ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ );
+ }
+ }
+ return fresh;
+}
+
+const fetchHlHistoryCached = unstable_cache(
+ fetchHlHistoryRaw,
+ ["hl-history-v1"],
+ { revalidate: 3600, tags: ["hl-cohort", "hl-history"] },
+);
+
+export async function fetchHlHistory(): Promise {
+ return fetchHlHistoryCached();
+}
+
/**
* Tiny vector helper. Returns `[{ labels, value }]` from an instant
* vector query, or empty on error/empty result. Kept local to this
* module because Sprint 2 is the only consumer; the broader Prometheus
* client only needs scalars.
*/
+/** Range query helper: returns evenly-stepped `{ t, v }` points for a
+ * single-series PromQL selector. If Prom returns multiple series (which
+ * shouldn't happen when the selector pins one label value) they're
+ * averaged per timestamp. Missing samples become `null` so the chart
+ * can render a gap rather than interpolating through zero. Returns null
+ * on network / Prom error so the caller can decide to skip the frontend. */
+async function queryRange(
+ prom: Prometheus,
+ promql: string,
+ startSec: number,
+ endSec: number,
+ stepSec: number,
+): Promise {
+ try {
+ const res = await prom.queryRange(
+ promql,
+ new Date(startSec * 1000),
+ new Date(endSec * 1000),
+ stepSec,
+ );
+ if (res.result.length === 0) return [];
+ const buckets = new Map();
+ for (const series of res.result) {
+ for (const [ts, raw] of series.values) {
+ const v = Number(raw);
+ if (!Number.isFinite(v)) continue;
+ const list = buckets.get(ts) ?? [];
+ list.push(v);
+ buckets.set(ts, list);
+ }
+ }
+ const points: HlHistoryPoint[] = [];
+ for (let ts = startSec; ts <= endSec; ts += stepSec) {
+ const vs = buckets.get(ts);
+ if (!vs || vs.length === 0) {
+ points.push({ t: ts * 1000, v: null });
+ } else {
+ const mean = vs.reduce((s, v) => s + v, 0) / vs.length;
+ points.push({
+ t: ts * 1000,
+ v: mean === 0 ? 0 : Number(mean.toPrecision(6)),
+ });
+ }
+ }
+ return points;
+ } catch (err) {
+ const reason = err instanceof Error ? err.message : String(err);
+ console.warn(
+ `prom.queryRange failed (${reason}) for query: ${promql.slice(0, 200)}`,
+ );
+ return null;
+ }
+}
+
async function queryVector(
prom: Prometheus,
promql: string,
diff --git a/worker/index.ts b/worker/index.ts
index 4d064bd38..17955e6b3 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -47,6 +47,7 @@ import { fetchPerpCohortFresh } from "@/lib/perp-stats";
import {
fetchHlCohortFresh,
fetchHlHip3CohortFresh,
+ fetchHlHistoryFresh,
} from "@/lib/hl-builder-stats";
import { fetchPmCohortFresh } from "@/lib/pm-stats";
import { fetchChainKpisFresh } from "@/lib/chain-kpis";
@@ -357,6 +358,7 @@ async function sweep(iteration: number): Promise {
{ key: "perp-cohort", build: () => fetchPerpCohortFresh() },
{ key: "hl-frontends", build: () => fetchHlCohortFresh() },
{ key: "hl-hip3", build: () => fetchHlHip3CohortFresh() },
+ { key: "hl-history", build: () => fetchHlHistoryFresh() },
{ key: "pm-hub", build: () => fetchPmCohortFresh() },
{ key: "search-featured", build: () => buildFeaturedLeadersFromStore() },
// One blob per chain slug so a stale reading on one chain doesn't