diff --git a/benchmarks/aggregator-head-lag.yml b/benchmarks/aggregator-head-lag.yml
index 74d55ba8..ba849e43 100644
--- a/benchmarks/aggregator-head-lag.yml
+++ b/benchmarks/aggregator-head-lag.yml
@@ -70,7 +70,7 @@ methodology:
- "Cardinality: 3 aggregators × 4 chains × 3 regions = 36 active series."
findings:
- - "The cross-chain average puts {{best_name}} on top at {{best_p50}} (p50, 24 h) over {{count}} measured providers. No single provider leads on every chain: on Base the leader is {{best_name:chain:base}} at {{best_p50:chain:base}}; on Solana it is {{best_name:chain:solana}} at {{best_p50:chain:solana}}; on BNB Chain {{best_name:chain:bnb}} at {{best_p50:chain:bnb}}; on Robinhood Chain {{best_name:chain:robinhood}} at {{best_p50:chain:robinhood}}. Read the per-chain tabs for integration decisions — chains differ in block cadence (Solana 400 ms vs Base/BNB 2-3 s), which shifts the floor independently of provider speed."
+ - "Cross-chain, {{best_name}} leads at {{best_p50}} (p50, 24 h) across {{count}} providers, but no one wins every chain: Base {{best_name:chain:base}} at {{best_p50:chain:base}}; Solana {{best_name:chain:solana}} at {{best_p50:chain:solana}}; BNB {{best_name:chain:bnb}} at {{best_p50:chain:bnb}}; Robinhood {{best_name:chain:robinhood}} at {{best_p50:chain:robinhood}}. Use per-chain tabs: block cadence (Solana 400 ms vs Base/BNB 2-3 s) sets the floor."
- "On Solana the leader is {{best_name:chain:solana}} at {{best_p50:chain:solana}} (p50, 24 h); the trailer is {{worst_name:chain:solana}} at {{worst_p50:chain:solana}}. Slot cadence is sub-second, so any second-scale aggregate latency is on the provider's pipeline, not the chain."
- "On Base the leader is {{best_name:chain:base}} at {{best_p50:chain:base}} (p50, 24 h); the trailer is {{worst_name:chain:base}} at {{worst_p50:chain:base}}. Base targets a 2 s block cadence, so head lag here measures pipeline latency on top of the chain's natural block interval."
- "On BNB Chain the leader is {{best_name:chain:bnb}} at {{best_p50:chain:bnb}} (p50, 24 h); the trailer is {{worst_name:chain:bnb}} at {{worst_p50:chain:bnb}}. BNB's ~3 s blocks set the floor; the spread between leader and trailer is the provider-side delta."
diff --git a/src/app/bridge/page.tsx b/src/app/bridge/page.tsx
index 53ca651a..a2fabdbe 100644
--- a/src/app/bridge/page.tsx
+++ b/src/app/bridge/page.tsx
@@ -1,6 +1,6 @@
import Link from "next/link";
import { fetchBridgeHub } from "@/lib/bridge-hub-stats";
-import type { BridgeProviderRow } from "@/lib/bridge-hub-stats";
+import { BridgeHubTable } from "@/components/bridge-hub-table";
import { pageMetadata } from "@/lib/page-metadata";
import { safeJsonLd, buildBreadcrumbJsonLd } from "@/lib/jsonld";
import { SITE } from "@/data/site";
@@ -43,14 +43,6 @@ export default async function BridgeHubPage() {
}
: null;
- const byFee = hub?.providers ?? [];
-
- const bySpeed = hub
- ? [...hub.providers]
- .filter((p) => p.quotep50 != null)
- .sort((a, b) => (a.quotep50 ?? Infinity) - (b.quotep50 ?? Infinity))
- : [];
-
return (
-
+
- Cost ranking
+ Bridge leaderboard
-
- per-corridor breakdown
-
+
+
+ fee breakdown
+
+
+ quote speed breakdown
+
+
- All-in fee at $300 USDC (fees + slippage + destination gas) as
- % of notional. p50 over 24h, cross-corridor average. Lower is
- better.
+ Sorted by fee p50 (all-in cost at $300 USDC). Quote columns from
+ bench 002. Success from fee bench. Trailing 24h, eu-west, cross-corridor average.
-
-
-
-
-
-
- Quote speed ranking
-
-
- per-corridor breakdown
-
-
-
- Time to receive a usable quote, wall-clock ms. p50 over 24h,
- cross-corridor average. Lower is better.
+
+
+ Across is not in the quote latency bench (dashes in quote columns).
+ Per-corridor splits on the individual bench pages.
-
@@ -279,20 +263,19 @@ export default async function BridgeHubPage() {
Methodology
- Cost rows:{" "}
- bridge_cost_percent (all-in: fees + slippage +
- destination gas) at $300 USDC, p50 over the trailing 24h
- averaged across corridors each provider supports. Quote latency
- rows: wall-clock time from request send to last byte received,
- p50 over the trailing 24h via{" "}
+ Fee column: bridge_cost_percent (all-in: fees +
+ slippage + destination gas) at $300 USDC, p50 over the trailing
+ 24h averaged across corridors each provider supports. Quote
+ latency: wall-clock time from request send to last byte received,
+ p50 via{" "}
histogram_quantile(0.50, sum by (le)
(rate(bridge_quote_latency_ms_bucket[24h])))
. Both benches run from eu-west only; multi-region expansion
requires additional harness instances. Failures (quote_failed,
- unsupported route, timeout) are excluded from cost and latency
- aggregates and counted toward success rate.
+ unsupported route, timeout) excluded from aggregates, counted
+ toward success rate.
Data and methodology released under{" "}
@@ -346,103 +329,6 @@ function SummaryCard({
);
}
-function BridgeTable({
- rows,
- metric,
-}: {
- rows: BridgeProviderRow[];
- metric: "fee" | "latency";
-}) {
- if (rows.length === 0) return null;
- return (
-
-
-
-
-
- #
-
-
- Provider
-
-
- Type
-
- {metric === "fee" ? (
- <>
-
- p50 fee
-
-
- p99 fee
-
-
- Success
-
- >
- ) : (
- <>
-
- p50 quote
-
-
- p99 quote
-
-
- Success
-
- >
- )}
-
-
-
- {rows.map((row, i) => {
- const p50 = metric === "fee" ? row.feep50 : row.quotep50;
- const p99 = metric === "fee" ? row.feep99 : row.quotep99;
- const success =
- metric === "fee" ? row.feeSuccess : row.quoteSuccess;
- const fmt = metric === "fee" ? fmtPct : fmtMs;
- return (
-
-
- {i + 1}
-
-
-
- {row.name}
-
-
-
- {row.type && (
-
- {TYPE_LABELS[row.type] ?? row.type}
-
- )}
-
-
- {p50 != null ? fmt(p50) : — }
-
-
- {p99 != null ? fmt(p99) : — }
-
-
- {success != null ? fmtSuccess(success) : — }
-
-
- );
- })}
-
-
-
- );
-}
-
function ArchCard({
label,
examples,
@@ -461,13 +347,6 @@ function ArchCard({
);
}
-const TYPE_LABELS: Record = {
- intent: "Intent layer",
- relay: "Relay",
- aggregator: "Aggregator",
- protocol: "Protocol",
-};
-
function fmtPct(v: number): string {
if (!Number.isFinite(v)) return "...";
return `${v.toFixed(2)}%`;
@@ -478,8 +357,3 @@ function fmtMs(v: number): string {
if (v < 1000) return `${Math.round(v)} ms`;
return `${(v / 1000).toFixed(2)} s`;
}
-
-function fmtSuccess(v: number): string {
- if (!Number.isFinite(v)) return "...";
- return `${(v * 100).toFixed(0)}%`;
-}
diff --git a/src/components/bridge-hub-table.tsx b/src/components/bridge-hub-table.tsx
new file mode 100644
index 00000000..436367d3
--- /dev/null
+++ b/src/components/bridge-hub-table.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import Link from "next/link";
+import { ProviderLogo } from "@/components/provider-logo";
+import type { BridgeProviderRow } from "@/lib/bridge-hub-stats";
+
+const TYPE_LABELS: Record = {
+ intent: "Intent layer",
+ relay: "Relay",
+ aggregator: "Aggregator",
+ protocol: "Protocol",
+};
+
+function fmtPct(v: number | null): string {
+ if (v == null || !Number.isFinite(v)) return "—";
+ return `${v.toFixed(2)}%`;
+}
+
+function fmtMs(v: number | null): string {
+ if (v == null || !Number.isFinite(v)) return "—";
+ if (v < 1000) return `${Math.round(v)} ms`;
+ return `${(v / 1000).toFixed(2)} s`;
+}
+
+function fmtSuccess(v: number | null): string {
+ if (v == null || !Number.isFinite(v)) return "—";
+ // successRate stored as 0-100 in ProviderResult blob
+ return `${v.toFixed(1)}%`;
+}
+
+export function BridgeHubTable({
+ rows,
+}: {
+ rows: BridgeProviderRow[];
+}) {
+ if (rows.length === 0) return null;
+
+ return (
+
+
+
+
+
+ #
+
+
+ Provider
+
+
+ Type
+
+ {/* Fee group */}
+
+ Fee p50
+
+
+ Fee p99
+
+ {/* Latency group */}
+
+ Quote p50
+
+
+ Quote p99
+
+ {/* Success */}
+
+ Success
+
+
+ {/* Sub-header labels */}
+
+
+
+ all-in fee · $300 USDC
+
+
+
+ quote latency
+
+
+ quote latency
+
+
+
+
+
+ {rows.map((row, i) => {
+ const isLeader = i === 0;
+ return (
+
+
+ {isLeader ? (
+
+ 1
+
+ ) : (
+ i + 1
+ )}
+
+
+
+
+
+ {row.name}
+
+
+
+
+ {row.type && (
+
+ {TYPE_LABELS[row.type] ?? row.type}
+
+ )}
+
+ {/* Fee p50 — primary ranking metric */}
+
+ {fmtPct(row.feep50)}
+
+ {/* Fee p99 */}
+
+ {fmtPct(row.feep99)}
+
+ {/* Quote p50 */}
+
+ {fmtMs(row.quotep50)}
+
+ {/* Quote p99 */}
+
+ {fmtMs(row.quotep99)}
+
+ {/* Success */}
+
+
+ {fmtSuccess(row.feeSuccess)}
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/components/share-section.tsx b/src/components/share-section.tsx
index d7131d56..b6398c53 100644
--- a/src/components/share-section.tsx
+++ b/src/components/share-section.tsx
@@ -5,6 +5,24 @@ import { Download, Image as ImageIcon, Loader2, X } from "lucide-react";
import type { Benchmark } from "@/types/benchmark";
import { Hint } from "@/components/hint";
+/** Dimensions the share-card handler on the server understands. Kept in
+ * sync with the pickOption() dims in
+ * src/app/benchmarks/[slug]/share-card/route.tsx. */
+const SCOPE_DIMS = ["chain", "region", "venue", "kind"] as const;
+type ScopeDim = (typeof SCOPE_DIMS)[number];
+
+/** Human-readable label for each dimension dropdown. */
+const DIM_LABEL: Record = {
+ chain: "Chain",
+ region: "Region",
+ venue: "Venue",
+ kind: "Kind",
+};
+
+/** Sentinel value the dropdowns use for "no filter on this dimension".
+ * Same as the bench detail page and the share-card server handler. */
+const ALL_VALUE = "all";
+
type Template = {
id: string;
label: string;
@@ -59,6 +77,69 @@ export function ShareSection({ slug, title, benchmark, chain }: Props) {
const [activeId, setActiveId] = useState("ranking");
const [open, setOpen] = useState(false);
+ // Which dimensions this bench actually declares. Only those get a
+ // dropdown in the modal; the rest are hidden. Kept as a plain object
+ // so template rendering can `dimOptions.venue?.length` without a
+ // per-render `.filter().find()` pass.
+ const dimOptions = useMemo(() => {
+ const out: Partial> = {};
+ for (const dim of SCOPE_DIMS) {
+ const opts = benchmark.dimensions?.[dim] ?? [];
+ // Drop the synthetic "all" option if the spec includes it: the
+ // modal always prepends its own "All" entry as the default so
+ // duplicating breaks the value equality check on select onChange.
+ const filtered = opts.filter((o) => o.value !== ALL_VALUE);
+ if (filtered.length > 0) out[dim] = filtered;
+ }
+ return out;
+ }, [benchmark]);
+ const hasAnyDim = Object.keys(dimOptions).length > 0;
+
+ // Scope state: one entry per SCOPE_DIMS. Default is "all" per dim.
+ // Re-seeded from window.location every time the modal opens, so the
+ // dropdowns start on whatever the reader was filtering on the
+ // dashboard (not surprising) but can be changed freely from inside
+ // the modal.
+ const [scope, setScope] = useState>({
+ chain: chain ?? ALL_VALUE,
+ region: ALL_VALUE,
+ venue: ALL_VALUE,
+ kind: ALL_VALUE,
+ });
+ // Re-seed scope from URL on each open transition. Uses the "adjust
+ // state during render on transition" pattern instead of a useEffect
+ // (react-hooks/set-state-in-effect flags synchronous setState calls
+ // inside effect bodies). `prevOpen` gates the reseed so subsequent
+ // renders while the modal stays open don't clobber the dropdowns.
+ const [prevOpen, setPrevOpen] = useState(false);
+ if (open !== prevOpen) {
+ setPrevOpen(open);
+ if (open && typeof window !== "undefined") {
+ const url = new URL(window.location.href);
+ const next: Record = {
+ chain: url.searchParams.get("chain") || ALL_VALUE,
+ region: url.searchParams.get("region") || ALL_VALUE,
+ venue: url.searchParams.get("venue") || ALL_VALUE,
+ kind: url.searchParams.get("kind") || ALL_VALUE,
+ };
+ // Coerce values the current bench does not offer back to "all".
+ // A chain the reader was previously filtering by can vanish
+ // between deploys (spec edit); silently falling back beats a
+ // broken picker.
+ for (const dim of SCOPE_DIMS) {
+ const opts = dimOptions[dim];
+ if (
+ opts &&
+ next[dim] !== ALL_VALUE &&
+ !opts.some((o) => o.value === next[dim])
+ ) {
+ next[dim] = ALL_VALUE;
+ }
+ }
+ setScope(next);
+ }
+ }
+
// Lock body scroll while modal open and close on Escape.
useEffect(() => {
if (!open) return;
@@ -123,28 +204,19 @@ export function ShareSection({ slug, title, benchmark, chain }: Props) {
setPairB(s);
}
- // Build the URL with the right params per template.
+ // Build the URL with the right params per template. Scope state is
+ // the source of truth: the modal dropdowns own it, so the preview
+ // and download always agree with what the user picked inside the
+ // modal (not what happened to be in window.location at open time).
const cardSrc = (templateId: string) => {
const tpl = TEMPLATES.find((t) => t.id === templateId);
- // Read every dimension filter from the live URL so the share-card
- // stays in sync when the user flips a chain / region / kind / venue
- // tab client-side.
- const liveUrl =
- typeof window !== "undefined"
- ? new URL(window.location.href)
- : null;
- const liveChain = liveUrl
- ? liveUrl.searchParams.get("chain")
- : chain ?? null;
- const chainParam = liveChain ? `&chain=${encodeURIComponent(liveChain)}` : "";
- const liveRegion = liveUrl ? liveUrl.searchParams.get("region") : null;
- const regionParam = liveRegion
- ? `®ion=${encodeURIComponent(liveRegion)}`
- : "";
- const liveKind = liveUrl ? liveUrl.searchParams.get("kind") : null;
- const kindParam = liveKind ? `&kind=${encodeURIComponent(liveKind)}` : "";
- const liveVenue = liveUrl ? liveUrl.searchParams.get("venue") : null;
- const venueParam = liveVenue ? `&venue=${encodeURIComponent(liveVenue)}` : "";
+ const dimParams: string[] = [];
+ for (const dim of SCOPE_DIMS) {
+ const val = scope[dim];
+ if (val && val !== ALL_VALUE) {
+ dimParams.push(`&${dim}=${encodeURIComponent(val)}`);
+ }
+ }
// Mirror the active site theme so the exported PNG matches what the
// user is looking at. SSR can't read the dark state - default to light
// server-side, the client re-renders with `dark` once mounted.
@@ -152,7 +224,7 @@ export function ShareSection({ slug, title, benchmark, chain }: Props) {
typeof window !== "undefined" &&
document.documentElement.classList.contains("dark");
const themeParam = isDark ? "&theme=dark" : "";
- const base = `/benchmarks/${slug}/share-card?template=${templateId}${chainParam}${regionParam}${kindParam}${venueParam}${themeParam}`;
+ const base = `/benchmarks/${slug}/share-card?template=${templateId}${dimParams.join("")}${themeParam}`;
if (!tpl) return base;
if (tpl.pick === "multi") {
if (
@@ -232,6 +304,25 @@ export function ShareSection({ slug, title, benchmark, chain }: Props) {
Pick a layout and download a 1200×630 PNG ready for Twitter, Reddit, LinkedIn or any OG-card embed. Same data, same colors as this dashboard.
+ {hasAnyDim && (
+
+ setScope((prev) => ({ ...prev, [dim]: value }))
+ }
+ onReset={() =>
+ setScope({
+ chain: ALL_VALUE,
+ region: ALL_VALUE,
+ venue: ALL_VALUE,
+ kind: ALL_VALUE,
+ })
+ }
+ slug={slug}
+ />
+ )}
+
{/* Tabs */}
{TEMPLATES.map((t) => (
@@ -435,6 +526,125 @@ export function ShareSection({ slug, title, benchmark, chain }: Props) {
);
}
+/** Dropdown row that lets the reader override chain / region / venue /
+ * kind from inside the modal. Only rendered for dims the bench actually
+ * declares (chain-less benches never see a chain dropdown, etc.).
+ *
+ * Pings /api/stat/
? after each pick so the user sees a
+ * "no data for this cell" hint before wasting a PNG download. The stat
+ * endpoint returns 404 on unknown filter combos (see route.ts comment
+ * "does NOT fall back to the unfiltered aggregate"), which is exactly
+ * the signal we need. */
+function ScopePicker({
+ dimOptions,
+ scope,
+ onChange,
+ onReset,
+ slug,
+}: {
+ dimOptions: Partial>;
+ scope: Record;
+ onChange: (dim: ScopeDim, value: string) => void;
+ onReset: () => void;
+ slug: string;
+}) {
+ const activeDims = SCOPE_DIMS.filter((d) => dimOptions[d]);
+ const hasFilter = activeDims.some((d) => scope[d] !== ALL_VALUE);
+
+ // Data-existence probe. `probe.key` is the URL we last resolved; the
+ // rendered status is derived (below), so the effect body only needs
+ // async setState inside .then() / .catch() callbacks — no sync
+ // setState-in-effect (blocked by react-hooks/set-state-in-effect).
+ const probeKey = useMemo(() => {
+ if (!hasFilter) return "";
+ const qs = new URLSearchParams();
+ for (const d of activeDims) {
+ if (scope[d] !== ALL_VALUE) qs.set(d, scope[d]);
+ }
+ return `/api/stat/${slug}?${qs.toString()}`;
+ }, [slug, activeDims, scope, hasFilter]);
+ const [probe, setProbe] = useState<{
+ key: string;
+ result: "ok" | "empty" | null;
+ }>({ key: "", result: null });
+ const status: "ok" | "empty" | "checking" | "idle" = !hasFilter
+ ? "idle"
+ : probe.key !== probeKey || probe.result == null
+ ? "checking"
+ : probe.result;
+
+ useEffect(() => {
+ if (!probeKey) return;
+ const ctrl = new AbortController();
+ fetch(probeKey, { signal: ctrl.signal })
+ .then((r) => {
+ if (r.status === 404) return { rankings: [] as unknown[] };
+ return r.json();
+ })
+ .then((json: { rankings?: unknown[]; leader?: unknown } | undefined) => {
+ const hasRows =
+ !!json && Array.isArray(json.rankings) && json.rankings.length > 0;
+ setProbe({ key: probeKey, result: hasRows ? "ok" : "empty" });
+ })
+ .catch((err) => {
+ if ((err as Error).name === "AbortError") return;
+ setProbe({ key: probeKey, result: "empty" });
+ });
+ return () => ctrl.abort();
+ }, [probeKey]);
+
+ return (
+
+
+
+ Scope
+
+ {activeDims.map((dim) => {
+ const opts = dimOptions[dim]!;
+ return (
+
+
+ {DIM_LABEL[dim]}
+
+ onChange(dim, e.target.value)}
+ className="rounded border border-rule bg-paper px-2 py-1 text-xs text-ink focus:outline-none focus:ring-1 focus:ring-ink/20"
+ >
+ All
+ {opts.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ );
+ })}
+ {hasFilter && (
+
+ Reset
+
+ )}
+
+ {status === "checking" && (
+
+ Checking data for this cell…
+
+ )}
+ {status === "empty" && (
+
+ No data for this scope. Card will fall back to the aggregate.
+
+ )}
+
+ );
+}
+
/** Preview frame with a "Generating preview…" overlay while the PNG loads. */
function SharePreview({ src, alt }: { src: string; alt: string }) {
const [loaded, setLoaded] = useState(false);
diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx
index 0c62dc1f..3a622cab 100644
--- a/src/components/site-header.tsx
+++ b/src/components/site-header.tsx
@@ -8,6 +8,14 @@ import { SearchTrigger } from "@/components/search/search-trigger";
import { SiteLogoSwitcher } from "@/components/site-logo-switcher";
import { ThemeToggle } from "@/components/theme-toggle";
+function XIcon({ size = 15 }: { size?: number }) {
+ return (
+
+
+
+ );
+}
+
function GithubIcon({ size = 15 }: { size?: number }) {
return (
@@ -120,8 +128,17 @@ export function SiteHeader() {
- {/* Utilities - GitHub + theme, gap-spaced, no pipe. */}
+ {/* Utilities - X + GitHub + theme, gap-spaced, no pipe. */}