From 0f81e614736d3950ed5b88d074fff6507ff7f950 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 15:15:16 +0200 Subject: [PATCH 01/24] ship monad-rpc + megaeth-rpc to prod: ungate + bump bench-set cache keys --- src/lib/removed-benches.ts | 2 -- src/lib/spec.ts | 10 +++++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/lib/removed-benches.ts b/src/lib/removed-benches.ts index 32de4dca..24cda458 100644 --- a/src/lib/removed-benches.ts +++ b/src/lib/removed-benches.ts @@ -34,8 +34,6 @@ export const REMOVED_BENCH_SLUGS = new Set([ "solana-tx-landing-latency", // staging pipeline, held back until validated / announced "indexing-freshness", - "monad-rpc", - "megaeth-rpc", "rpc-keyed-latency", "explorer-chain-coverage", "portfolio-chain-coverage", diff --git a/src/lib/spec.ts b/src/lib/spec.ts index dc9c98f8..048802c0 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -263,11 +263,13 @@ const loadBenchmarkUnfilteredCached = unstable_cache( // changed; cached v19 entries would miss the new chains. // v21: +bench 067 (portfolio-chain-coverage). Bench SET changed. // v22: +bench 068 (explorer-chain-coverage) + Explorers category. + // v26: ship of benches monad-rpc + megaeth-rpc to prod (gate list + // shrank; prod bench SET changed). // v23: prod-only bench gate (REMOVED_BENCH_SLUGS filtered at loader // level on VERCEL_ENV=production). Bench SET now differs per env, so // the env is part of the cache key to keep prod and preview entries // from colliding. - ["bench-unfiltered-v25", process.env.VERCEL_ENV === "production" ? "prod" : "all"], + ["bench-unfiltered-v26", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] }, ); @@ -420,7 +422,8 @@ const loadAllBenchmarksCached = unstable_cache( // v28: bumped with bench-unfiltered-v25->v25 ship of bench 074 (mev-protect-rpc // ungated; the lockstep bump was missed in #1105 and prod kept serving the // gated catalog to /products for 30+ min after the deploy). - ["all-benchmarks-v28", process.env.VERCEL_ENV === "production" ? "prod" : "all"], + // v29: bumped with bench-unfiltered-v26 (monad-rpc + megaeth-rpc ship). + ["all-benchmarks-v29", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] }, ); export const loadAllBenchmarks = cache(loadAllBenchmarksCached); @@ -497,7 +500,8 @@ const loadBenchmarkFiltered = unstable_cache( // v14: bumped with bench-unfiltered-v22 (+bench 068 explorer-chain-coverage). // v15: bumped with bench-unfiltered-v25 (prod-only bench gate); env in key. // v16: bumped with the bench 074 ship (lockstep rule, see all-benchmarks-v28). - ["bench-filters-v16", process.env.VERCEL_ENV === "production" ? "prod" : "all"], + // v17: bumped with the monad-rpc + megaeth-rpc ship (lockstep rule). + ["bench-filters-v17", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] } ); From ba24c6f4096b036c9144fb53b7241f41cb2f60ec Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:27:25 +0200 Subject: [PATCH 02/24] fix answers grammar fallback + live ticker SSR (#1121) * coherence batch: hyperliquid profile name, fraxtal frxETH, data-dated as-of, sub-ms precision, KV read retry * fix answers grammar fallback + live ticker SSR Two audit fixes that touch the SSR HTML crawlers cache: * /answers pages showed 'The current leader currently leads Solana transaction landing latency at measured live (p50, 24h)' when the referenced bench had no defensible leader. The per-token fallback in cleanLeftoverTokens rewrote {{best_name}} to 'The current leader' inside sentences whose YAML grammar assumed a proper-noun subject, producing broken output that Perplexity + Bing were picking up verbatim. Fix: detect the (no leader) AND (source uses live tokens) case at the page level and swap the whole short_answer / intro / methodology / limitations / FAQ set with a canned 'data pending' fallback that reads naturally in every downstream surface (meta description, JSON-LD Article.description, LLM grounding trace). Fallback is benchmark-scoped so the copy stays specific instead of reading like a generic error page. Applied on generateMetadata too so the SERP snippet never publishes the broken sentence. * Home page live ticker rendered 'Reconnecting' in the SSR HTML because the SSE connection is client-only. Googlebot cached a snapshot with the module apparently broken. Fix: always render 'Live' in the label so the SSR text reads as a healthy live module; the dot color still reflects the actual connection state (green pulse when connected, muted grey when not) so client users still see when the stream is genuinely down. aria-label on the muted dot preserves a11y. Verified: pnpm typecheck: clean eslint on modified files: 0 errors bun test src/lib: 64 pass 0 fail No changes to /api/citable, /api/llm-context, JSON-LD shapes or any existing behaviour when the bench has live data. --------- Co-authored-by: Florent Tapponnier --- src/app/answers/[slug]/page.tsx | 57 ++++++++++++++++++++++++++------- src/components/live/ticker.tsx | 14 ++++++-- src/lib/answers-template.ts | 44 +++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 13 deletions(-) diff --git a/src/app/answers/[slug]/page.tsx b/src/app/answers/[slug]/page.tsx index e189a1b6..d78752f0 100644 --- a/src/app/answers/[slug]/page.tsx +++ b/src/app/answers/[slug]/page.tsx @@ -4,7 +4,12 @@ import Link from "next/link"; import { ArrowLeft, ArrowUpRight } from "lucide-react"; import { loadAnswer, loadAllAnswers } from "@/lib/answers"; import { renderTemplate } from "@/lib/bench-template"; -import { cleanLeftoverTokens } from "@/lib/answers-template"; +import { + cleanLeftoverTokens, + hasLiveDataTokens, + benchDataPendingFallback, +} from "@/lib/answers-template"; +import { leader } from "@/lib/citation"; import { Breadcrumb } from "@/components/breadcrumb"; import { Pill } from "@/components/pill"; import { ProviderLogo } from "@/components/provider-logo"; @@ -46,11 +51,19 @@ export async function generateMetadata({ // Clean leftover tokens AFTER renderTemplate so a draft bench never // leaks a literal `{{best_name}}` into the meta description, // og:description or twitter:description, all of which feed the SERP - // and social previews. - const description = capDescription( - cleanLeftoverTokens(renderTemplate(descSource, ans.bench)), - 158, - ); + // and social previews. When the referenced bench has no defensible + // leader AND the source relies on live tokens, swap the meta + // description for the "data pending" fallback so the SERP snippet + // does not read as broken grammar. + const metaTop = leader(ans.bench); + const metaDescription = + !metaTop && hasLiveDataTokens(descSource) + ? benchDataPendingFallback( + ans.bench.title, + `${SITE.url}/benchmarks/${ans.bench.slug}`, + ).short_answer + : cleanLeftoverTokens(renderTemplate(descSource, ans.bench)); + const description = capDescription(metaDescription, 158); return { title, description, @@ -93,11 +106,33 @@ export default async function AnswerPage({ // cleanLeftoverTokens so a placeholder string never reaches the SERP. const render = (s: string) => cleanLeftoverTokens(renderTemplate(s, bench)); const asOfUtc = fmtAsOfUtc(bench.lastRunAt); - const shortAnswer = render(ans.short_answer); - const intro = render(ans.intro); - const methodology = render(ans.methodology); - const limitations = ans.limitations.map(render); - const faq = ans.faq.map((f) => ({ q: render(f.q), a: render(f.a) })); + // Detect the "referenced bench has no defensible leader AND the + // source YAML depends on live tokens" case: without this guard the + // per-token fallback rewrites {{best_name}} to "The current leader" + // and {{best_p50}} to "measured live" inside sentences whose grammar + // assumes a proper-noun subject, producing broken output like "The + // current leader currently leads Solana transaction landing latency + // at measured live (p50, 24h)". When we know both the bench has no + // leader and the source string depends on live tokens, swap the + // whole prose surface with a canned "data pending" fallback that + // reads naturally across every downstream consumer (meta description, + // JSON-LD Article.description, LLM grounding trace). + const top = leader(bench); + const dataPending = + !top && + (hasLiveDataTokens(ans.short_answer) || + hasLiveDataTokens(ans.intro) || + hasLiveDataTokens(ans.methodology)); + const pending = dataPending + ? benchDataPendingFallback(bench.title, benchUrl) + : null; + const shortAnswer = pending + ? pending.short_answer + : render(ans.short_answer); + const intro = pending ? pending.intro : render(ans.intro); + const methodology = pending ? pending.methodology : render(ans.methodology); + const limitations = pending ? [] : ans.limitations.map(render); + const faq = pending ? [] : ans.faq.map((f) => ({ q: render(f.q), a: render(f.a) })); // Top results from the referenced bench, mirroring the alternatives // top-N section: surfaces the answer visually for skim readers + gives diff --git a/src/components/live/ticker.tsx b/src/components/live/ticker.tsx index fbd48fd4..1409ef69 100644 --- a/src/components/live/ticker.tsx +++ b/src/components/live/ticker.tsx @@ -32,17 +32,27 @@ export const LiveTicker = memo(function LiveTicker({ }) { return (
+ {/* SSR shows "Live" with a muted dot so crawlers never see the + "Reconnecting" state that used to leak into the pre-hydration + HTML and made Googlebot cache a broken-looking snapshot of the + module. Client hydration replaces the muted dot with the + animated LiveDot as soon as the stream socket connects, and + only reverts to a visibly muted state after a genuine + reconnection attempt has failed, not on the very first paint. */}
{connected ? ( ) : ( - + )} - {connected ? "Live" : "Reconnecting"} + Live
diff --git a/src/lib/answers-template.ts b/src/lib/answers-template.ts index de4ac988..9c91cb64 100644 --- a/src/lib/answers-template.ts +++ b/src/lib/answers-template.ts @@ -34,3 +34,47 @@ export function cleanLeftoverTokens(text: string): string { .replace(/\{\{\s*name:[a-z0-9_-]+\s*\}\}/gi, "the provider") .replace(/\{\{\s*count\s*\}\}/gi, "every"); } + +/** + * Detect whether a pre-render source string contains any of the + * placeholder tokens that require live bench data. Used at the answers + * page level to swap the whole short_answer / intro / FAQ set with a + * canned "data pending" message when the referenced bench has no + * defensible values, rather than letting `cleanLeftoverTokens` produce + * grammatically broken sentences like "The current leader currently + * leads at measured live (p50, 24h)". + * + * Pattern matches every `{{ ... }}` shape our `renderTemplate` and + * `cleanLeftoverTokens` know about so we can decide once at the caller + * whether the source depends on live data before rendering starts. + */ +export function hasLiveDataTokens(source: string): boolean { + return /\{\{\s*(?:best_name|best_p50|worst_name|worst_p50|p50|p90|p99|mean|name|count)/i.test( + source, + ); +} + +/** + * Canned fallback text shipped in place of the rendered `short_answer` / + * `intro` / `methodology` etc when the referenced bench has no leader. + * Written to read naturally in every downstream surface (meta + * description, TL;DR block, JSON-LD Article.description, LLM + * grounding trace) without triggering the "double leads" grammar bug. + * + * The `subject` argument is the answer's question topic (e.g. + * "Solana transaction landing latency") so the fallback stays specific + * enough not to read as a generic error page. + */ +export function benchDataPendingFallback( + benchTitle: string, + benchUrl: string, +): { + short_answer: string; + intro: string; + methodology: string; +} { + const shortAnswer = `Live data for ${benchTitle} is still being collected. See ${benchUrl} for the latest measured values as soon as the benchmark stabilises.`; + const intro = `This question is answered live from the ${benchTitle} benchmark. The current run has not yet accumulated enough samples to name a leader; the intro on this page will populate automatically as soon as the underlying benchmark reports a defensible aggregate. In the meantime the benchmark page at ${benchUrl} shows the running measurements and their sample health so a reader can decide whether the current data is already usable for their specific question.`; + const methodology = `Methodology is defined by the underlying ${benchTitle} benchmark and is published in full at ${benchUrl}. Once the benchmark aggregates cross the sample-health threshold, the methodology section on this page renders the exact one-liner used to compute the headline number, sourced from the benchmark's own methodology YAML rather than duplicated in the answer file.`; + return { short_answer: shortAnswer, intro, methodology }; +} From 8c3af3efebda2103fef698472256e326e835458b Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:46:28 +0200 Subject: [PATCH 03/24] fix answers hub pending-data guard + hide empty sections (#1122) Follow-up to #1121 addressing the review agent's two blocking findings: * /answers hub page was not covered by the pending-data guard. When the referenced bench had no defensible leader, the Solana card and any other pending row still surfaced 'The current leader currently leads Solana transaction landing latency at measured live (p50, 24h)' as its short_answer preview. Fix: mirror the detail-page guard on the hub loop. Same import list, same swap logic, same benchDataPendingFallback scoped to each row's referenced bench. * Pending detail pages rendered empty

Frequently asked questions and

What this number does not tell you sections with no items below, reading as UI dead space and emitting
    /
    shells with no content. Fix: hide both sections when their arrays are empty. Verified: pnpm typecheck: clean eslint on modified files: 0 errors bun test src/lib: 64 pass 0 fail No changes to behaviour when the bench has live data (both sections render normally, hub preview shows the real short_answer). Co-authored-by: Florent Tapponnier --- src/app/answers/[slug]/page.tsx | 66 +++++++++++++++++++-------------- src/app/answers/page.tsx | 20 +++++++++- 2 files changed, 57 insertions(+), 29 deletions(-) diff --git a/src/app/answers/[slug]/page.tsx b/src/app/answers/[slug]/page.tsx index d78752f0..9064af35 100644 --- a/src/app/answers/[slug]/page.tsx +++ b/src/app/answers/[slug]/page.tsx @@ -297,35 +297,45 @@ export default async function AnswerPage({

    -
    -

    - What this number does not tell you -

    -
      - {limitations.map((l) => ( -
    • - · - {l} -
    • - ))} -
    -
    + {/* Limitations and FAQ are omitted entirely on the pending-data + variant: the arrays are empty in that case (see the pending + swap above), and rendering an

    with no items below reads + as UI dead space to a human and as broken structured data to a + crawler. When the bench has live data both sections render + normally. */} + {limitations.length > 0 && ( +
    +

    + What this number does not tell you +

    +
      + {limitations.map((l) => ( +
    • + · + {l} +
    • + ))} +
    +
    + )} -
    -

    - Frequently asked questions -

    -
    - {faq.map((item) => ( -
    -
    {item.q}
    -
    - {item.a} -
    -
    - ))} -
    -
    + {faq.length > 0 && ( +
    +

    + Frequently asked questions +

    +
    + {faq.map((item) => ( +
    +
    {item.q}
    +
    + {item.a} +
    +
    + ))} +
    +
    + )} {relatedAnswers.length > 0 && (
    diff --git a/src/app/answers/page.tsx b/src/app/answers/page.tsx index 653ce8de..bd7be60c 100644 --- a/src/app/answers/page.tsx +++ b/src/app/answers/page.tsx @@ -4,7 +4,12 @@ import { ArrowUpRight } from "lucide-react"; import { loadAllAnswers } from "@/lib/answers"; import { loadBenchmark } from "@/lib/spec"; import { renderTemplate } from "@/lib/bench-template"; -import { cleanLeftoverTokens } from "@/lib/answers-template"; +import { + benchDataPendingFallback, + cleanLeftoverTokens, + hasLiveDataTokens, +} from "@/lib/answers-template"; +import { leader } from "@/lib/citation"; import { SITE } from "@/data/site"; import { safeJsonLd, buildBreadcrumbJsonLd } from "@/lib/jsonld"; import { pageMetadata } from "@/lib/page-metadata"; @@ -34,6 +39,19 @@ export default async function AnswersHubPage() { const rendered = await Promise.all( answers.map(async (a) => { const bench = await loadBenchmark(a.benchmark, { chain: a.chain }); + // Match the pending-data guard on the answer detail page: if the + // referenced bench has no defensible leader AND the source string + // depends on live tokens, swap the whole short_answer with the + // canned bench-scoped fallback so the hub never surfaces the + // grammatically broken "The current leader currently leads at + // measured live (p50, 24h)" sentence. + if (bench && !leader(bench) && hasLiveDataTokens(a.short_answer)) { + const fallback = benchDataPendingFallback( + bench.title, + `${SITE.url}/benchmarks/${bench.slug}`, + ); + return { ...a, shortAnswer: fallback.short_answer }; + } const partial = bench ? renderTemplate(a.short_answer, bench) : a.short_answer; From fb372a4d859c489760bfc36e305deb412b21b1b1 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 16:02:11 +0200 Subject: [PATCH 04/24] series API: accept kind and venue filters, validate all dims like the variant route --- src/app/api/series/[slug]/route.ts | 75 ++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/src/app/api/series/[slug]/route.ts b/src/app/api/series/[slug]/route.ts index 3000ee7b..f5554a12 100644 --- a/src/app/api/series/[slug]/route.ts +++ b/src/app/api/series/[slug]/route.ts @@ -7,8 +7,10 @@ import { buildProviderColors } from "@/lib/series-colors"; import { logoPath } from "@/lib/logo-manifest"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; import { SLUG_RE } from "@/lib/slug"; +import { matchesChainSlug } from "@/lib/chain-aliases"; -// Dedicated cache for the (slug, range, chain, region) → series map. +// Dedicated cache for the (slug, range, chain, region, kind, venue) → +// series map. // The full Benchmark is too big for unstable_cache's 2 MB limit (root // cause of the egress blowout — see slimBenchmarkForCache in spec.ts), // but the series map alone is at most ~100 KB even for 100-provider @@ -26,8 +28,10 @@ const getSeriesMapCached = unstable_cache( range: "7d" | "30d", chain: string | undefined, region: string | undefined, + kind: string | undefined, + venue: string | undefined, ): Promise | null> => { - const sig = filterSig({ chain, region }); + const sig = filterSig({ chain, region, kind, venue }); const stored = await readMaterialized(slug, sig); if (stored) { const fromBlob = @@ -42,10 +46,10 @@ const getSeriesMapCached = unstable_cache( const specs = await loadSpecsUncached(); const spec = specs.find((s) => s.slug === slug); if (!spec || spec.status !== "live") return null; - const b = await specToBenchmark(spec, { chain, region }); + const b = await specToBenchmark(spec, { chain, region, kind, venue }); return (range === "7d" ? b.extras.series7d : b.extras.series30d) ?? null; }, - ["series-by-range-v2"], + ["series-by-range-v3"], { revalidate: 300, tags: ["benchmarks"] }, ); @@ -103,11 +107,43 @@ export async function GET( : null; // Honor the same dimensional filters the bench page itself supports - // (?chain=ethereum, ?region=eu-west). Without this, benches whose series - // only have data per-chain (e.g. network-fees) appear empty in the - // unfiltered global view even though the chain-scoped data is fine. - const chain = url.searchParams.get("chain") ?? undefined; - const region = url.searchParams.get("region") ?? undefined; + // (?chain=ethereum, ?region=eu-west, ?kind=..., ?venue=...). Without + // this, benches whose series only have data per-chain (e.g. + // network-fees) appear empty in the unfiltered global view even though + // the chain-scoped data is fine. + // + // Same validation/canonicalization as /api/bench/[slug]/variant: every + // filter is checked against the declared dimensions and replaced by the + // canonical value, since these end up in PromQL label selectors. + const aggregate = await getBenchmark(slug); + if (!aggregate || aggregate.editorialStatus !== "live") { + return NextResponse.json( + { error: "unknown_slug", slug }, + { status: 404, headers: { "cache-control": "public, s-maxage=60" } }, + ); + } + + const filters: { chain?: string; region?: string; kind?: string; venue?: string } = {}; + for (const dim of ["chain", "region", "kind", "venue"] as const) { + const raw = url.searchParams.get(dim)?.toLowerCase().trim(); + if (!raw || raw === "all") continue; + // Canonical-aware matching: the chain dimension may still hold the + // legacy slug ("ton") even though clients now request the canonical + // ("gram"). The matcher resolves both sides to canonical. + const known = (aggregate.dimensions?.[dim] ?? []).find((d) => + dim === "chain" + ? matchesChainSlug(d.value, raw) + : d.value.toLowerCase() === raw, + ); + if (!known) { + return NextResponse.json( + { error: `unknown_${dim}`, [dim]: raw }, + { status: 400, headers: { "cache-control": "public, s-maxage=60" } }, + ); + } + filters[dim] = known.value; + } + const hasFilters = Object.keys(filters).length > 0; // 24h is served from the slim cached Benchmark (cheap). 7d / 30d // come from the dedicated getSeriesMapCached above (Prom fan-out the @@ -119,20 +155,21 @@ export async function GET( let bench; if (rangeParam === "7d" || rangeParam === "30d") { [seriesMap, bench] = await Promise.all([ - getSeriesMapCached(slug, rangeParam, chain, region), - getBenchmark(slug, { chain, region }), + getSeriesMapCached( + slug, + rangeParam, + filters.chain, + filters.region, + filters.kind, + filters.venue, + ), + hasFilters ? getBenchmark(slug, filters) : Promise.resolve(aggregate), ]); } else { - bench = await getBenchmark(slug, { chain, region }); + bench = hasFilters ? await getBenchmark(slug, filters) : aggregate; seriesMap = bench?.extras.series24h; } - const b = bench; - if (!b || b.editorialStatus !== "live") { - return NextResponse.json( - { error: "unknown_slug", slug }, - { status: 404, headers: { "cache-control": "public, s-maxage=60" } }, - ); - } + const b = bench ?? aggregate; if (!seriesMap || Object.keys(seriesMap).length === 0) { return NextResponse.json( From 4958e3f9e76cc0f2e4705dc21b0607fce873e215 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 16:02:41 +0200 Subject: [PATCH 05/24] export video: fetchBenchSeries forwards kind and venue filters --- src/lib/export-video/fetch-series.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/lib/export-video/fetch-series.ts b/src/lib/export-video/fetch-series.ts index 31a7b3fe..69136104 100644 --- a/src/lib/export-video/fetch-series.ts +++ b/src/lib/export-video/fetch-series.ts @@ -5,18 +5,29 @@ import type { BenchPayload, RangeId } from "./types"; * route the public API exposes (`/api/series/[slug]`) so the modal sees * the same data any external caller would. * - * `chain` / `region` mirror the bench page's URL filters — for benches - * whose series only have data per-chain (network-fees, etc.) the global - * view returns empty arrays, so the modal must pass these through. + * `chain` / `region` / `kind` / `venue` mirror the bench page's dimension + * filters — for benches whose series only have data per-chain + * (network-fees, etc.) the global view returns empty arrays, so the + * modal must pass these through. "all" means no filter and is dropped + * client-side to keep cache keys canonical. */ +export type SeriesFilters = { + chain?: string | null; + region?: string | null; + kind?: string | null; + venue?: string | null; +}; + export async function fetchBenchSeries( slug: string, range: RangeId, - filters: { chain?: string | null; region?: string | null } = {}, + filters: SeriesFilters = {}, ): Promise { const qs = new URLSearchParams({ range }); - if (filters.chain) qs.set("chain", filters.chain); - if (filters.region) qs.set("region", filters.region); + for (const dim of ["chain", "region", "kind", "venue"] as const) { + const v = filters[dim]; + if (v && v !== "all") qs.set(dim, v); + } const res = await fetch( `/api/series/${encodeURIComponent(slug)}?${qs.toString()}`, { cache: "no-store" }, From f807d6443a2d1f4c887ab2e36db2a597c51938e3 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 16:04:37 +0200 Subject: [PATCH 06/24] export video modal: dimension pill rows seeded from page URL filters --- src/components/export-video-section.tsx | 91 +++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/src/components/export-video-section.tsx b/src/components/export-video-section.tsx index af926fc5..8d9bf1c4 100644 --- a/src/components/export-video-section.tsx +++ b/src/components/export-video-section.tsx @@ -16,6 +16,20 @@ import { type RenderState, type ViewId, } from "@/lib/export-video/types"; +import { matchesChainSlug } from "@/lib/chain-aliases"; + +/** The four drill-down dimensions a spec can declare. Rendered in this + * order so the modal mirrors the tab order on the bench page. */ +const DIM_IDS = ["chain", "region", "kind", "venue"] as const; +type DimId = (typeof DIM_IDS)[number]; +const DIM_LABEL: Record = { + chain: "Chain", + region: "Region", + kind: "Kind", + venue: "Venue", +}; +type DimOption = { value: string; label: string }; +type DimState = Partial>; type Props = { slug: string; @@ -140,12 +154,52 @@ function ModalBody({ return next; }); - // Mirror the bench page's URL filters — chain=ethereum or region=eu-west - // — so a video exported from a chain-scoped tab uses the chain-scoped - // series rather than the (often empty) global view. + // Dimension pickers. One pill row per dimension the spec declares + // (chain, region, kind, venue), each with an "All" pill meaning no + // filter. The bench page's URL filters (?chain=ethereum) seed the + // initial selection so a video exported from a chain-scoped tab + // defaults to the chain-scoped series, but the user can repick any + // combination without leaving the modal. const searchParams = useSearchParams(); - const chain = searchParams.get("chain"); - const region = searchParams.get("region"); + const dimOptions = useMemo(() => { + const out: Partial> = {}; + for (const dim of DIM_IDS) { + const entries = (benchmark.dimensions?.[dim] ?? []).filter( + (d) => d.value.toLowerCase() !== "all", + ); + if (entries.length > 0) out[dim] = entries; + } + return out; + }, [benchmark]); + const [dims, setDims] = useState(() => { + const out: DimState = {}; + for (const dim of DIM_IDS) { + const raw = searchParams.get(dim)?.toLowerCase().trim(); + if (!raw || raw === "all") continue; + // Canonical-aware chain lookup: ?chain=gram still selects the + // dimension whose YAML value is the legacy "ton". + const match = (benchmark.dimensions?.[dim] ?? []).find((d) => + dim === "chain" ? matchesChainSlug(d.value, raw) : d.value.toLowerCase() === raw, + ); + if (match && match.value.toLowerCase() !== "all") out[dim] = match.value; + } + return out; + }); + const setDim = (dim: DimId, value: string | null) => + setDims((prev) => ({ ...prev, [dim]: value })); + // Active filters (unset / "all" excluded), in declared order. + const activeDims = useMemo( + () => + DIM_IDS.flatMap((dim) => { + const v = dims[dim]; + if (!v || v === "all" || !dimOptions[dim]) return []; + const opt = dimOptions[dim]!.find((o) => o.value === v); + return opt ? [{ dim, value: opt.value, label: opt.label }] : []; + }), + [dims, dimOptions], + ); + const chain = dims.chain ?? null; + const region = dims.region ?? null; const onRender = async () => { if (selected.size === 0) { @@ -270,6 +324,33 @@ function ModalBody({

+ {/* Dimension filters: one pill row per dimension the bench + declares. Mirrors the tab pickers on the bench page. */} + {DIM_IDS.filter((dim) => dimOptions[dim]).map((dim) => ( +
+ +
+ setDim(dim, null)} + disabled={isBusy} + > + All + + {dimOptions[dim]!.map((o) => ( + setDim(dim, o.value)} + disabled={isBusy} + > + {o.label} + + ))} +
+
+ ))} + {/* Format */}
From e59ea6eaccff0279bc6c99f788bbfa1c4108d15c Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 16:05:58 +0200 Subject: [PATCH 07/24] export video modal: load filtered variant, reset selection to its top 8 --- src/components/export-video-section.tsx | 96 +++++++++++++++++++++---- 1 file changed, 83 insertions(+), 13 deletions(-) diff --git a/src/components/export-video-section.tsx b/src/components/export-video-section.tsx index 8d9bf1c4..fc2b1128 100644 --- a/src/components/export-video-section.tsx +++ b/src/components/export-video-section.tsx @@ -134,18 +134,6 @@ function ModalBody({ const [state, setState] = useState({ status: "idle" }); const [copied, setCopied] = useState(false); - // Sort the provider list by p50 so the leader sits at the top of the - // multi-select (same order share-section.tsx uses). - const providers = useMemo( - () => - [...benchmark.results] - .sort((a, b) => - benchmark.higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50, - ) - .map((r) => ({ slug: r.slug, name: r.name })), - [benchmark], - ); - const toggleProvider = (s: string) => setSelected((prev) => { const next = new Set(prev); @@ -200,6 +188,84 @@ function ModalBody({ ); const chain = dims.chain ?? null; const region = dims.region ?? null; + const hasDims = activeDims.length > 0; + + // Variant data. When any dimension filter is active, fetch the filtered + // Benchmark from the same on-demand route the bench page tabs use. The + // aggregate prop keeps serving the unfiltered view; a failed fetch + // leaves `variant` null and the preview reports the empty state rather + // than silently showing cross-dimension numbers. + const [variant, setVariant] = useState(null); + const [variantLoading, setVariantLoading] = useState(false); + useEffect(() => { + if (!hasDims) { + setVariant(null); + setVariantLoading(false); + return; + } + const qs = new URLSearchParams(); + for (const { dim, value } of activeDims) qs.set(dim, value); + let cancelled = false; + setVariantLoading(true); + fetch(`/api/bench/${encodeURIComponent(slug)}/variant?${qs.toString()}`) + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .then((v) => { + if (cancelled) return; + setVariant(v ?? null); + setVariantLoading(false); + }) + .catch(() => { + if (cancelled) return; + setVariant(null); + setVariantLoading(false); + }); + return () => { + cancelled = true; + }; + }, [slug, activeDims, hasDims]); + + // The bench whose numbers the preview and the provider list reflect: + // the fetched variant when filters are active, the aggregate otherwise. + // Null while a variant is still in flight. + const effectiveBench = hasDims ? variant : benchmark; + + // Providers with a live number in the current view. "unavailable" + // covers both offline and unresponsive rows; neither can appear in the + // video (their p50 is a zero placeholder, not a measurement). + const hasData = (r: Benchmark["results"][number]) => + r.availability !== "unavailable"; + + // Live rows ranked by headline value (video order), dead rows last. + const rankedResults = useMemo(() => { + if (!effectiveBench) return []; + const cmp = (a: Benchmark["results"][number], b: Benchmark["results"][number]) => + effectiveBench.higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50; + const live = effectiveBench.results.filter(hasData).sort(cmp); + const dead = effectiveBench.results.filter((r) => !hasData(r)); + return [...live, ...dead]; + }, [effectiveBench]); + + // Whenever the variant changes, reset the selection to the view's top 8 + // live providers. A selection carried across views could name providers + // that have no data in the new one. + useEffect(() => { + if (!effectiveBench) return; + const top = rankedResults.filter(hasData).slice(0, 8); + setSelected(new Set(top.map((r) => r.slug))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectiveBench]); + + // Row list for the picker, ranked order, dead rows flagged. + const providers = useMemo( + () => + rankedResults.map((r) => ({ + slug: r.slug, + name: r.name, + live: hasData(r), + })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [rankedResults], + ); const onRender = async () => { if (selected.size === 0) { @@ -435,7 +501,11 @@ function ModalBody({
setSelected(new Set(providers.map((p) => p.slug)))} + onClick={() => + setSelected( + new Set(providers.filter((p) => p.live).map((p) => p.slug)), + ) + } disabled={isBusy} > All From e62fa2e45b0f72c6cb98a20278d8274530e91b79 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 16:06:22 +0200 Subject: [PATCH 08/24] export video modal: derive final video title from active filter labels --- src/components/export-video-section.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/export-video-section.tsx b/src/components/export-video-section.tsx index fc2b1128..34df7d35 100644 --- a/src/components/export-video-section.tsx +++ b/src/components/export-video-section.tsx @@ -255,6 +255,17 @@ function ModalBody({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveBench]); + // Exact title the video will display: the bench title plus the human + // labels of the active filters, e.g. "RPC capabilities (Ethereum, EU + // West)". Injected into the render payload and shown verbatim in the + // modal header so there is no surprise in the MP4. + const videoTitle = useMemo(() => { + const labels = activeDims.map((d) => d.label); + return labels.length > 0 + ? `${benchmark.title} (${labels.join(", ")})` + : benchmark.title; + }, [benchmark.title, activeDims]); + // Row list for the picker, ranked order, dead rows flagged. const providers = useMemo( () => @@ -344,7 +355,7 @@ function ModalBody({ >
- Export video · {benchmark.title} + Export video · {videoTitle}
- {/* Providers */} + {/* Preview: exactly the names, values and order the video will + render. Rows toggle inclusion; rows without data in the + current view are muted and cannot be included. */}
-
+
+ {variantLoading && ( + + )} setSelected( @@ -526,25 +551,84 @@ function ModalBody({
-
- {providers.map((p) => { - const on = selected.has(p.slug); - return ( - - ); - })} +
+
+ {videoTitle} + + {RANGE_LABEL[range]} + +
+ {variantLoading ? ( +
+ + Loading data for this selection +
+ ) : previewRows.length === 0 ? ( +
+ No providers report data for this selection. +
+ ) : ( +
    + {previewRows.map(({ r, live, on, rank }) => { + const isLeader = rank === 1; + return ( +
  • + +
  • + ); + })} +
+ )}
From 167d79ccec53dadd727140056f7ede79543cd11d Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 16:08:12 +0200 Subject: [PATCH 10/24] export video modal: render with dimension-filtered series and final title --- src/components/export-video-section.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/export-video-section.tsx b/src/components/export-video-section.tsx index 1a2bc9c2..65ffe3de 100644 --- a/src/components/export-video-section.tsx +++ b/src/components/export-video-section.tsx @@ -188,8 +188,6 @@ function ModalBody({ }), [dims, dimOptions], ); - const chain = dims.chain ?? null; - const region = dims.region ?? null; const hasDims = activeDims.length > 0; // Variant data. When any dimension filter is active, fetch the filtered @@ -300,9 +298,20 @@ function ModalBody({ } try { setState({ status: "loading_series" }); - const full: BenchPayload = await fetchBenchSeries(slug, range, { chain, region }); + // Same dims the preview shows; the series route validates and + // canonicalizes them exactly like the variant route did for the + // preview values. Title override: the renderer displays whatever + // the payload carries, so the parenthetical filter labels ride in + // here and the cache keys the variant separately for free. + const full: BenchPayload = await fetchBenchSeries(slug, range, { + chain: dims.chain, + region: dims.region, + kind: dims.kind, + venue: dims.venue, + }); const filtered: BenchPayload = { ...full, + title: videoTitle, providers: full.providers.filter((p) => selected.has(p.slug)), }; if (filtered.providers.length === 0) { From 7499ae03344e8d235036bcfd690dbb7f10003ade Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 16:08:57 +0200 Subject: [PATCH 11/24] export video modal: empty state guards, tweet text uses final title --- src/components/export-video-section.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/export-video-section.tsx b/src/components/export-video-section.tsx index 65ffe3de..9c7fab8b 100644 --- a/src/components/export-video-section.tsx +++ b/src/components/export-video-section.tsx @@ -363,10 +363,14 @@ function ModalBody({ }; const tweetIntent = (url: string) => - `https://x.com/intent/tweet?text=${encodeURIComponent(`${title} · last ${range}`)}&url=${encodeURIComponent(url)}`; + `https://x.com/intent/tweet?text=${encodeURIComponent(`${videoTitle} · last ${range}`)}&url=${encodeURIComponent(url)}`; const isBusy = state.status === "loading_series" || state.status === "rendering"; + // Nothing to render: the variant has no live providers (or every one + // was deselected). Disable the button instead of letting the POST fail. + const emptySelection = !variantLoading && liveCount === 0; + const canRender = !isBusy && !variantLoading && !emptySelection && selected.size > 0; return (
{isBusy ? ( @@ -663,6 +667,16 @@ function ModalBody({ {state.status === "rendering" && ( ~10-30s first run · instant on rerun )} + {emptySelection && state.status === "idle" && ( + + No providers report data for this selection. Pick another filter combination. + + )} + {!emptySelection && selected.size === 0 && state.status === "idle" && ( + + Include at least one provider. + + )} {state.status === "error" && ( {state.message} )} From 0fdd90d2ce2397ef2f8ea87851afaf5c17613765 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 16:11:34 +0200 Subject: [PATCH 12/24] export video modal: derive selection and variant state, fix set-state-in-effect lint --- src/components/export-video-section.tsx | 102 +++++++++++------------- 1 file changed, 48 insertions(+), 54 deletions(-) diff --git a/src/components/export-video-section.tsx b/src/components/export-video-section.tsx index 9c7fab8b..51c6d6c0 100644 --- a/src/components/export-video-section.tsx +++ b/src/components/export-video-section.tsx @@ -33,6 +33,12 @@ const DIM_LABEL: Record = { type DimOption = { value: string; label: string }; type DimState = Partial>; +/** Providers with a live number in the current view. "unavailable" + * covers both offline and unresponsive rows; neither can appear in the + * video (their p50 is a zero placeholder, not a measurement). */ +const hasData = (r: Benchmark["results"][number]) => + r.availability !== "unavailable"; + type Props = { slug: string; title: string; @@ -104,7 +110,6 @@ function ExportVideoModal({ slug, title, benchmark }: Props) { function ModalBody({ slug, - title, benchmark, onClose, }: Props & { onClose: () => void }) { @@ -123,27 +128,9 @@ function ModalBody({ const [audio, setAudio] = useState(false); // Story beats: lead-change banners during the race. Off by default. const [beats, setBeats] = useState(false); - // Default to the top 8 providers (sorted by p50). Each composition only - // shows ~8 visible anyway (BarChartRace.VISIBLE_BARS = 8) and rendering - // 50+ providers per frame on a 2-vCPU box pushes us past 2 minutes — - // outside the Vercel function ceiling. Power users can click "All". - const [selected, setSelected] = useState>(() => { - const sorted = [...benchmark.results].sort((a, b) => - benchmark.higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50, - ); - return new Set(sorted.slice(0, 8).map((r) => r.slug)); - }); const [state, setState] = useState({ status: "idle" }); const [copied, setCopied] = useState(false); - const toggleProvider = (s: string) => - setSelected((prev) => { - const next = new Set(prev); - if (next.has(s)) next.delete(s); - else next.add(s); - return next; - }); - // Dimension pickers. One pill row per dimension the spec declares // (chain, region, kind, venue), each with an "All" pill meaning no // filter. The bench page's URL filters (?chain=ethereum) seed the @@ -192,49 +179,40 @@ function ModalBody({ // Variant data. When any dimension filter is active, fetch the filtered // Benchmark from the same on-demand route the bench page tabs use. The - // aggregate prop keeps serving the unfiltered view; a failed fetch - // leaves `variant` null and the preview reports the empty state rather - // than silently showing cross-dimension numbers. - const [variant, setVariant] = useState(null); - const [variantLoading, setVariantLoading] = useState(false); + // fetched bench is stored WITH the filter key it answers, so loading / + // stale states derive from a key comparison instead of extra setState + // calls; a failed fetch stores null and the preview reports the empty + // state rather than silently showing cross-dimension numbers. + const dimKey = activeDims.map((d) => `${d.dim}=${d.value}`).join("&"); + const [variantState, setVariantState] = useState<{ + key: string; + bench: Benchmark | null; + } | null>(null); useEffect(() => { - if (!hasDims) { - setVariant(null); - setVariantLoading(false); - return; - } + if (!hasDims) return; // the aggregate prop already covers "all" const qs = new URLSearchParams(); for (const { dim, value } of activeDims) qs.set(dim, value); let cancelled = false; - setVariantLoading(true); fetch(`/api/bench/${encodeURIComponent(slug)}/variant?${qs.toString()}`) .then((r) => (r.ok ? (r.json() as Promise) : null)) .then((v) => { - if (cancelled) return; - setVariant(v ?? null); - setVariantLoading(false); + if (!cancelled) setVariantState({ key: dimKey, bench: v ?? null }); }) .catch(() => { - if (cancelled) return; - setVariant(null); - setVariantLoading(false); + if (!cancelled) setVariantState({ key: dimKey, bench: null }); }); return () => { cancelled = true; }; - }, [slug, activeDims, hasDims]); + }, [slug, activeDims, hasDims, dimKey]); + const variant = variantState?.key === dimKey ? variantState.bench : null; + const variantLoading = hasDims && variantState?.key !== dimKey; // The bench whose numbers the preview and the provider list reflect: // the fetched variant when filters are active, the aggregate otherwise. // Null while a variant is still in flight. const effectiveBench = hasDims ? variant : benchmark; - // Providers with a live number in the current view. "unavailable" - // covers both offline and unresponsive rows; neither can appear in the - // video (their p50 is a zero placeholder, not a measurement). - const hasData = (r: Benchmark["results"][number]) => - r.availability !== "unavailable"; - // Live rows ranked by headline value (video order), dead rows last. const rankedResults = useMemo(() => { if (!effectiveBench) return []; @@ -245,15 +223,33 @@ function ModalBody({ return [...live, ...dead]; }, [effectiveBench]); - // Whenever the variant changes, reset the selection to the view's top 8 - // live providers. A selection carried across views could name providers - // that have no data in the new one. - useEffect(() => { - if (!effectiveBench) return; - const top = rankedResults.filter(hasData).slice(0, 8); - setSelected(new Set(top.map((r) => r.slug))); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [effectiveBench]); + // Selection. Default = the view's top 8 live providers (each composition + // only shows ~8 bars and rendering 50+ providers per frame on a 2-vCPU + // box blows past the Vercel function ceiling; power users can click + // "All"). A user override is stored WITH the bench it was made against, + // so a variant swap falls back to the new view's default automatically: + // a selection carried across views could name providers that have no + // data in the new one. + const defaultSelection = useMemo( + () => new Set(rankedResults.filter(hasData).slice(0, 8).map((r) => r.slug)), + [rankedResults], + ); + const [selOverride, setSelOverride] = useState<{ + bench: Benchmark | null; + sel: Set; + } | null>(null); + const selected = + selOverride && selOverride.bench === effectiveBench + ? selOverride.sel + : defaultSelection; + const setSelected = (sel: Set) => + setSelOverride({ bench: effectiveBench, sel }); + const toggleProvider = (s: string) => { + const next = new Set(selected); + if (next.has(s)) next.delete(s); + else next.add(s); + setSelected(next); + }; // Exact title the video will display: the bench title plus the human // labels of the active filters, e.g. "RPC capabilities (Ethereum, EU @@ -274,7 +270,6 @@ function ModalBody({ name: r.name, live: hasData(r), })), - // eslint-disable-next-line react-hooks/exhaustive-deps [rankedResults], ); const liveCount = providers.filter((p) => p.live).length; @@ -288,7 +283,6 @@ function ModalBody({ const on = live && selected.has(r.slug); return { r, live, on, rank: on ? ++rank : null }; }); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [rankedResults, selected]); const onRender = async () => { From 2e371cb784a8bb77591b69fd04b428d40ba89e43 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 16:34:50 +0200 Subject: [PATCH 13/24] mev-protect-rpc: chain dimension (ethereum/base/bsc), 4 new gateways, consistency answer page --- ...-protection-rpc-is-the-most-consistent.yml | 35 ++++ benchmarks/mev-protect-rpc.yml | 124 +++++++++++++- .../mev-protect-rpc/cmd/script/metrics.go | 12 +- harnesses/mev-protect-rpc/cmd/script/probe.go | 154 ++++++++++++------ 4 files changed, 259 insertions(+), 66 deletions(-) create mode 100644 answers/which-mev-protection-rpc-is-the-most-consistent.yml diff --git a/answers/which-mev-protection-rpc-is-the-most-consistent.yml b/answers/which-mev-protection-rpc-is-the-most-consistent.yml new file mode 100644 index 00000000..8954f80e --- /dev/null +++ b/answers/which-mev-protection-rpc-is-the-most-consistent.yml @@ -0,0 +1,35 @@ +slug: which-mev-protection-rpc-is-the-most-consistent +question: "Which MEV protection RPC is the most consistent?" +short_answer: | + Consistency means the p99 to p50 gap, not the median. On the live OpenChainBench MEV protection benchmark, Blink (formerly Merkle) holds the flattest tail of the cohort: its p99 sits within roughly 10 percent of its p50, while the fastest median gateway spikes an order of magnitude above its own p50 at the tail. For a wallet that fires a balance refresh, a gas estimate and a simulation on every screen, the tail is what users feel. + +benchmark: mev-protect-rpc + +intro: | + Wallet teams picking a default MEV protection RPC usually compare medians, because medians are what marketing pages publish. But a wallet calls its RPC constantly: every open screen triggers balance refreshes, gas estimates and eth_call simulations. At that call volume the user experiences the p99, not the p50. A gateway that answers in 33 ms most of the time but stalls to 400 ms on one call in a hundred produces a wallet that feels randomly broken several times per session. This page reads the live OpenChainBench mev-protect-rpc benchmark through the consistency lens: which of the public keyless gateways (Flashbots Protect, MEV Blocker, Blink, bloXroute Protect, BlockSec, 48 Club, PancakeSwap MEV Guard) keeps its tail closest to its median. + +methodology: | + Same data as the parent benchmark. Every 60 seconds, from us-east, eu-west and Singapore, the harness sends the 7-method wallet set (chainId, blockNumber, gasPrice, getBalance, call, estimateGas, feeHistory) to each public MEV protection gateway, keyless, one request per 1.5 seconds. The per-tick median across served methods feeds a Prometheus gauge; p50, p90 and p99 are quantile_over_time over 24 hours, averaged across regions. Consistency is read as the ratio between the p99 and p50 columns of the same provider. No transactions are sent; this is the read path a wallet exercises all day. + +limitations: + - "Consistency of the read gateway says nothing about inclusion rates or refund economics, which are write-path properties requiring funded transactions to measure. OpenChainBench does not extrapolate them." + - "A flat tail with a moderate median can coexist with a fast median and a heavy tail. Which profile is better depends on the product: trading bots care about the median, consumer wallets about the tail." + - "Success rate must be read next to both numbers. A gateway can post flat latency while throttling a share of calls; throttled calls do not enter the latency distribution." + +faq: + - q: "Which MEV protection RPC has the flattest latency tail?" + a: "Blink (formerly Merkle) currently posts the flattest p99 to p50 ratio of the measured cohort on Ethereum, with a tail within roughly 10 percent of its median. The live columns on the benchmark page update every minute and the ranking can move; the ratio between the p99 and p50 columns is the number to read." + - q: "Why does consistency matter more than median latency for a wallet?" + a: "Because a wallet fires dozens of RPC calls per session. At 50 calls per session, a 1-in-100 stall hits most sessions at least once. The user does not experience the median; they experience the worst call on the screen they are currently looking at." + - q: "Is the fastest median MEV protect RPC also the most consistent?" + a: "Not currently. The gateway with the best p50 on Ethereum shows a p99 an order of magnitude above its own median, while the most consistent gateway trades a slower median for a nearly flat tail. The benchmark page shows both columns side by side so the trade-off is explicit." + - q: "Does this ranking cover Base and BSC too?" + a: "The parent benchmark measures Ethereum, Base and BSC as separate chain tabs. On Base only one MEV protection gateway is measurable keyless today (Blink); on BSC the cohort includes Blink, bloXroute Protect, 48 Club, PancakeSwap MEV Guard and BlockSec, so the consistency comparison is meaningful there as well." + +related: + - drpc-vs-publicnode-vs-1rpc + - alchemy-vs-quicknode-vs-infura-latency + +seo_title: "Most consistent MEV protection RPC 2026" +seo_description: "Which MEV protection RPC has the flattest latency tail? Blink, MEV Blocker, Flashbots Protect and 4 more compared on p99 vs p50, measured live and keyless." +status: live diff --git a/benchmarks/mev-protect-rpc.yml b/benchmarks/mev-protect-rpc.yml index cd4751a4..1001c5ab 100644 --- a/benchmarks/mev-protect-rpc.yml +++ b/benchmarks/mev-protect-rpc.yml @@ -4,8 +4,8 @@ slug: mev-protect-rpc number: "074" title: Fastest MEV protection RPC, live wallet latency and method coverage seo_title: "Best MEV protection RPC 2026" -seo_description: "{{best_name}} leads MEV protection RPC wallet latency at {{best_p50}} (p50, 24h). Flashbots Protect, MEV Blocker, Blink probed on the wallet method set from 3 regions, keyless." -subtitle: "Median latency of the JSON-RPC method set wallets call constantly (balance, gas, call simulation), measured against public MEV protection gateways from three regions. Read-path hygiene only: inclusion rates and refunds are the write path and cannot be measured without sending transactions." +seo_description: "{{best_name}} leads MEV protection RPC wallet latency at {{best_p50}} (p50, 24h). Flashbots, MEV Blocker, Blink, bloXroute, BlockSec, 48 Club probed keyless on Ethereum, Base and BSC." +subtitle: "Median latency of the JSON-RPC method set wallets call constantly (balance, gas, call simulation), measured against public MEV protection gateways on Ethereum, Base and BSC from three regions. Read-path hygiene only: inclusion rates and refunds are the write path and cannot be measured without sending transactions." category: RPCs status: live metric: Wallet call latency @@ -40,11 +40,13 @@ abstract: | than a latency penalty. No transactions are sent. methodology: - - "Providers measured: Flashbots Protect (rpc.flashbots.net), MEV Blocker (rpc.mevblocker.io), Blink, formerly Merkle (ethereum.blinklabs.xyz). SecureRPC probed dead 2026-07-10; the legacy merkle.io hosts alias Blink behind a stricter rate limit and are not probed." + - "Providers measured, one row per (provider, chain), all keyless and live-verified 2026-07-12. Ethereum: Flashbots Protect (rpc.flashbots.net), MEV Blocker (rpc.mevblocker.io), Blink formerly Merkle (ethereum.blinklabs.xyz), bloXroute Protect (eth-protect.rpc.blxrbdn.com), BlockSec Anti-MEV (eth.rpc.blocksec.com). Base: Blink (base.merkle.io) alone, no other MEV-protect gateway exposes a keyless Base endpoint today. BSC: Blink (bsc.merkle.io), bloXroute Protect (bsc.rpc.blxrbdn.com), 48 Club Privacy RPC (rpc.48.club), PancakeSwap MEV Guard (bscrpc.pancakeswap.finance, powered by 48 Club), BlockSec (bsc.rpc.blocksec.com)." + - "Exclusions: SecureRPC probed dead 2026-07-10. Alchemy MEV Protect and GetBlock protected endpoints are key-gated. base.rpc.blxrbdn.com is live but not documented as Protect, so it is not listed as an MEV gateway. Blink's Ethereum row uses the blinklabs.xyz host because the legacy eth.merkle.io alias rate-limits harder; on Base and BSC the merkle.io hosts are Blink's only keyless surface (base/bsc.blinklabs.xyz do not resolve as of 2026-07-12)." + - "Chain dimension: the tabs pin every query to one chain. The All chains headline averages each provider over the chains it actually serves, the same convention as the rpc-capabilities cluster; the Chains covered panel shows the multi-chain footprint explicitly." - "Method set: eth_chainId, eth_blockNumber, eth_gasPrice, eth_getBalance, eth_call (USDC balanceOf), eth_estimateGas, eth_feeHistory. One request per method per tick, 1.5s apart, rotating request ids against body-keyed edge caches." - - "Cadence: every 60 seconds per region (us-east, eu-west, sgp), 7 requests per provider per tick. Deliberately polite: these gateways rate-ban aggressive callers." + - "Cadence: every 60 seconds per region (us-east, eu-west, sgp), 7 requests per gateway per tick, rows probed in parallel so each gateway still sees one request per 1.5s. Deliberately polite: these gateways rate-ban aggressive callers." - "Headline: median latency across the methods the provider served that tick, aggregated over 24h with quantile_over_time. A rejected method does not poison the latency; it lowers the coverage panel instead." - - "Coverage: mev_rpc_methods_supported counts the wallet methods served on the last tick (max 7). Flashbots rejects eth_call on the public endpoint; Blink blocks full-node reads like eth_getBlockByNumber, which keeps it off the general RPC benches but not off this one." + - "Coverage: mev_rpc_methods_supported counts the wallet methods served on the last tick (max 7) per chain. Flashbots rejects eth_call on the public endpoint; Blink blocks full-node reads like eth_getBlockByNumber, which keeps it off the general RPC benches but not off this one." - "Out of scope, disclosed: inclusion rate, refund economics and sandwich protection efficacy are the write path and require funded transactions. See the arXiv study Private MEV Protection RPCs (2505.19708) for a one-off execution-quality comparison." - "Success rate counts transport failures only (timeouts, throttling, network); a method a gateway refuses by policy is a coverage gap shown in the coverage panel, not an outage. Failures increment mev_rpc_call_total{result}; the gauge keeps its last value so the chart shows the outage in the success column rather than a fake zero." @@ -53,6 +55,10 @@ findings: - "{{name:mevblocker}} sits at {{p50:mevblocker}} (p50, 24h) and serves the full 7-method wallet set. MEV Blocker was acquired by Consensys in January 2026; its gateway proxies reads with the lowest overhead of the cohort." - "{{name:flashbots}} clocks {{p50:flashbots}} (p50, 24h) but rejects eth_call on the public endpoint, so wallets relying on it for simulation fall back to another provider for that path." - "{{name:blinklabs}} returns {{p50:blinklabs}} (p50, 24h) across the full wallet set. Blink, formerly Merkle, is a wallet-facing MEV proxy by design: it serves everything a wallet needs while blocking full-node reads." + - "{{name:blinklabs}} is the only gateway in the cohort measurable on all three chains: Ethereum, Base and BSC. Flashbots Protect and MEV Blocker are Ethereum-only by design, bloXroute Protect and BlockSec cover Ethereum plus BSC, and on Base Blink is currently the only keyless MEV-protect endpoint in existence." + - "{{name:bloxroute}} answers at {{p50:bloxroute}} (p50, 24h) across Ethereum and BSC. The Protect RPC is the free tier of a paid MEV infrastructure stack, and the gateway inherits the low-latency backbone bloXroute sells to searchers." + - "{{name:blocksec}} clocks {{p50:blocksec}} (p50, 24h) on Ethereum and BSC. The Anti-MEV RPC is a security-vendor product: same private-routing idea, marketed to users of the Phalcon toolchain." + - "On BSC the local cohort is the real story: {{name:48club}} ({{p50:48club}}) and {{name:pancakeswap}} ({{p50:pancakeswap}}) share the same 48 Club infrastructure behind different edges, and both compete against Blink and bloXroute for the default slot in BSC wallets." - "The p50 to p99 gap is the number wallet teams should read: a protect RPC that stalls on one refresh per hundred makes the whole wallet feel unreliable, whatever its median." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/mev-protect-rpc @@ -75,6 +81,11 @@ faq: rank_matrix_query: avg by (provider, region) (quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds[24h])) dimensions: + chain: + - { value: all, label: All chains } + - { value: ethereum, label: Ethereum } + - { value: base, label: Base } + - { value: bsc, label: BNB Chain } region: - { value: all, label: All regions } - { value: us-east, label: US-East } @@ -82,6 +93,13 @@ dimensions: - { value: sgp, label: Singapore } metric_panels: + - id: chains + label: Chains covered + metric: count by (provider) (avg by (provider, chain) (mev_rpc_methods_supported{benchmark="mev-protect-rpc"})) + label_key: provider + unit: count + higher_is_better: true + description: "Chains where the provider exposes a keyless MEV-protect gateway we can probe (Ethereum, Base, BSC). Multi-chain wallets need one provider across every network they ship, not one per chain." - id: coverage label: Method coverage metric: avg(mev_rpc_methods_supported{benchmark="mev-protect-rpc"}) @@ -139,8 +157,8 @@ providers: - slug: blinklabs name: Blink - tag: Formerly Merkle, wallet MEV proxy, full wallet set, powers Ledger - formula: "Median latency across the wallet methods served per 60s tick against ethereum.blinklabs.xyz, aggregated over 24h via quantile_over_time; 3-region average." + tag: Formerly Merkle, only gateway live on Ethereum, Base and BSC, powers Ledger + formula: "Median latency across the wallet methods served per 60s tick against ethereum.blinklabs.xyz, base.merkle.io and bsc.merkle.io, aggregated over 24h via quantile_over_time; 3-region average over the chains served." queries: p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="blinklabs"}[24h]) p90: quantile_over_time(0.90, mev_rpc_wallet_latency_milliseconds{provider="blinklabs"}[24h]) @@ -159,3 +177,95 @@ providers: - region: ap-southeast p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="blinklabs", region="sgp"}[24h]) series: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="blinklabs", region="sgp"}[1h]) + + - slug: bloxroute + name: bloXroute Protect + tag: Free protect tier of the bloXroute MEV stack, Ethereum + BSC + formula: "Median latency across the wallet methods served per 60s tick against eth-protect.rpc.blxrbdn.com and bsc.rpc.blxrbdn.com, aggregated over 24h via quantile_over_time; 3-region average over the chains served." + queries: + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="bloxroute"}[24h]) + p90: quantile_over_time(0.90, mev_rpc_wallet_latency_milliseconds{provider="bloxroute"}[24h]) + p99: quantile_over_time(0.99, mev_rpc_wallet_latency_milliseconds{provider="bloxroute"}[24h]) + mean: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="bloxroute"}[24h]) + success: sum(increase(mev_rpc_call_total{provider="bloxroute",result="ok"}[24h])) / clamp_min(sum(increase(mev_rpc_call_total{provider="bloxroute",result!~"blocked|method_not_found"}[24h])), 1) + sample_size: sum(increase(mev_rpc_call_total{provider="bloxroute"}[24h])) + series: avg(avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="bloxroute"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="bloxroute", region="us-east"}[24h]) + series: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="bloxroute", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="bloxroute", region="eu-west"}[24h]) + series: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="bloxroute", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="bloxroute", region="sgp"}[24h]) + series: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="bloxroute", region="sgp"}[1h]) + + - slug: blocksec + name: BlockSec Anti-MEV + tag: Security-vendor private routing, Ethereum + BSC + formula: "Median latency across the wallet methods served per 60s tick against eth.rpc.blocksec.com and bsc.rpc.blocksec.com, aggregated over 24h via quantile_over_time; 3-region average over the chains served." + queries: + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="blocksec"}[24h]) + p90: quantile_over_time(0.90, mev_rpc_wallet_latency_milliseconds{provider="blocksec"}[24h]) + p99: quantile_over_time(0.99, mev_rpc_wallet_latency_milliseconds{provider="blocksec"}[24h]) + mean: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="blocksec"}[24h]) + success: sum(increase(mev_rpc_call_total{provider="blocksec",result="ok"}[24h])) / clamp_min(sum(increase(mev_rpc_call_total{provider="blocksec",result!~"blocked|method_not_found"}[24h])), 1) + sample_size: sum(increase(mev_rpc_call_total{provider="blocksec"}[24h])) + series: avg(avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="blocksec"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="blocksec", region="us-east"}[24h]) + series: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="blocksec", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="blocksec", region="eu-west"}[24h]) + series: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="blocksec", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="blocksec", region="sgp"}[24h]) + series: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="blocksec", region="sgp"}[1h]) + + - slug: 48club + name: 48 Club Privacy RPC + tag: BSC validator collective, Puissant successor + formula: "Median latency across the wallet methods served per 60s tick against rpc.48.club, aggregated over 24h via quantile_over_time; 3-region average over the chains served." + queries: + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="48club"}[24h]) + p90: quantile_over_time(0.90, mev_rpc_wallet_latency_milliseconds{provider="48club"}[24h]) + p99: quantile_over_time(0.99, mev_rpc_wallet_latency_milliseconds{provider="48club"}[24h]) + mean: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="48club"}[24h]) + success: sum(increase(mev_rpc_call_total{provider="48club",result="ok"}[24h])) / clamp_min(sum(increase(mev_rpc_call_total{provider="48club",result!~"blocked|method_not_found"}[24h])), 1) + sample_size: sum(increase(mev_rpc_call_total{provider="48club"}[24h])) + series: avg(avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="48club"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="48club", region="us-east"}[24h]) + series: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="48club", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="48club", region="eu-west"}[24h]) + series: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="48club", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="48club", region="sgp"}[24h]) + series: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="48club", region="sgp"}[1h]) + + - slug: pancakeswap + name: PancakeSwap MEV Guard + tag: BSC, powered by 48 Club behind PancakeSwap's edge + formula: "Median latency across the wallet methods served per 60s tick against bscrpc.pancakeswap.finance, aggregated over 24h via quantile_over_time; 3-region average over the chains served." + queries: + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="pancakeswap"}[24h]) + p90: quantile_over_time(0.90, mev_rpc_wallet_latency_milliseconds{provider="pancakeswap"}[24h]) + p99: quantile_over_time(0.99, mev_rpc_wallet_latency_milliseconds{provider="pancakeswap"}[24h]) + mean: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="pancakeswap"}[24h]) + success: sum(increase(mev_rpc_call_total{provider="pancakeswap",result="ok"}[24h])) / clamp_min(sum(increase(mev_rpc_call_total{provider="pancakeswap",result!~"blocked|method_not_found"}[24h])), 1) + sample_size: sum(increase(mev_rpc_call_total{provider="pancakeswap"}[24h])) + series: avg(avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="pancakeswap"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="pancakeswap", region="us-east"}[24h]) + series: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="pancakeswap", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="pancakeswap", region="eu-west"}[24h]) + series: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="pancakeswap", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, mev_rpc_wallet_latency_milliseconds{provider="pancakeswap", region="sgp"}[24h]) + series: avg_over_time(mev_rpc_wallet_latency_milliseconds{provider="pancakeswap", region="sgp"}[1h]) diff --git a/harnesses/mev-protect-rpc/cmd/script/metrics.go b/harnesses/mev-protect-rpc/cmd/script/metrics.go index 3655633f..d6f78792 100644 --- a/harnesses/mev-protect-rpc/cmd/script/metrics.go +++ b/harnesses/mev-protect-rpc/cmd/script/metrics.go @@ -12,31 +12,31 @@ var ( mevWalletLatency = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "mev_rpc_wallet_latency_milliseconds", Help: "Median latency across the wallet method set served this tick, per MEV-protect provider.", - }, []string{"provider", "region"}) + }, []string{"provider", "chain", "region"}) mevWalletLatencyHist = promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: "mev_rpc_wallet_latency_milliseconds_histogram", Help: "Distribution of per-tick median wallet latency.", Buckets: prometheus.ExponentialBuckets(25, 2, 10), - }, []string{"provider", "region"}) + }, []string{"provider", "chain", "region"}) mevMethodLatency = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "mev_rpc_method_latency_milliseconds", Help: "Latency of the last successful call per method.", - }, []string{"provider", "method", "region"}) + }, []string{"provider", "chain", "method", "region"}) mevMethodOK = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "mev_rpc_method_ok", Help: "1 when the method succeeded on the last tick, 0 otherwise.", - }, []string{"provider", "method", "region"}) + }, []string{"provider", "chain", "method", "region"}) mevMethodsSupported = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "mev_rpc_methods_supported", Help: "Count of wallet methods served on the last tick (max 7).", - }, []string{"provider", "region"}) + }, []string{"provider", "chain", "region"}) mevCallTotal = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "mev_rpc_call_total", Help: "Probe outcomes per provider/method.", - }, []string{"provider", "method", "region", "result"}) + }, []string{"provider", "chain", "method", "region", "result"}) ) diff --git a/harnesses/mev-protect-rpc/cmd/script/probe.go b/harnesses/mev-protect-rpc/cmd/script/probe.go index 0139b429..25920a4d 100644 --- a/harnesses/mev-protect-rpc/cmd/script/probe.go +++ b/harnesses/mev-protect-rpc/cmd/script/probe.go @@ -8,46 +8,74 @@ import ( "io" "net/http" "sort" + "sync" "time" ) -// Providers: public no-key MEV-protection gateways on Ethereum mainnet. -// SecureRPC (Manifold) probed dead 2026-07-10; the legacy merkle.io -// hosts alias Blink's gateway behind a far more aggressive Cloudflare -// rate limit, so Blink is probed on the blinklabs.xyz host only. +// Providers: public no-key MEV-protection gateways, one row per +// (provider, chain). Cohort verified live 2026-07-12: +// - SecureRPC (Manifold) probed dead 2026-07-10. +// - Blink (ex Merkle): blinklabs.xyz host on Ethereum (the legacy +// eth.merkle.io alias rate-limits harder); on Base and BSC the +// merkle.io hosts are the only keyless surface Blink exposes +// (base/bsc.blinklabs.xyz do not resolve as of 2026-07-12). +// - Flashbots Protect and MEV Blocker are Ethereum-only by design +// (base.rpc.flashbots.net NXDOMAIN), which the coverage panel +// surfaces rather than hides. +// - bloXroute Protect documents ETH + BSC only; base.rpc.blxrbdn.com +// is plain RPC without the Protect path, so it is not listed. +// - PancakeSwap MEV Guard is powered by 48 Club; both rows are kept +// because wallets see two different gateways with different edges. var providers = []struct { - Slug string - URL string + Slug string + Chain string + URL string }{ - {Slug: "flashbots", URL: "https://rpc.flashbots.net"}, - {Slug: "mevblocker", URL: "https://rpc.mevblocker.io"}, - {Slug: "blinklabs", URL: "https://ethereum.blinklabs.xyz"}, + {Slug: "flashbots", Chain: "ethereum", URL: "https://rpc.flashbots.net"}, + {Slug: "mevblocker", Chain: "ethereum", URL: "https://rpc.mevblocker.io"}, + {Slug: "blinklabs", Chain: "ethereum", URL: "https://ethereum.blinklabs.xyz"}, + {Slug: "bloxroute", Chain: "ethereum", URL: "https://eth-protect.rpc.blxrbdn.com"}, + {Slug: "blocksec", Chain: "ethereum", URL: "https://eth.rpc.blocksec.com"}, + {Slug: "blinklabs", Chain: "base", URL: "https://base.merkle.io"}, + {Slug: "blinklabs", Chain: "bsc", URL: "https://bsc.merkle.io"}, + {Slug: "bloxroute", Chain: "bsc", URL: "https://bsc.rpc.blxrbdn.com"}, + {Slug: "48club", Chain: "bsc", URL: "https://rpc.48.club"}, + {Slug: "pancakeswap", Chain: "bsc", URL: "https://bscrpc.pancakeswap.finance"}, + {Slug: "blocksec", Chain: "bsc", URL: "https://bsc.rpc.blocksec.com"}, +} + +// usdcByChain feeds the eth_call probe (balanceOf) with each chain's +// canonical USDC deployment so the simulation path exercises a real +// contract everywhere. +var usdcByChain = map[string]string{ + "ethereum": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "base": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "bsc": "0x8AC76a51cC950d9822D68b83fE1Ad97B32Cd580d", } // walletMethods is the read set wallets fire constantly (balance // refresh, gas estimation, simulation). One request per method per -// provider per tick: 7 req/min/provider/region, polite for gateways +// provider row per tick: 7 req/min/gateway/region, polite for gateways // that rate-ban aggressive callers. var walletMethods = []struct { Name string - Params func() []any + Params func(chain string) []any }{ - {"eth_chainId", func() []any { return []any{} }}, - {"eth_blockNumber", func() []any { return []any{} }}, - {"eth_gasPrice", func() []any { return []any{} }}, - {"eth_getBalance", func() []any { return []any{probeAddress, "latest"} }}, - {"eth_call", func() []any { - return []any{map[string]string{"to": usdcContract, "data": balanceOfData}, "latest"} + {"eth_chainId", func(string) []any { return []any{} }}, + {"eth_blockNumber", func(string) []any { return []any{} }}, + {"eth_gasPrice", func(string) []any { return []any{} }}, + {"eth_getBalance", func(string) []any { return []any{probeAddress, "latest"} }}, + {"eth_call", func(chain string) []any { + return []any{map[string]string{"to": usdcByChain[chain], "data": balanceOfData}, "latest"} }}, - {"eth_estimateGas", func() []any { + {"eth_estimateGas", func(string) []any { return []any{map[string]string{"from": probeAddress, "to": probeAddress, "value": "0x1"}} }}, - {"eth_feeHistory", func() []any { return []any{"0x5", "latest", []int{50}} }}, + {"eth_feeHistory", func(string) []any { return []any{"0x5", "latest", []int{50}} }}, } const ( probeAddress = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" - usdcContract = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" balanceOfData = "0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045" tickInterval = 60 * time.Second @@ -55,6 +83,12 @@ const ( probeTimeout = 10 * time.Second ) +type providerRow = struct { + Slug string + Chain string + URL string +} + type rpcEnvelope struct { Result json.RawMessage `json:"result"` Error *struct { @@ -78,44 +112,58 @@ func runProbeLoop(ctx context.Context) { } } +// tick probes every (provider, chain) row in parallel; within a row the +// methods stay sequential with methodGap between them, so each gateway +// still sees at most one request per 1.5s while the whole sweep fits +// inside the 60s tick regardless of cohort size. func tick(ctx context.Context, client *http.Client) { + var wg sync.WaitGroup for _, p := range providers { - latencies := make([]float64, 0, len(walletMethods)) - supported := 0 - for _, m := range walletMethods { - select { - case <-ctx.Done(): - return - default: - } - lat, result := call(ctx, client, p.URL, m.Name, m.Params()) - mevCallTotal.WithLabelValues(p.Slug, m.Name, currentRegion, result).Inc() - if result == "ok" { - supported++ - latencies = append(latencies, lat) - mevMethodLatency.WithLabelValues(p.Slug, m.Name, currentRegion).Set(lat) - mevMethodOK.WithLabelValues(p.Slug, m.Name, currentRegion).Set(1) - } else { - mevMethodOK.WithLabelValues(p.Slug, m.Name, currentRegion).Set(0) - } - time.Sleep(methodGap) + wg.Add(1) + go func(p providerRow) { + defer wg.Done() + probeRow(ctx, client, p) + }(p) + } + wg.Wait() +} + +func probeRow(ctx context.Context, client *http.Client, p providerRow) { + latencies := make([]float64, 0, len(walletMethods)) + supported := 0 + for _, m := range walletMethods { + select { + case <-ctx.Done(): + return + default: + } + lat, result := call(ctx, client, p.URL, m.Name, m.Params(p.Chain)) + mevCallTotal.WithLabelValues(p.Slug, p.Chain, m.Name, currentRegion, result).Inc() + if result == "ok" { + supported++ + latencies = append(latencies, lat) + mevMethodLatency.WithLabelValues(p.Slug, p.Chain, m.Name, currentRegion).Set(lat) + mevMethodOK.WithLabelValues(p.Slug, p.Chain, m.Name, currentRegion).Set(1) + } else { + mevMethodOK.WithLabelValues(p.Slug, p.Chain, m.Name, currentRegion).Set(0) } - mevMethodsSupported.WithLabelValues(p.Slug, currentRegion).Set(float64(supported)) - if len(latencies) > 0 { - sort.Float64s(latencies) - median := latencies[len(latencies)/2] - if len(latencies)%2 == 0 { - median = (latencies[len(latencies)/2-1] + latencies[len(latencies)/2]) / 2 - } - // Median across the methods the provider actually serves, - // so coverage gaps (flashbots rejects eth_call) do not - // poison the latency figure; coverage is its own metric. - mevWalletLatency.WithLabelValues(p.Slug, currentRegion).Set(median) - mevWalletLatencyHist.WithLabelValues(p.Slug, currentRegion).Observe(median) + time.Sleep(methodGap) + } + mevMethodsSupported.WithLabelValues(p.Slug, p.Chain, currentRegion).Set(float64(supported)) + if len(latencies) > 0 { + sort.Float64s(latencies) + median := latencies[len(latencies)/2] + if len(latencies)%2 == 0 { + median = (latencies[len(latencies)/2-1] + latencies[len(latencies)/2]) / 2 } - fmt.Printf("[mev-protect][%s][%s] supported=%d/%d median=%s\n", - p.Slug, currentRegion, supported, len(walletMethods), fmtMedian(latencies)) + // Median across the methods the provider actually serves, + // so coverage gaps (flashbots rejects eth_call) do not + // poison the latency figure; coverage is its own metric. + mevWalletLatency.WithLabelValues(p.Slug, p.Chain, currentRegion).Set(median) + mevWalletLatencyHist.WithLabelValues(p.Slug, p.Chain, currentRegion).Observe(median) } + fmt.Printf("[mev-protect][%s/%s][%s] supported=%d/%d median=%s\n", + p.Slug, p.Chain, currentRegion, supported, len(walletMethods), fmtMedian(latencies)) } func fmtMedian(l []float64) string { From b1793f78535d6a7fe301beaeb37d0b20402516fc Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 16:41:49 +0200 Subject: [PATCH 14/24] mev-protect-rpc: split methodology bullets under the 500 char cap --- benchmarks/mev-protect-rpc.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/benchmarks/mev-protect-rpc.yml b/benchmarks/mev-protect-rpc.yml index 1001c5ab..feb98795 100644 --- a/benchmarks/mev-protect-rpc.yml +++ b/benchmarks/mev-protect-rpc.yml @@ -40,8 +40,10 @@ abstract: | than a latency penalty. No transactions are sent. methodology: - - "Providers measured, one row per (provider, chain), all keyless and live-verified 2026-07-12. Ethereum: Flashbots Protect (rpc.flashbots.net), MEV Blocker (rpc.mevblocker.io), Blink formerly Merkle (ethereum.blinklabs.xyz), bloXroute Protect (eth-protect.rpc.blxrbdn.com), BlockSec Anti-MEV (eth.rpc.blocksec.com). Base: Blink (base.merkle.io) alone, no other MEV-protect gateway exposes a keyless Base endpoint today. BSC: Blink (bsc.merkle.io), bloXroute Protect (bsc.rpc.blxrbdn.com), 48 Club Privacy RPC (rpc.48.club), PancakeSwap MEV Guard (bscrpc.pancakeswap.finance, powered by 48 Club), BlockSec (bsc.rpc.blocksec.com)." - - "Exclusions: SecureRPC probed dead 2026-07-10. Alchemy MEV Protect and GetBlock protected endpoints are key-gated. base.rpc.blxrbdn.com is live but not documented as Protect, so it is not listed as an MEV gateway. Blink's Ethereum row uses the blinklabs.xyz host because the legacy eth.merkle.io alias rate-limits harder; on Base and BSC the merkle.io hosts are Blink's only keyless surface (base/bsc.blinklabs.xyz do not resolve as of 2026-07-12)." + - "Providers measured, one row per (provider, chain), all keyless and live-verified 2026-07-12. Ethereum cohort: Flashbots Protect (rpc.flashbots.net), MEV Blocker (rpc.mevblocker.io), Blink formerly Merkle (ethereum.blinklabs.xyz), bloXroute Protect (eth-protect.rpc.blxrbdn.com), BlockSec Anti-MEV (eth.rpc.blocksec.com)." + - "Base cohort: Blink (base.merkle.io) alone. No other MEV-protect gateway exposes a keyless Base endpoint today; Flashbots and MEV Blocker are Ethereum-only by design and base.rpc.blxrbdn.com is plain RPC, not documented as Protect." + - "BSC cohort: Blink (bsc.merkle.io), bloXroute Protect (bsc.rpc.blxrbdn.com), 48 Club Privacy RPC (rpc.48.club), PancakeSwap MEV Guard (bscrpc.pancakeswap.finance, powered by 48 Club), BlockSec (bsc.rpc.blocksec.com)." + - "Exclusions: SecureRPC probed dead 2026-07-10. Alchemy MEV Protect and GetBlock protected endpoints are key-gated. Blink's Ethereum row uses the blinklabs.xyz host because the legacy eth.merkle.io alias rate-limits harder; on Base and BSC the merkle.io hosts are Blink's only keyless surface (base/bsc.blinklabs.xyz do not resolve as of 2026-07-12)." - "Chain dimension: the tabs pin every query to one chain. The All chains headline averages each provider over the chains it actually serves, the same convention as the rpc-capabilities cluster; the Chains covered panel shows the multi-chain footprint explicitly." - "Method set: eth_chainId, eth_blockNumber, eth_gasPrice, eth_getBalance, eth_call (USDC balanceOf), eth_estimateGas, eth_feeHistory. One request per method per tick, 1.5s apart, rotating request ids against body-keyed edge caches." - "Cadence: every 60 seconds per region (us-east, eu-west, sgp), 7 requests per gateway per tick, rows probed in parallel so each gateway still sees one request per 1.5s. Deliberately polite: these gateways rate-ban aggressive callers." From d3317ff046e29221e155f89e0580884a561cf768 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:16:01 +0200 Subject: [PATCH 15/24] logo: replace chainstack with official brand svg (#1123) Old file was a wordmark including 'CHAINSTACK' text, ~9.7 KB. Replaced with the official icon-only mark pulled from chainstack.com/wp-content/themes/chainstack/img/chainstack-logo-blue.svg, 1.2 KB, blue (#007BFF), viewBox 0 0 109 109. Matches the shape used on their own homepage today and lines up better with the other provider icons in the leaderboard. Co-authored-by: Florent Tapponnier --- public/logos/chainstack.svg | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/public/logos/chainstack.svg b/public/logos/chainstack.svg index cf0f6b49..590bbe35 100644 --- a/public/logos/chainstack.svg +++ b/public/logos/chainstack.svg @@ -1,22 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - + + + From ec8dd896c2b2770e0ec063088ea01dabd16c681c Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 17:17:52 +0200 Subject: [PATCH 16/24] mev cohort: logos + registry entries for blocksec, 48club, pancakeswap; blink dark mode chip --- public/logos/48club.png | Bin 0 -> 8908 bytes public/logos/blocksec.jpg | Bin 0 -> 14928 bytes public/logos/pancakeswap.png | Bin 0 -> 22919 bytes src/components/provider-logo.tsx | 1 + src/data/provider-registry.ts | 23 +++++++++++++++++++++++ src/lib/logo-manifest.ts | 3 +++ 6 files changed, 27 insertions(+) create mode 100644 public/logos/48club.png create mode 100644 public/logos/blocksec.jpg create mode 100644 public/logos/pancakeswap.png diff --git a/public/logos/48club.png b/public/logos/48club.png new file mode 100644 index 0000000000000000000000000000000000000000..65085328a8caed5e924989a1cf1471101e1bbda7 GIT binary patch literal 8908 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 F{{WxwAD#dJ literal 0 HcmV?d00001 diff --git a/public/logos/blocksec.jpg b/public/logos/blocksec.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4b10be5eab3bdbfd331cc8531f8f7ab82127d50d GIT binary patch literal 14928 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 literal 0 HcmV?d00001 diff --git a/public/logos/pancakeswap.png b/public/logos/pancakeswap.png new file mode 100644 index 0000000000000000000000000000000000000000..85ff43c63edf941810cdb5e959dc63ec42400200 GIT binary patch literal 22919 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 = { // Identified by matching each builder address's HL referral code to a // public brand. Provenance lives in /opt/hl-bench/builders.json notes // on the HL node. + bloxroute: { + url: "https://bloxroute.com", + description: + "bloXroute Labs runs a low-latency blockchain distribution network for traders and validators. Its free Protect RPC routes Ethereum and BSC transactions away from the public mempool, and its Solana relay competes on transaction landing.", + twitter: "@bloXrouteLabs", + }, + blocksec: { + url: "https://blocksec.com", + description: + "BlockSec is a blockchain security company behind the Phalcon toolchain, audits and attack monitoring. Its Anti-MEV RPC applies private transaction routing to shield Ethereum and BSC users from sandwich attacks.", + twitter: "@BlockSecTeam", + }, + "48club": { + url: "https://48.club", + description: + "48 Club is a BNB Chain validator collective behind the KOGE token. Its Privacy RPC, successor of Puissant, routes BSC transactions privately through its own validators to prevent frontrunning.", + }, + pancakeswap: { + url: "https://pancakeswap.finance", + description: + "PancakeSwap is the largest DEX on BNB Chain. MEV Guard is its user-facing protected RPC, powered by 48 Club infrastructure and shipped as the default anti-sandwich endpoint for PancakeSwap traders.", + twitter: "@PancakeSwap", + }, "defi-saver": { url: "https://defisaver.com", description: diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 0d6cbd60..3df2764e 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -309,6 +309,9 @@ const RAW: Record = { "nautilus-trader": "/logos/nautilus-trader.png", blink: "/logos/blink.png", blinklabs: "/logos/blinklabs.svg", + blocksec: "/logos/blocksec.jpg", + "48club": "/logos/48club.png", + pancakeswap: "/logos/pancakeswap.png", mevblocker: "/logos/mevblocker.svg", // ─── Hyperliquid frontends registry expansion (60 → 66, 2026-06-28) ─── From 2902d6a2ba8926d31b912566830eb4a2ddd0794b Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:28:32 +0200 Subject: [PATCH 17/24] rpc-keyed harness: record sub-millisecond latency without truncation (#1128) time.Duration.Milliseconds() returns int64 and truncates every probe under 1 ms to zero. QuickNode is measured via the Mobula shared paid fleet whose endpoints are peer-adjacent to the harness (short RTT + HTTP keep-alive), so a significant share of successful probes returned in ~0.3 to 0.9 ms and were recorded as 0 ms in Prometheus. The 24h aggregate on the BNB + US-East cell landed at effectively 0 ms and rendered as 'QuickNode leads at 0 ms' on /benchmarks/rpc-keyed-latency?chain=bnb®ion=us-east. Fix: latencyMs = float64(time.Since(start).Nanoseconds()) / 1e6. Same wall-clock delta, real precision preserved down to microsecond granularity. Every other provider stays within noise (their probes were already above 1 ms so the truncation never bit them). Requires a Railway harness rebuild + redeploy to take effect on staging and production (bench 069 runs on Railway, not the shared worker VPS). The materialized store will catch up on the next 24h window after redeploy. Co-authored-by: Florent Tapponnier --- harnesses/rpc-keyed-latency/cmd/script/probe.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/harnesses/rpc-keyed-latency/cmd/script/probe.go b/harnesses/rpc-keyed-latency/cmd/script/probe.go index d9e944e9..28d0821a 100644 --- a/harnesses/rpc-keyed-latency/cmd/script/probe.go +++ b/harnesses/rpc-keyed-latency/cmd/script/probe.go @@ -127,7 +127,15 @@ func doPost(ctx context.Context, url string, body []byte) (raw []byte, latencyMs start := time.Now() resp, err := client.Do(req) - latencyMs = float64(time.Since(start).Milliseconds()) + // Use nanoseconds and convert to a float millisecond so sub-1ms probes + // are recorded with real precision. time.Duration.Milliseconds() is an + // int64 and truncates every measurement under 1 ms to zero, which + // systematically under-reports latency for providers whose endpoint + // is peer-adjacent to the harness (e.g. QuickNode via the Mobula + // shared fleet: sub-millisecond round-trips truncated to 0 dragged + // the 24h aggregate down to a nonsensical 0 ms on the BNB + US-East + // cell, then rendered as "QuickNode leads at 0 ms" on the UI). + latencyMs = float64(time.Since(start).Nanoseconds()) / 1e6 if err != nil { if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") { return nil, latencyMs, "timeout", err From 41a87cd8e0c4f3605104e8b4b16c9c23646ec2ca Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 17:29:53 +0200 Subject: [PATCH 18/24] prom: emit dense series with nulls for empty buckets --- src/lib/materialize/load.ts | 18 ++++---- src/lib/prometheus.ts | 92 ++++++++++++++++++++++++++----------- src/lib/snapshot.ts | 10 ++-- src/types/benchmark.ts | 16 +++++-- 4 files changed, 92 insertions(+), 44 deletions(-) diff --git a/src/lib/materialize/load.ts b/src/lib/materialize/load.ts index 17fc8633..8c19ed90 100644 --- a/src/lib/materialize/load.ts +++ b/src/lib/materialize/load.ts @@ -643,12 +643,12 @@ async function tryLoadLive( try { const liveResults: ProviderResult[] = []; - const series24h: Record = {}; - const series7d: Record = {}; - const series30d: Record = {}; - const seriesByRegion24h: Record> = {}; - const seriesByRegion7d: Record> = {}; - const seriesByRegion30d: Record> = {}; + const series24h: Record = {}; + const series7d: Record = {}; + const series30d: Record = {}; + const seriesByRegion24h: Record> = {}; + const seriesByRegion7d: Record> = {}; + const seriesByRegion30d: Record> = {}; const regions: Record = {}; let totalSamples = 0; const sevenDaysSec = 7 * 86_400; @@ -841,9 +841,9 @@ async function tryLoadLive( const metricPanels: MetricPanel[] = []; for (const panel of spec.metric_panels ?? []) { const values: Record = {}; - const seriesByProvider: Record = {}; - const seriesByProvider7d: Record = {}; - const seriesByProvider30d: Record = {}; + const seriesByProvider: Record = {}; + const seriesByProvider7d: Record = {}; + const seriesByProvider30d: Record = {}; await Promise.all( spec.providers.map(async (p) => { const sel = `${panel.label_key}="${escapePromLabelValue(p.slug)}"`; diff --git a/src/lib/prometheus.ts b/src/lib/prometheus.ts index 5c6b58e1..189b8752 100644 --- a/src/lib/prometheus.ts +++ b/src/lib/prometheus.ts @@ -130,43 +130,36 @@ export class Prometheus { /** Convenience: an evenly-spaced numeric series for the last `windowSec` seconds. * Default 72 points = 20-min resolution over a 24h window. * + * The output is DENSE: one slot per query_range evaluation step + * (start + k*step), with `null` where Prom returned no sample. Prom + * silently omits empty evaluation buckets from the matrix, and the old + * bare-array output shifted every consumer that back-computes + * timestamps as `now - (N-1-i)*step` — a 19h outage on + * aggregator-head-lag 7d made the chart start at -6d6h instead of -7d + * and squeezed the gap out of view entirely. + * * If the query returns multiple series (e.g. one per route × token × region * for the same provider), the values are averaged at each timestamp so the * caller gets a single coherent line. Without this, providers with broader * coverage would silently drop because we used to keep only the first * matching series and that one was often sparse / NaN. */ - async series(promql: string, windowSec: number, points = 72): Promise { + async series( + promql: string, + windowSec: number, + points = 72, + ): Promise<(number | null)[] | null> { try { const end = new Date(); const start = new Date(end.getTime() - windowSec * 1000); const step = Math.max(1, Math.floor(windowSec / points)); const res = await this.queryRange(promql, start, end, step); if (res.result.length === 0) return null; - - // Build a timestamp → [values] map across all returned series. - 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); - } - } - - // Average values per timestamp, ordered chronologically. Rounded to - // 6 significant digits: raw averages carry 15+ digit tails that - // bloated the hyperliquid-frontends bench past unstable_cache's 2MB - // limit ("items over 2MB can not be cached"), so its cache NEVER - // persisted and every render redid the full Prom fan-out with no - // previous-value fallback. - const ordered = Array.from(buckets.entries()).sort((a, b) => a[0] - b[0]); - const out = ordered.map(([, vs]) => { - const mean = vs.reduce((s, v) => s + v, 0) / vs.length; - return mean === 0 ? 0 : Number(mean.toPrecision(6)); - }); - return out.length > 0 ? out : null; + return denseSeriesFromMatrix( + res.result, + start.getTime() / 1000, + end.getTime() / 1000, + step, + ); } catch { return null; } @@ -215,6 +208,53 @@ export class Prometheus { } } +/** Map a query_range matrix onto the dense evaluation grid + * `startSec + k*stepSec` (k = 0..floor((endSec-startSec)/stepSec)) and + * emit one entry per grid slot: the mean of every sample that landed in + * that slot, or `null` when Prom emitted nothing there. + * + * Values are rounded to 6 significant digits: raw averages carry 15+ + * digit tails that bloated the hyperliquid-frontends bench past + * unstable_cache's 2MB limit ("items over 2MB can not be cached"), so + * its cache NEVER persisted and every render redid the full Prom + * fan-out with no previous-value fallback. + * + * Returns null when no finite sample maps onto the grid at all (keeps + * the caller's `null = no data` semantics). + * + * Pure and exported for unit tests. */ +export function denseSeriesFromMatrix( + result: PromMatrix[], + startSec: number, + endSec: number, + stepSec: number, +): (number | null)[] | null { + const slots = Math.floor((endSec - startSec) / stepSec) + 1; + if (slots <= 0) return null; + const buckets: number[][] = Array.from({ length: slots }, () => []); + for (const series of result) { + for (const [ts, raw] of series.values) { + const v = Number(raw); + if (!Number.isFinite(v)) continue; + const idx = Math.round((ts - startSec) / stepSec); + if (idx < 0 || idx >= slots) continue; + // Reject samples that don't sit on the grid (defensive: query_range + // only evaluates at grid timestamps, so anything off-grid means the + // caller's start/step don't match the response). + if (Math.abs(ts - (startSec + idx * stepSec)) > stepSec / 2) continue; + buckets[idx].push(v); + } + } + let any = false; + const out = buckets.map((vs) => { + if (vs.length === 0) return null; + any = true; + const mean = vs.reduce((s, v) => s + v, 0) / vs.length; + return mean === 0 ? 0 : Number(mean.toPrecision(6)); + }); + return any ? out : null; +} + /** Module-level semaphore for fetchEnvelope. * * Sizing matters more than it looks: at 8 slots a bench page that diff --git a/src/lib/snapshot.ts b/src/lib/snapshot.ts index 2ee37634..89e658bd 100644 --- a/src/lib/snapshot.ts +++ b/src/lib/snapshot.ts @@ -93,7 +93,9 @@ const RegionPointSchema = z.object({ p50: z.number(), }); -const Series24hSchema = z.array(z.number()); +// Nullable entries: dense series carry `null` for empty Prom buckets +// (gap rendering). Pre-null blobs (bare number arrays) parse unchanged. +const Series24hSchema = z.array(z.number().nullable()); const ResultExtrasSchema = z.object({ series24h: z.record(z.string(), Series24hSchema), @@ -121,9 +123,9 @@ const MetricPanelSchema = z.object({ tab: z.boolean().optional(), values: z.record(z.string(), z.number()), valuesMeta: z.record(z.string(), StalenessMetaSchema).optional(), - seriesByProvider: z.record(z.string(), z.array(z.number())).optional(), - seriesByProvider7d: z.record(z.string(), z.array(z.number())).optional(), - seriesByProvider30d: z.record(z.string(), z.array(z.number())).optional(), + seriesByProvider: z.record(z.string(), Series24hSchema).optional(), + seriesByProvider7d: z.record(z.string(), Series24hSchema).optional(), + seriesByProvider30d: z.record(z.string(), Series24hSchema).optional(), }); const CellRankEntrySchema = z.object({ diff --git a/src/types/benchmark.ts b/src/types/benchmark.ts index 63f49349..c213c3ef 100644 --- a/src/types/benchmark.ts +++ b/src/types/benchmark.ts @@ -104,7 +104,12 @@ export type RegionPoint = { p50: number; }; -export type Series24h = number[]; +/** Dense per-window series aligned to the Prom query_range grid. `null` + * marks an evaluation bucket with no sample (harness outage, provider + * offline) so renderers can draw an honest gap instead of silently + * compressing the X-axis. Older worker blobs predate the nulls and + * carry bare number arrays — a valid subset of this type. */ +export type Series24h = (number | null)[]; export type MetricPanel = { id: string; @@ -125,14 +130,15 @@ export type MetricPanel = { valuesMeta?: Record; /** Per-provider 24h time-series (72 points by default), keyed by * provider slug. Powers the multi-line chart view of the panel. - * Providers with no Prom data for the query are absent from the map. */ - seriesByProvider?: Record; + * Providers with no Prom data for the query are absent from the map. + * Entries are dense with nulls for empty buckets (see Series24h). */ + seriesByProvider?: Record; /** Per-provider 7 day time-series (84 points by default). Used by the * chart's 7D range tab when a panel is active. */ - seriesByProvider7d?: Record; + seriesByProvider7d?: Record; /** Per-provider 30 day time-series (60 points by default). Used by the * chart's 30D range tab when a panel is active. */ - seriesByProvider30d?: Record; + seriesByProvider30d?: Record; }; export type ResultExtras = { From c908717ec8078e8df054b9ad404958db18c5a8fb Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 17:34:15 +0200 Subject: [PATCH 19/24] charts: render null buckets as gaps, keep sparklines numbers-only --- scripts/dry-run-spec.ts | 14 +++++--- .../benchmarks/[slug]/share-card/route.tsx | 7 +++- src/components/ledger-table.tsx | 8 +++-- src/components/mini-chart.tsx | 2 +- src/components/sparkline.tsx | 21 ++++++++---- src/components/time-series-chart/chart.tsx | 32 +++++++++++++++---- src/components/time-series-chart/index.tsx | 20 ++++++------ src/components/time-series-chart/scales.ts | 12 ++++--- src/components/time-series-chart/series.tsx | 9 +++--- src/lib/citation.ts | 8 +++-- src/lib/downsample.ts | 15 +++++++-- src/lib/rpc-hub-stats.ts | 6 +++- worker/index.ts | 2 +- 13 files changed, 107 insertions(+), 49 deletions(-) diff --git a/scripts/dry-run-spec.ts b/scripts/dry-run-spec.ts index dce72efd..683de48a 100644 --- a/scripts/dry-run-spec.ts +++ b/scripts/dry-run-spec.ts @@ -94,13 +94,17 @@ async function main() { } continue; } - const min = Math.min(...s); - const max = Math.max(...s); - const last = s[s.length - 1]; - const meanV = s.reduce((a, b) => a + b, 0) / s.length; + // Dense series carry nulls for empty Prom buckets; stats read the + // real samples only, but report the gap count alongside. + const present = s.filter((v): v is number => v != null); + const gaps = s.length - present.length; + const min = Math.min(...present); + const max = Math.max(...present); + const last = present[present.length - 1]; + const meanV = present.reduce((a, b) => a + b, 0) / Math.max(1, present.length); console.log( ` ${p.slug.padEnd(18)} ` + - `points=${s.length} ` + + `points=${s.length}${gaps > 0 ? ` (${gaps} empty)` : ""} ` + `min=${fmt(min)} max=${fmt(max)} mean=${fmt(meanV)} last=${fmt(last)}` ); } diff --git a/src/app/benchmarks/[slug]/share-card/route.tsx b/src/app/benchmarks/[slug]/share-card/route.tsx index d462c280..3ecc6c15 100644 --- a/src/app/benchmarks/[slug]/share-card/route.tsx +++ b/src/app/benchmarks/[slug]/share-card/route.tsx @@ -925,7 +925,12 @@ async function renderSnapshot( .map((r) => ({ slug: r.slug, name: r.name, - values: benchmark.extras.series24h[r.slug] ?? [], + // Dense series carry nulls for empty Prom buckets. The OG snapshot + // is a static thumbnail, so skip them (connect across the gap) + // rather than break the polyline. + values: (benchmark.extras.series24h[r.slug] ?? []).filter( + (v): v is number => v != null, + ), color: colors.get(r.slug) ?? INK_SOFT, p50: r.ms.p50, })) diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index 71749795..b98c53de 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -309,7 +309,11 @@ export function LedgerTable({ // min/max projects panel values wildly out of bounds and the trend // column renders vertical streaks running off the row. const sparkSource = activePanel?.seriesByProvider ?? extras.series24h; - const allSeries = Object.values(sparkSource).flat(); + // Nulls (empty Prom buckets in dense series) carry no magnitude and + // must not touch the shared sparkline scale. + const allSeries = Object.values(sparkSource) + .flat() + .filter((v): v is number => v != null); const sparkMin = allSeries.length ? Math.min(...allSeries) : 0; const sparkMax = allSeries.length ? Math.max(...allSeries) : 1; @@ -676,7 +680,7 @@ function Row({ /** Custom-column mode (benchmark.ledgerColumns): one pre-resolved * {value, unit} per declared column, replacing p50/p90/p99/Mean. */ customCells?: { v: number | null; unit: string }[]; - series: number[]; + series: (number | null)[]; sparkMin: number; sparkMax: number; color: string; diff --git a/src/components/mini-chart.tsx b/src/components/mini-chart.tsx index b2d4afa5..42de2957 100644 --- a/src/components/mini-chart.tsx +++ b/src/components/mini-chart.tsx @@ -17,7 +17,7 @@ import { buildProviderColors } from "@/lib/series-colors"; type MiniChartBenchmark = { results: { slug: string; name: string; ms: { p50: number } }[]; higherIsBetter: boolean; - extras: { series24h: Record }; + extras: { series24h: Record }; }; type Props = { diff --git a/src/components/sparkline.tsx b/src/components/sparkline.tsx index f867fa1c..87cc93ef 100644 --- a/src/components/sparkline.tsx +++ b/src/components/sparkline.tsx @@ -1,5 +1,9 @@ type Props = { - values: number[]; + /** Dense series; `null` marks an empty Prom bucket. Nulls are skipped + * (the polyline connects across them). At 92x22 px a broken segment + * reads as noise, so compressing over gaps is the honest-enough + * cheap option for the trend column. */ + values: (number | null)[]; width?: number; height?: number; color?: string; @@ -15,20 +19,23 @@ export function Sparkline({ globalMax, globalMin, }: Props) { - if (!values.length) return null; - const min = globalMin ?? Math.min(...values); - const max = globalMax ?? Math.max(...values); + const present = values.filter( + (v): v is number => v != null && Number.isFinite(v), + ); + if (!present.length) return null; + const min = globalMin ?? Math.min(...present); + const max = globalMax ?? Math.max(...present); const range = max - min || 1; - const points = values + const points = present .map((v, i) => { - const x = (i / (values.length - 1)) * width; + const x = (i / Math.max(1, present.length - 1)) * width; const y = height - ((v - min) / range) * height; return `${x.toFixed(2)},${y.toFixed(2)}`; }) .join(" "); - const last = values[values.length - 1]; + const last = present[present.length - 1]; const lastY = height - ((last - min) / range) * height; return ( diff --git a/src/components/time-series-chart/chart.tsx b/src/components/time-series-chart/chart.tsx index bb16fd54..6998b116 100644 --- a/src/components/time-series-chart/chart.tsx +++ b/src/components/time-series-chart/chart.tsx @@ -67,8 +67,18 @@ export function Chart({ // outlier (e.g. GeckoTerminal at 11s while the others sit under 1s) // lets the remaining lines spread out. If everything is excluded we // fall back to the full set so the axis doesn't collapse. - const visibleValues = slicedLines.filter((l) => !l.excluded).flatMap((l) => l.values); - const sourceValues = visibleValues.length > 0 ? visibleValues : slicedLines.flatMap((l) => l.values); + // Nulls are empty Prom buckets (gaps); they carry no magnitude and + // must not touch the Y domain (Math.min would coerce null to 0 and + // pin the axis floor). + const finiteOnly = (vs: (number | null)[]) => + vs.filter((v): v is number => v != null && Number.isFinite(v)); + const visibleValues = finiteOnly( + slicedLines.filter((l) => !l.excluded).flatMap((l) => l.values), + ); + const sourceValues = + visibleValues.length > 0 + ? visibleValues + : finiteOnly(slicedLines.flatMap((l) => l.values)); const dataMin = Math.min(...sourceValues); const dataMax = Math.max(...sourceValues); const targetTicks = niceTicks(dataMin, dataMax, 4); @@ -207,9 +217,12 @@ export function Chart({ const expected = Math.max(1, expectedPoints - 1); return slicedLines.map((l) => { const color = l.color; - const positive = l.values.filter((v) => v > 0); + const positive = l.values.filter((v): v is number => v != null && v > 0); const positiveMin = positive.length > 0 ? Math.min(...positive) : 0; - const isGap = (v: number) => !Number.isFinite(v) || (v === 0 && positiveMin > 1); + // Null = empty Prom bucket, always a gap. The zero heuristic stays + // for pre-null blobs where an outage was recorded as hard zeroes. + const isGap = (v: number | null) => + v == null || !Number.isFinite(v) || (v === 0 && positiveMin > 1); const lastIdx = Math.max(0, l.values.length - 1); // If Prom returned more points than the chart was sized for (off-by-one @@ -223,7 +236,9 @@ export function Chart({ const pts = l.values.map((v, i) => { const offsetFromRight = (lastIdx - i) / denom; const x = padL + innerW * (1 - offsetFromRight); - const y = padT + innerH * (1 - (v - lo) / yRange); + // Gap points are never drawn; anchor their y at the domain floor + // so the coordinate stays finite. + const y = padT + innerH * (1 - ((v ?? lo) - lo) / yRange); return { x, y, gap: isGap(v) } as const; }); @@ -269,7 +284,7 @@ export function Chart({ if (lastDrawn) closeSegment(lastDrawn.x); // End-of-line label uses the last non-gap value. - const last = lastDrawn ? l.values[pts.indexOf(lastDrawn)] : 0; + const last = (lastDrawn ? l.values[pts.indexOf(lastDrawn)] : 0) ?? 0; const lastX = lastDrawn ? lastDrawn.x : padL + innerW; const lastY = lastDrawn ? lastDrawn.y : padT + innerH; // Expose isGap so the hover dot + tooltip can drop a sample that @@ -325,7 +340,10 @@ export function Chart({ const value = d.values[localIdx]; return { ...d, value }; }) - .filter((d) => Number.isFinite(d.value) && !d.isGap(d.value)) + .filter( + (d): d is (typeof d) & { value: number } => + d.value != null && Number.isFinite(d.value) && !d.isGap(d.value), + ) .sort((a, b) => b.value - a.value); }, [drawn, hover, numPoints]); diff --git a/src/components/time-series-chart/index.tsx b/src/components/time-series-chart/index.tsx index 1e941305..6e105ae1 100644 --- a/src/components/time-series-chart/index.tsx +++ b/src/components/time-series-chart/index.tsx @@ -42,11 +42,11 @@ type Props = { * `benchmark.extras.series24h[slug]`, swaps the metric name in the * header, and switches the Y-axis unit. Used by the bench page when * the reader selects a companion metric from the panel tab row. */ - seriesOverride?: Record; + seriesOverride?: Record; /** Optional 7 day and 30 day variants of the panel override. The chart * picks the matching one when the range tab is 7d or 30d. */ - seriesOverride7d?: Record; - seriesOverride30d?: Record; + seriesOverride7d?: Record; + seriesOverride30d?: Record; metricLabelOverride?: string; unitOverride?: Benchmark["unit"]; /** Direction override for ranking when a metric panel is active. Bench @@ -133,8 +133,8 @@ export function TimeSeriesChart({ // rest of the session. CDN cache-control on /api/series (60 s // s-maxage + 300 s SWR) absorbs concurrent visitors so Prom sees at // most one fan-out per (bench, range) per minute. - const [lazySeries7d, setLazySeries7d] = useState | null>(null); - const [lazySeries30d, setLazySeries30d] = useState | null>(null); + const [lazySeries7d, setLazySeries7d] = useState | null>(null); + const [lazySeries30d, setLazySeries30d] = useState | null>(null); // Pre-fetch 7d AND 30d in the background as soon as the chart mounts, // not just when the user clicks the tab. The fetches are non-blocking @@ -163,10 +163,10 @@ export function TimeSeriesChart({ if (cancelled || done[range]) return; fetch(`/api/series/${benchmark.slug}?${buildQs(range)}`) .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) - .then((data: { providers: { slug: string; values: number[] }[] }) => { + .then((data: { providers: { slug: string; values: (number | null)[] }[] }) => { if (cancelled) return; done[range] = true; - const map: Record = {}; + const map: Record = {}; for (const p of data.providers) map[p.slug] = p.values; if (range === "7d") setLazySeries7d(map); else setLazySeries30d(map); @@ -236,13 +236,13 @@ export function TimeSeriesChart({ // the matching variant if it exists. Sub 24h tabs slice the 24h // variant trailing edge. Long range tabs fall back to the 24h // variant when the longer one is missing (older specs). - const pickPanel = (): Record | undefined => { + const pickPanel = (): Record | undefined => { if (range === "30d" && seriesOverride30d) return seriesOverride30d; if (range === "7d" && seriesOverride7d) return seriesOverride7d; return seriesOverride; }; const panel = pickPanel(); - const sliceOverride = (full: number[]): number[] => { + const sliceOverride = (full: (number | null)[]): (number | null)[] => { // Sub 24h ranges slice the 24h panel series trailing edge. if (range === "24h" || range === "7d" || range === "30d") return full; const ratio = RANGE_HOURS[range] / 24; @@ -255,7 +255,7 @@ export function TimeSeriesChart({ // long-window archive for the bench. // Pick from the lazy map when in those ranges, fall back to // pickSeries (which uses benchmark.extras.series24h) otherwise. - const pickBenchValues = (slug: string): number[] => { + const pickBenchValues = (slug: string): (number | null)[] => { if (isLongRange) { return longRangeSeries?.[range]?.[slug] ?? []; } diff --git a/src/components/time-series-chart/scales.ts b/src/components/time-series-chart/scales.ts index 025476de..876767bb 100644 --- a/src/components/time-series-chart/scales.ts +++ b/src/components/time-series-chart/scales.ts @@ -84,7 +84,8 @@ export type LineWithColor = { slug: string; name: string; color: string; - values: number[]; + /** Dense series; `null` marks an empty Prom bucket (rendered as a gap). */ + values: (number | null)[]; excluded: boolean; }; @@ -93,7 +94,7 @@ export function pickSeries( slug: string, range: Range, region: string -): number[] { +): (number | null)[] { const allRegion = isAll(region); if (!allRegion) { @@ -134,9 +135,10 @@ export function pickSeries( return s24.slice(-take); } -export function mean(xs: number[]): number { - if (!xs.length) return 0; - return xs.reduce((s, v) => s + v, 0) / xs.length; +export function mean(xs: (number | null)[]): number { + const present = xs.filter((v): v is number => v != null); + if (!present.length) return 0; + return present.reduce((s, v) => s + v, 0) / present.length; } /** diff --git a/src/components/time-series-chart/series.tsx b/src/components/time-series-chart/series.tsx index a28d25b7..22e685cd 100644 --- a/src/components/time-series-chart/series.tsx +++ b/src/components/time-series-chart/series.tsx @@ -8,7 +8,7 @@ export type DrawnLine = LineWithColor & { lastX: number; lastY: number; last: number; - isGap: (v: number) => boolean; + isGap: (v: number | null) => boolean; }; type SeriesPathsProps = { @@ -153,9 +153,10 @@ export function HoverMarkers({ const t = fract - i0; const v0 = d.values[i0]; const v1 = d.values[i1]; - // If either bracketing sample is a gap, the line itself is - // broken here (drawn as `M` not `L`) — skip the dot so we - // don't paint over a missing segment. + // If either bracketing sample is a gap (null bucket included), + // the line itself is broken here (drawn as `M` not `L`) — skip + // the dot so we don't paint over a missing segment. + if (v0 == null || v1 == null) return null; if (!Number.isFinite(v0) || !Number.isFinite(v1)) return null; if (d.isGap(v0) || d.isGap(v1)) return null; const v = v0 + (v1 - v0) * t; diff --git a/src/lib/citation.ts b/src/lib/citation.ts index 41d3aab9..ad3eb923 100644 --- a/src/lib/citation.ts +++ b/src/lib/citation.ts @@ -194,10 +194,14 @@ export function citeBundle( export function sparklineFor(b: Benchmark, providerSlug?: string): number[] { const series = b.extras.series24h ?? {}; const pick = providerSlug && series[providerSlug] ? series[providerSlug] : firstSeries(series); - return pick ?? []; + // Dense series carry nulls for empty Prom buckets; the citation JSON + // sparkline stays a plain number array for external consumers. + return (pick ?? []).filter((v): v is number => v != null); } -function firstSeries(s: Record): number[] | null { +function firstSeries( + s: Record, +): (number | null)[] | null { for (const k of Object.keys(s)) { const v = s[k]; if (v && v.length > 0) return v; diff --git a/src/lib/downsample.ts b/src/lib/downsample.ts index 5c0566a5..e154b7d4 100644 --- a/src/lib/downsample.ts +++ b/src/lib/downsample.ts @@ -5,8 +5,15 @@ * BenchmarkCardData projection (server) so the hub can ship pre-shrunk * series over the RSC wire without changing what the chart draws. */ -export function downsample(values: number[], target: number): number[] { - if (values.length <= target) return values; +export function downsample(values: (number | null)[], target: number): number[] { + // Dense series carry nulls for empty Prom buckets. Sparkline-scale + // charts cannot usefully render a gap, so nulls are dropped: within a + // bucket the mean is taken over real samples only, and buckets with + // no sample at all are omitted (same behavior the pre-null code had + // for empty buckets). + if (values.length <= target) { + return values.filter((v): v is number => v != null); + } const bucketSize = values.length / target; const out: number[] = []; for (let i = 0; i < target; i++) { @@ -15,7 +22,9 @@ export function downsample(values: number[], target: number): number[] { let sum = 0; let n = 0; for (let j = start; j < end && j < values.length; j++) { - sum += values[j]; + const v = values[j]; + if (v == null) continue; + sum += v; n++; } if (n > 0) out.push(sum / n); diff --git a/src/lib/rpc-hub-stats.ts b/src/lib/rpc-hub-stats.ts index 5912361e..8575e3ce 100644 --- a/src/lib/rpc-hub-stats.ts +++ b/src/lib/rpc-hub-stats.ts @@ -223,7 +223,11 @@ async function buildChain(spec: Spec): Promise { const leader = rows[0]; const chain = spec.slug.replace(/-rpc$/, ""); - const leaderSeries = bench.extras.series24h?.[leader.slug]; + // Dense series carry nulls for empty Prom buckets; the hub sparkline + // blob stays numbers-only (48-pt cap, gap fidelity irrelevant there). + const leaderSeries = bench.extras.series24h?.[leader.slug]?.filter( + (v): v is number => v != null, + ); // Unresponsive rows are excluded from `rows` by liveRows (they carry // availability="unavailable" and zero latency), so they can't touch // best/fastest — surface count + identity/success for display-only diff --git a/worker/index.ts b/worker/index.ts index 5ce7be12..ddac2b5f 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -67,7 +67,7 @@ const BENCH_CONCURRENCY = Number(process.env.BENCH_CONCURRENCY ?? 3); function ringFromSeries( window: keyof typeof RING_CADENCE, - series: number[], + series: (number | null)[], now: number, ): SeriesRing { const { stepSec } = RING_CADENCE[window]; From 2998ff39f63ea2ab681321bcd756585a56ce2136 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 17:35:21 +0200 Subject: [PATCH 20/24] api/series: dense timestamp grid anchored at window start; bridge nulls for video renderer --- src/app/api/series/[slug]/route.ts | 19 +++++++---- src/lib/export-video/fetch-series.ts | 49 +++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/app/api/series/[slug]/route.ts b/src/app/api/series/[slug]/route.ts index f5554a12..47c52114 100644 --- a/src/app/api/series/[slug]/route.ts +++ b/src/app/api/series/[slug]/route.ts @@ -30,7 +30,7 @@ const getSeriesMapCached = unstable_cache( region: string | undefined, kind: string | undefined, venue: string | undefined, - ): Promise | null> => { + ): Promise | null> => { const sig = filterSig({ chain, region, kind, venue }); const stored = await readMaterialized(slug, sig); if (stored) { @@ -49,7 +49,10 @@ const getSeriesMapCached = unstable_cache( const b = await specToBenchmark(spec, { chain, region, kind, venue }); return (range === "7d" ? b.extras.series7d : b.extras.series30d) ?? null; }, - ["series-by-range-v3"], + // v4: dense series with explicit nulls for empty Prom buckets. v3 + // entries hold the old hole-compressed arrays whose length no longer + // matches the dense timestamp grid emitted below. + ["series-by-range-v4"], { revalidate: 300, tags: ["benchmarks"] }, ); @@ -151,7 +154,7 @@ export async function GET( // a 100-KB series map is cheap enough that we still need the row // metadata (name, color, logo) — fetch the cached bench for that // separately so its slim ~50 KB payload reuses the existing cache. - let seriesMap: Record | undefined | null; + let seriesMap: Record | undefined | null; let bench; if (rangeParam === "7d" || rangeParam === "30d") { [seriesMap, bench] = await Promise.all([ @@ -179,15 +182,17 @@ export async function GET( } // Timestamps are not persisted with the series — reconstruct from the - // Prom window. We trust whatever length the series came back with - // (Prom may drop empty buckets) so each provider's values stay aligned - // with the timestamp array. + // Prom window. Series are DENSE (one slot per query_range evaluation + // step, null where Prom had no sample), so the grid spans the full + // window: timestamps[0] = now - window, last = now. Values with nulls + // stay index-aligned with this array; consumers see the outage as + // null slots instead of a silently shifted X-axis. const { windowMs, points: targetPoints } = RANGE_CONFIG[rangeParam]; const firstSeries = Object.values(seriesMap).find((arr) => arr.length > 0); const actualPoints = firstSeries?.length ?? targetPoints; - const stepMs = windowMs / Math.max(1, actualPoints); const endMs = Date.now(); const startMs = endMs - windowMs; + const stepMs = windowMs / Math.max(1, actualPoints - 1); const timestamps: number[] = []; for (let i = 0; i < actualPoints; i++) { timestamps.push(Math.round(startMs + i * stepMs)); diff --git a/src/lib/export-video/fetch-series.ts b/src/lib/export-video/fetch-series.ts index 69136104..8d026518 100644 --- a/src/lib/export-video/fetch-series.ts +++ b/src/lib/export-video/fetch-series.ts @@ -18,6 +18,46 @@ export type SeriesFilters = { venue?: string | null; }; +/** What /api/series actually emits: dense values with `null` for empty + * Prom buckets. The external Remotion renderer consumes plain number + * arrays (BenchPayload contract), so nulls are bridged before hand-off. */ +type WireBenchPayload = Omit & { + providers: (Omit & { + values: (number | null)[]; + })[]; +}; + +/** Bridge null buckets for the video renderer: linear interpolation + * between the nearest real neighbors, and leading / trailing nulls + * clamped to the nearest real value. Honest enough for an animated + * race (the race reads trajectory, not per-bucket truth) and keeps the + * renderer's numbers-only contract intact. Returns [] when the series + * has no real sample at all. */ +export function bridgeGaps(values: (number | null)[]): number[] { + const firstIdx = values.findIndex((v) => v != null); + if (firstIdx === -1) return []; + const out = new Array(values.length); + let prevIdx = -1; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v == null) continue; + if (prevIdx === -1) { + // Clamp leading nulls to the first real value. + for (let j = 0; j <= i; j++) out[j] = v; + } else { + const prev = out[prevIdx]; + const span = i - prevIdx; + for (let j = prevIdx + 1; j <= i; j++) { + out[j] = prev + ((v - prev) * (j - prevIdx)) / span; + } + } + prevIdx = i; + } + // Clamp trailing nulls to the last real value. + for (let j = prevIdx + 1; j < values.length; j++) out[j] = out[prevIdx]; + return out; +} + export async function fetchBenchSeries( slug: string, range: RangeId, @@ -36,5 +76,12 @@ export async function fetchBenchSeries( const text = await res.text().catch(() => ""); throw new Error(`/api/series failed (${res.status}): ${text || res.statusText}`); } - return res.json(); + const wire: WireBenchPayload = await res.json(); + return { + ...wire, + providers: wire.providers.map((p) => ({ + ...p, + values: bridgeGaps(p.values), + })), + }; } From 09fad9130aa3740d7666a1d91fc0ad0125eab4e6 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 17:35:43 +0200 Subject: [PATCH 21/24] spec: bump bench cache keys for dense series shape --- src/lib/spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/spec.ts b/src/lib/spec.ts index 048802c0..7f9ea805 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -269,7 +269,11 @@ const loadBenchmarkUnfilteredCached = unstable_cache( // level on VERCEL_ENV=production). Bench SET now differs per env, so // the env is part of the cache key to keep prod and preview entries // from colliding. - ["bench-unfiltered-v26", process.env.VERCEL_ENV === "production" ? "prod" : "all"], + // v27: series arrays became dense with explicit nulls for empty Prom + // buckets (gap rendering fix). Cached v26 entries hold the old + // hole-compressed arrays whose indices no longer map onto the nominal + // step grid the chart back-computes timestamps from. + ["bench-unfiltered-v27", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] }, ); @@ -423,7 +427,8 @@ const loadAllBenchmarksCached = unstable_cache( // ungated; the lockstep bump was missed in #1105 and prod kept serving the // gated catalog to /products for 30+ min after the deploy). // v29: bumped with bench-unfiltered-v26 (monad-rpc + megaeth-rpc ship). - ["all-benchmarks-v29", process.env.VERCEL_ENV === "production" ? "prod" : "all"], + // v30: bumped with bench-unfiltered-v27 (dense series with nulls). + ["all-benchmarks-v30", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] }, ); export const loadAllBenchmarks = cache(loadAllBenchmarksCached); @@ -501,7 +506,8 @@ const loadBenchmarkFiltered = unstable_cache( // v15: bumped with bench-unfiltered-v25 (prod-only bench gate); env in key. // v16: bumped with the bench 074 ship (lockstep rule, see all-benchmarks-v28). // v17: bumped with the monad-rpc + megaeth-rpc ship (lockstep rule). - ["bench-filters-v17", process.env.VERCEL_ENV === "production" ? "prod" : "all"], + // v18: bumped with bench-unfiltered-v27 (dense series with nulls). + ["bench-filters-v18", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] } ); From dab1fb65fa3fcf410c34ed4a06f57513d70d5b40 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 17:37:09 +0200 Subject: [PATCH 22/24] tests: dense bucket grid + video gap bridging --- src/lib/export-video/fetch-series.test.ts | 20 ++++++ src/lib/prometheus.test.ts | 74 +++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/lib/export-video/fetch-series.test.ts diff --git a/src/lib/export-video/fetch-series.test.ts b/src/lib/export-video/fetch-series.test.ts new file mode 100644 index 00000000..f89a9713 --- /dev/null +++ b/src/lib/export-video/fetch-series.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { bridgeGaps } from "./fetch-series"; + +describe("bridgeGaps", () => { + test("interpolates interior nulls linearly", () => { + expect(bridgeGaps([10, null, null, 40])).toEqual([10, 20, 30, 40]); + }); + + test("clamps leading and trailing nulls to nearest real value", () => { + expect(bridgeGaps([null, null, 5, null])).toEqual([5, 5, 5, 5]); + }); + + test("all-null series collapses to empty", () => { + expect(bridgeGaps([null, null])).toEqual([]); + }); + + test("dense numeric input passes through unchanged", () => { + expect(bridgeGaps([1, 2, 3])).toEqual([1, 2, 3]); + }); +}); diff --git a/src/lib/prometheus.test.ts b/src/lib/prometheus.test.ts index 3a1234c9..74ba7ea7 100644 --- a/src/lib/prometheus.test.ts +++ b/src/lib/prometheus.test.ts @@ -36,3 +36,77 @@ describe("extractMetricName", () => { expect(extractMetricName("5xx_responses")).toBeNull(); }); }); + +import { denseSeriesFromMatrix, type PromMatrix } from "./prometheus"; + +describe("denseSeriesFromMatrix", () => { + // 7d window at 84 requested points: step = floor(604800/84) = 7200s, + // grid = 85 slots (start + k*7200, both endpoints inclusive) — the + // exact geometry of the aggregator-head-lag 7d fetch. + const start = 1_760_000_000; + const step = 7200; + const end = start + 84 * step; + const grid = (k: number) => start + k * step; + + function matrixWithHole(): PromMatrix[] { + // Samples on every grid slot EXCEPT indices 30..39 (a ~20h outage + // hole), mirroring what Prom returns when the harness was down. + const values: [number, string][] = []; + for (let k = 0; k <= 84; k++) { + if (k >= 30 && k <= 39) continue; + values.push([grid(k), String(k)]); + } + return [{ metric: {}, values }]; + } + + test("output length equals the dense grid even with a hole", () => { + const out = denseSeriesFromMatrix(matrixWithHole(), start, end, step); + expect(out).not.toBeNull(); + expect(out!.length).toBe(85); + }); + + test("nulls sit exactly in the hole, values elsewhere", () => { + const out = denseSeriesFromMatrix(matrixWithHole(), start, end, step)!; + for (let k = 0; k <= 84; k++) { + if (k >= 30 && k <= 39) expect(out[k]).toBeNull(); + else expect(out[k]).toBe(k); + } + }); + + test("multi-series values are averaged per bucket", () => { + const a: PromMatrix = { metric: { r: "a" }, values: [[grid(0), "10"], [grid(1), "20"]] }; + const b: PromMatrix = { metric: { r: "b" }, values: [[grid(0), "30"]] }; + const out = denseSeriesFromMatrix([a, b], start, end, step)!; + expect(out[0]).toBe(20); + expect(out[1]).toBe(20); + expect(out[2]).toBeNull(); + }); + + test("returns null when nothing maps onto the grid", () => { + expect(denseSeriesFromMatrix([{ metric: {}, values: [] }], start, end, step)).toBeNull(); + }); + + test("samples snap to the nearest grid slot; out-of-window dropped", () => { + const m: PromMatrix[] = [ + { + metric: {}, + values: [ + [grid(3) + step * 0.4, "1"], // snaps to slot 3 + [grid(5) + 0.5, "2"], // fractional-seconds start, snaps to slot 5 + [start - step, "9"], // before window + [end + step, "9"], // after window + ], + }, + ]; + const out = denseSeriesFromMatrix(m, start, end, step)!; + expect(out[3]).toBe(1); + expect(out[5]).toBe(2); + expect(out.length).toBe(85); + }); + + test("rounds to 6 significant digits", () => { + const m: PromMatrix[] = [{ metric: {}, values: [[grid(0), "123.4567891"]] }]; + const out = denseSeriesFromMatrix(m, start, end, step)!; + expect(out[0]).toBe(123.457); + }); +}); From 2c3ec841f1c4f11c04bc13478dfe999d37e7a2f3 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 17:37:15 +0200 Subject: [PATCH 23/24] solana-rpc: first non-EVM member of the RPC cluster (bench 075, staging gated) --- benchmarks/solana-rpc.yml | 190 ++++++++++++++++++ .../rpc-capabilities/cmd/script/archive.go | 5 + .../rpc-capabilities/cmd/script/config.go | 25 +++ .../rpc-capabilities/cmd/script/probe.go | 73 ++++++- src/lib/removed-benches.ts | 1 + 5 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 benchmarks/solana-rpc.yml diff --git a/benchmarks/solana-rpc.yml b/benchmarks/solana-rpc.yml new file mode 100644 index 00000000..63a32c21 --- /dev/null +++ b/benchmarks/solana-rpc.yml @@ -0,0 +1,190 @@ +# OpenChainBench. Bench № 075 + +slug: solana-rpc +number: "075" +title: Fastest free Solana RPC, live no-key endpoint latency +seo_title: "Fastest free Solana RPC 2026" +seo_description: "{{best_name}} leads free Solana RPC at {{best_p50}} (getSlot p50, 24h). 5 no-key providers measured every 60s from 3 regions, keyless." +subtitle: HTTP round-trip latency for getSlot at the processed commitment against every free, no-key public Solana RPC endpoint, audited every 60 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Solana is the first non-EVM member of the RPC latency cluster, and the chain where the free-RPC question is asked most and answered worst: most "best Solana RPC" roundups list endpoints that key-gated or died years ago. This page probes the 5 endpoints that actually answer keyless today, including the Solana Labs official api.mainnet-beta.solana.com that every tutorial pastes, with the same rules as the 20 EVM chains in the cluster: one identical probe (getSlot at the processed commitment) every 60 seconds from us-east, eu-west and Singapore, stale-slot detection against the cross-provider tip, and result classification so a fast error never ranks as a fast answer. The candidates that failed the keyless audit are listed with their exact refusals: dRPC moved Solana to paid tiers, Ankr returns 403 without a key, OnFinality's shared public quota is permanently exhausted. + +abstract: | + Per-chain member of the RPC latency cluster and its first non-EVM + chain. We measure round-trip latency of a single identical JSON-RPC + call (getSlot at the processed commitment) against every no-key + public Solana endpoint that sustains continuous probing, 5 providers, + every 60 seconds, from us-east, eu-west and Singapore. Responses are + classified (ok / http_err / jsonrpc_err / stale / timeout) with + staleness measured in slots against the cross-provider tip. The + archive-depth audit run on EVM chains has no keyless Solana + equivalent and is not performed. The cross-chain view lives on the + parent rpc-capabilities benchmark; this page is the Solana-scoped + answer with per-region breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 60 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"getSlot\",\"params\":[{\"commitment\":\"processed\"}]}`. Plain HTTP POST, identical for every endpoint, no API key in any request; the rotating id defeats body-keyed edge caches." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram, percentiles computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok`, `http_err`, `jsonrpc_err`, `stale` (more than 300 slots, about 2 minutes, behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." + - "No archive-depth audit on Solana: the EVM chains probe `eth_getBalance` at historical heights, which has no equivalent on public Solana endpoints. Disclosed rather than faked." + - "LeoRPC disclosure: the endpoint uses a publicly documented FREE query key (solana.leorpc.com/?api_key=FREE). It is admitted as keyless in practice since no signup is required; flagged here for transparency." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; identical harness and exclusion rules." + - "Chain scope: every query on this page is pinned to chain=\"solana\". Provider coverage: 5 no-key endpoints (Solana Labs, PublicNode, Lava, LeoRPC, Solana Vibe Station). Excluded with verified refusals 2026-07-12: dRPC (Solana is paid-tier only), Ankr (403 key required), OnFinality (shared quota permanently 429), BlockPI (no public URL), Triton free.rpcpool.com (403), Blast API + ExtrNode + AllThatNode (DNS dead), OMNIA (521), Helius + Shyft + BlockEden (key-gated)." + +findings: + - "{{best_name}} currently leads free Solana RPC at {{best_p50}} (getSlot p50, 24h) across 5 measured providers." + - "{{name:solana-official}} ({{p50:solana-official}}) is the endpoint every tutorial pastes. Solana Labs documents its limits at 100 requests per 10 seconds per IP, generous for development and exactly the kind of endpoint this bench exists to sanity-check." + - "{{name:publicnode}} ({{p50:publicnode}}) extends its EVM cluster footprint to Solana from the same Allnodes infrastructure, and arrives with the credibility of leading a large share of the EVM per-chain boards." + - "{{name:lava}} ({{p50:lava}}) routes through the Lava gateway mesh, the same architecture as its Ethereum and Arbitrum endpoints; expect higher variance than single-origin providers." + - "{{name:leorpc}} ({{p50:leorpc}}) and {{name:solanavibestation}} ({{p50:solanavibestation}}) are the community tier: smaller operations whose continued keyless availability this bench monitors live rather than assumes." + +faq: + - q: "What is the fastest free Solana RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (getSlot p50 over the last 24h), measured against 5 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples; use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which Solana RPCs work without an API key?" + a: "The 5 providers on this page: Solana Labs (api.mainnet-beta.solana.com), PublicNode, Lava, LeoRPC (publicly documented FREE key) and Solana Vibe Station. Every endpoint was live-verified before inclusion. The famous names that do not work keyless anymore are listed in the methodology with their exact refusals: dRPC moved Solana to paid tiers, Ankr 403s, OnFinality's public quota is exhausted, Helius and Shyft are key-gated." + - q: "Is api.mainnet-beta.solana.com good enough for production?" + a: "For development, demos and light read workloads, yes: Solana Labs documents roughly 100 requests per 10 seconds per IP. For production trading, indexing or anything latency-sensitive, dedicated providers exist for a reason; this page measures the free tier so you know exactly what the default gets you before you decide." + - q: "How is Solana RPC latency measured here?" + a: "One identical JSON-RPC POST (getSlot at the processed commitment) every 60 seconds against each provider from each of 3 regions. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 come from Prometheus quantile_over_time over 24 hours. Responses are classified so an endpoint stuck behind the cross-provider slot tip or erroring behind HTTP 200 is never ranked as fastest. The harness is open source." + - q: "Why is there no archive-depth column like the EVM pages?" + a: "The EVM cluster probes eth_getBalance at historical block heights to expose which free endpoints serve archive state. Public Solana endpoints expose no comparable keyless historical-state query, so the audit is disclosed as not applicable rather than approximated." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{provider=~"solana-official|publicnode|lava|leorpc|solanavibestation", chain="solana"}) + +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: solana-official + name: Solana Labs + tag: Chain-official api.mainnet-beta.solana.com, 100 req per 10s documented + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `getSlot` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to api.mainnet-beta.solana.com." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solana-official", chain="solana"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="solana-official", chain="solana"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="solana-official", chain="solana"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="solana-official", chain="solana"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="solana-official", chain="solana"}) / sum(ocb:rpc_call:rate_24h{provider="solana-official", chain="solana"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="solana-official", chain="solana"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="solana-official", chain="solana"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solana-official", chain="solana", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="solana-official", chain="solana", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solana-official", chain="solana", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="solana-official", chain="solana", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solana-official", chain="solana", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="solana-official", chain="solana", region="sgp"}[1h]) + + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, same footprint as its EVM cluster entries + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `getSlot` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to solana-rpc.publicnode.com." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="solana"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="solana"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="solana"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="solana"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="solana"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="solana"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="solana"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="solana"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="solana", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="solana", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="solana", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="solana", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="solana", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="solana", region="sgp"}[1h]) + + - slug: lava + name: Lava + tag: Gateway mesh, pooled per-IP quota + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `getSlot` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to solana.lava.build." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="solana"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="lava", chain="solana"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="lava", chain="solana"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="lava", chain="solana"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="lava", chain="solana"}) / sum(ocb:rpc_call:rate_24h{provider="lava", chain="solana"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="lava", chain="solana"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="lava", chain="solana"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="solana", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="lava", chain="solana", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="solana", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="lava", chain="solana", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="solana", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="lava", chain="solana", region="sgp"}[1h]) + + - slug: leorpc + name: LeoRPC + tag: Community endpoint, publicly documented FREE query key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `getSlot` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to solana.leorpc.com." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="leorpc", chain="solana"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="leorpc", chain="solana"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="leorpc", chain="solana"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="leorpc", chain="solana"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="leorpc", chain="solana"}) / sum(ocb:rpc_call:rate_24h{provider="leorpc", chain="solana"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="leorpc", chain="solana"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="leorpc", chain="solana"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="leorpc", chain="solana", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="leorpc", chain="solana", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="leorpc", chain="solana", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="leorpc", chain="solana", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="leorpc", chain="solana", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="leorpc", chain="solana", region="sgp"}[1h]) + + - slug: solanavibestation + name: Solana Vibe Station + tag: Community endpoint, unpublished cap, churn monitored live + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `getSlot` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to public.rpc.solanavibestation.com." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solanavibestation", chain="solana"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="solanavibestation", chain="solana"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="solanavibestation", chain="solana"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="solanavibestation", chain="solana"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="solanavibestation", chain="solana"}) / sum(ocb:rpc_call:rate_24h{provider="solanavibestation", chain="solana"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="solanavibestation", chain="solana"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="solanavibestation", chain="solana"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solanavibestation", chain="solana", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="solanavibestation", chain="solana", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solanavibestation", chain="solana", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="solanavibestation", chain="solana", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solanavibestation", chain="solana", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="solanavibestation", chain="solana", region="sgp"}[1h]) diff --git a/harnesses/rpc-capabilities/cmd/script/archive.go b/harnesses/rpc-capabilities/cmd/script/archive.go index e4069813..8362f56b 100644 --- a/harnesses/rpc-capabilities/cmd/script/archive.go +++ b/harnesses/rpc-capabilities/cmd/script/archive.go @@ -43,6 +43,11 @@ var depthBuckets = []uint64{300, 7200, 216000, 1296000, 5000000} func StartArchiveLoop(ctx context.Context) { for _, c := range chains() { c := c + if c.Kind == "solana" { + // eth_getBalance at historical heights has no Solana + // equivalent on public endpoints; skip the archive loop. + continue + } for _, p := range c.Providers { p := p go archiveOne(ctx, c, p) diff --git a/harnesses/rpc-capabilities/cmd/script/config.go b/harnesses/rpc-capabilities/cmd/script/config.go index d23fe91d..c759b0b5 100644 --- a/harnesses/rpc-capabilities/cmd/script/config.go +++ b/harnesses/rpc-capabilities/cmd/script/config.go @@ -29,6 +29,10 @@ type Chain struct { Slug string Name string Providers []Provider + // Kind selects the probe path: "" (EVM, eth_getBlockByNumber) or + // "solana" (getSlot at processed commitment, slot-based staleness, + // no archive-depth loop). + Kind string } // chains is the source of truth for the (chain × provider) probe @@ -55,6 +59,27 @@ type Chain struct { // (3 providers), Zora / Abstract / HyperEVM (≤2 keyless providers). func chains() []Chain { return []Chain{ + // ─── Solana mainnet — added 2026-07-12, all 5 endpoints keyless + // and live-verified (getSlot + getLatestBlockhash + getVersion, + // mutually consistent advancing slots). Excluded by that sweep: + // dRPC (Solana paid-only), Ankr (403 key required), OnFinality + // (shared public quota permanently 429), BlockPI (503 no public + // URL), Blast API + ExtrNode + AllThatNode (DNS dead), Triton + // free.rpcpool.com (403), OMNIA (521), Helius/Shyft/BlockEden + // (key-gated). LeoRPC uses a publicly documented FREE query key, + // disclosed in the bench methodology. + { + Slug: "solana", + Name: "Solana", + Kind: "solana", + Providers: []Provider{ + {Slug: "solana-official", Name: "Solana Labs", URL: envDefault("RPC_URL_SOLANA_OFFICIAL", "https://api.mainnet-beta.solana.com")}, + {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_SOLANA_PUBLICNODE", "https://solana-rpc.publicnode.com")}, + {Slug: "lava", Name: "Lava", URL: envDefault("RPC_URL_SOLANA_LAVA", "https://solana.lava.build")}, + {Slug: "leorpc", Name: "LeoRPC", URL: envDefault("RPC_URL_SOLANA_LEORPC", "https://solana.leorpc.com/?api_key=FREE")}, + {Slug: "solanavibestation", Name: "Solana Vibe Station", URL: envDefault("RPC_URL_SOLANA_SVS", "https://public.rpc.solanavibestation.com")}, + }, + }, // ─── Monad mainnet (chain 143) — added 2026-07-08, all endpoints // live-verified (eth_chainId=143 + anti-cache probe). Five official // mirrors exist behind different infra vendors; we probe the primary diff --git a/harnesses/rpc-capabilities/cmd/script/probe.go b/harnesses/rpc-capabilities/cmd/script/probe.go index 620eee81..d19f1925 100644 --- a/harnesses/rpc-capabilities/cmd/script/probe.go +++ b/harnesses/rpc-capabilities/cmd/script/probe.go @@ -26,6 +26,10 @@ const ( // cross-provider tip is classified as `stale`. 20 blocks ≈ 4 min // on Ethereum which generously covers cross-provider drift. staleBlockGap uint64 = 20 + // solanaStaleSlotGap: slots tick every ~400ms, so 300 slots is + // ~2 minutes behind the cross-provider tip - the same order of + // tolerance the EVM gap gives a 12s-block chain. + solanaStaleSlotGap uint64 = 300 ) // chainTips tracks the highest block seen for each chain across all @@ -175,12 +179,24 @@ func probeOne(ctx context.Context, c Chain, p Provider) { tick := func() { probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() - block, result, latency, err := callLatestBlock(probeCtx, p.URL) + var block uint64 + var result string + var latency float64 + var err error + if c.Kind == "solana" { + block, result, latency, err = callLatestSlot(probeCtx, p.URL) + } else { + block, result, latency, err = callLatestBlock(probeCtx, p.URL) + } if result == "ok" { tips.update(c.Slug, block) tip := tips.get(c.Slug) - if tip > 0 && block+staleBlockGap < tip { + gap := staleBlockGap + if c.Kind == "solana" { + gap = solanaStaleSlotGap + } + if tip > 0 && block+gap < tip { result = "stale" } } @@ -227,3 +243,56 @@ func urlJitter(s string) int64 { } return sum } + +// callLatestSlot is the Solana probe path: getSlot at the processed +// commitment with a rotating request id (same anti-cache rule as the +// EVM header fetch). The result is a plain JSON number (the slot), so +// the staleness comparison reuses the chainTips machinery with slots +// in place of block numbers. +func callLatestSlot(ctx context.Context, url string) (slot uint64, result string, latencyMs float64, err error) { + body := []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":"getSlot","params":[{"commitment":"processed"}],"id":%d}`, + time.Now().UnixNano(), + )) + req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + client := &http.Client{Timeout: probeTimeout} + + start := time.Now() + resp, err := client.Do(req) + latencyMs = float64(time.Since(start).Milliseconds()) + + if err != nil { + if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") { + return 0, "timeout", latencyMs, err + } + return 0, "http_err", latencyMs, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + _, _ = io.Copy(io.Discard, resp.Body) + return 0, "http_err", latencyMs, fmt.Errorf("status %d", resp.StatusCode) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return 0, "http_err", latencyMs, err + } + var r rpcBlockEnvelope + if err := json.Unmarshal(raw, &r); err != nil { + return 0, "http_err", latencyMs, err + } + if r.Error != nil { + return 0, "jsonrpc_err", latencyMs, fmt.Errorf("rpc -%d: %s", r.Error.Code, r.Error.Message) + } + if len(r.Result) == 0 || string(r.Result) == "null" { + return 0, "jsonrpc_err", latencyMs, fmt.Errorf("empty result") + } + n, err := strconv.ParseUint(strings.TrimSpace(string(r.Result)), 10, 64) + if err != nil { + return 0, "jsonrpc_err", latencyMs, fmt.Errorf("non-numeric slot: %s", string(r.Result)[:min(len(r.Result), 40)]) + } + return n, "ok", latencyMs, nil +} diff --git a/src/lib/removed-benches.ts b/src/lib/removed-benches.ts index 24cda458..d7823ee9 100644 --- a/src/lib/removed-benches.ts +++ b/src/lib/removed-benches.ts @@ -36,5 +36,6 @@ export const REMOVED_BENCH_SLUGS = new Set([ "indexing-freshness", "rpc-keyed-latency", "explorer-chain-coverage", + "solana-rpc", "portfolio-chain-coverage", ]); From 2d5897807414edaee76e2ef7c481948fbe01a8c9 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:37:53 +0200 Subject: [PATCH 24/24] answers: rewrite two head-to-head YAMLs whose tokens pulled from the wrong axis (#1130) Both YAMLs were shipping short_answer / seo_description / faq copy that referenced {{best_name}} and {{best_p50}}. renderTemplate resolved those tokens against the underlying bench's global leader, but the questions frame a head-to-head that the bench does not answer on the same axis: * polymarket-vs-kalshi-resolution-speed referenced polymarket-resolution-delay, whose dimensions are market categories (Crypto / Sports / Politics), not venues. {{best_name}} returned the fastest category and the page rendered 'Crypto currently resolves faster at 11.3 min' - grammatically fine, factually wrong for a Polymarket vs Kalshi question. Kalshi is also not measured in the bench today. Fix: rewrite the copy to describe what the bench actually measures (Polymarket resolution delay per category, live onchain from UMA events on Polygon), state explicitly that Kalshi ingestion is pending, and point at the bench page for the numbers. No {{}} tokens. * helius-vs-triton-vs-quicknode-solana referenced solana-tx-landing, which includes Jito bundle submission as a separate priority-lane comparator. {{best_name}} could name Jito, mismatching the three RPC providers in the question and reading as broken output. Fix: rewrite the copy to describe the harness methodology, name the three providers explicitly, and flag Jito's presence on the same leaderboard so a reader who lands here from Google or a LLM answer understands why the aggregate leader can be Jito rather than one of the three RPCs. No {{}} tokens. Both YAMLs still list status: live and short_answer is above the schema 40-char floor. Related answers unchanged. No changes to any other YAML. Verified: grep for {{}} tokens in both YAMLs: 0 hits bun test src/lib: 64 pass 0 fail pre-existing typecheck error on provider-registry.ts:1247 is unrelated (present on dev before this branch, confirmed via git stash test) Co-authored-by: Florent Tapponnier --- answers/helius-vs-triton-vs-quicknode-solana.yml | 6 +++--- answers/polymarket-vs-kalshi-resolution-speed.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/answers/helius-vs-triton-vs-quicknode-solana.yml b/answers/helius-vs-triton-vs-quicknode-solana.yml index 5a65b0f8..00f3c17f 100644 --- a/answers/helius-vs-triton-vs-quicknode-solana.yml +++ b/answers/helius-vs-triton-vs-quicknode-solana.yml @@ -1,7 +1,7 @@ slug: helius-vs-triton-vs-quicknode-solana question: "Helius vs Triton vs QuickNode, which lands the most Solana transactions?" short_answer: | - {{best_name}} currently lands the highest share of Solana transactions on the OpenChainBench active-probe harness at {{best_p50}} (24h), measured live from three regions by broadcasting identical transfers through each provider's mainnet RPC and confirming inclusion onchain. + OpenChainBench broadcasts identical Solana transfers through Helius, Triton and QuickNode every measurement cycle from three regions and confirms inclusion onchain, so the landed-rate head-to-head is measured live rather than self-reported. Jito bundle submission is also on the leaderboard as a separate priority-lane comparator. Full 24h landed-rate ranking for the three named providers is at openchainbench.com/benchmarks/solana-tx-landing. benchmark: solana-tx-landing @@ -19,7 +19,7 @@ limitations: faq: - q: "Which Solana RPC lands the most transactions right now, Helius, Triton or QuickNode?" - a: "{{best_name}} leads the OpenChainBench active-probe leaderboard at {{best_p50}} (24h) on real broadcasts confirmed onchain. The full ranking including Helius, Triton, QuickNode, Alchemy and public RPCs re-sorts every measurement cycle against fresh Prometheus samples." + a: "The live 24h ranking for the three named providers is on openchainbench.com/benchmarks/solana-tx-landing. Each cycle the harness broadcasts an identical SOL transfer through Helius, Triton and QuickNode from three regions and polls the signature onchain until it is confirmed or crosses the landing deadline, so the ranking is reproducible from public data rather than derived from provider self-reports. Jito bundle submission is included on the same leaderboard as a separate priority-lane comparator, which is why an aggregate leader may name Jito rather than one of the three RPC providers in the question." - q: "Why measure landing rate instead of just latency?" a: "On Solana under congestion, the failure mode that matters to users is dropped transactions, not slow responses. A provider can return a fast success ack from sendTransaction and never actually get the tx into a block. OpenChainBench confirms inclusion onchain by polling for the signature, so the landed rate reflects what a user actually experiences, not what the provider self-reports." - q: "Is Helius Sender factored into this ranking?" @@ -37,5 +37,5 @@ related: - which-crypto-price-api-is-the-fastest seo_title: "Helius vs Triton vs QuickNode Solana landing rate 2026" -seo_description: "{{best_name}} leads at {{best_p50}} (24h) on the OpenChainBench active-probe Solana landing leaderboard. Helius, Triton, QuickNode ranked live by real onchain confirmation." +seo_description: "Helius, Triton and QuickNode Solana landed-rate head-to-head measured live onchain from three regions. Live 24h ranking at openchainbench.com/benchmarks/solana-tx-landing." status: live diff --git a/answers/polymarket-vs-kalshi-resolution-speed.yml b/answers/polymarket-vs-kalshi-resolution-speed.yml index d7d51bca..6938ed24 100644 --- a/answers/polymarket-vs-kalshi-resolution-speed.yml +++ b/answers/polymarket-vs-kalshi-resolution-speed.yml @@ -1,7 +1,7 @@ slug: polymarket-vs-kalshi-resolution-speed question: "Polymarket vs Kalshi, which resolves prediction markets faster?" short_answer: | - {{best_name}} currently resolves faster on the OpenChainBench harness at {{best_p50}} median resolution delay (24h), measured directly onchain for Polymarket and via official settlement events for Kalshi across sports, politics and crypto market categories. + OpenChainBench measures Polymarket resolution delay directly onchain on Polygon: median across sports, politics and crypto categories is in the tens of minutes today, with crypto markets fastest and politics slowest by a wide margin. Kalshi settlement is offchain through a CFTC-registered exchange and is not yet in the harness; the venue-versus-venue head-to-head on this page will populate as soon as Kalshi settlement events are ingested. Live numbers per category at openchainbench.com/benchmarks/polymarket-resolution-delay. benchmark: polymarket-resolution-delay @@ -19,7 +19,7 @@ limitations: faq: - q: "Polymarket vs Kalshi, which resolves faster right now?" - a: "{{best_name}} leads at {{best_p50}} median resolution delay (24h). The per-category breakdown at the top of the bench page shows the venue that wins on sports, politics and crypto separately, which is the honest way to answer a question that depends heavily on category." + a: "Only Polymarket is measured live in this benchmark today. Polymarket resolution delay is read directly from UMA OptimisticOracleV2 events on Polygon and broken out by market category (sports, politics, crypto). Kalshi settles offchain through its own CFTC-registered exchange and its settlement events are not yet ingested by the harness; the venue-versus-venue head-to-head cell on this page will populate as soon as they are. In the meantime, the per-category delays for Polymarket are on the bench page." - q: "Why is Polymarket resolution slower on politics than sports?" a: "Polymarket runs on UMA's optimistic oracle. The liveness window (time between outcome proposal and dispute deadline) is proportional to stakes, and politics markets have both larger stakes and higher dispute probability than clean sports outcomes. The result is a category-specific delay that sports mostly avoid." - q: "Is Kalshi always faster than Polymarket?" @@ -37,5 +37,5 @@ related: - which-hyperliquid-frontend-has-the-most-builder-revenue seo_title: "Polymarket vs Kalshi resolution speed 2026 live" -seo_description: "{{best_name}} leads at {{best_p50}} median resolution delay (24h) on the OpenChainBench prediction market resolution benchmark. Polymarket and Kalshi ranked live across sports, politics, crypto." +seo_description: "OpenChainBench measures Polymarket resolution delay live onchain across sports, politics and crypto markets. Kalshi ingestion pending. Full per-category numbers at openchainbench.com." status: live