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
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..feb98795 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,15 @@ 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 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 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 +57,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 +83,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 +95,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 +159,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 +179,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/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/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 {
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/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
diff --git a/public/logos/48club.png b/public/logos/48club.png
new file mode 100644
index 00000000..65085328
Binary files /dev/null and b/public/logos/48club.png differ
diff --git a/public/logos/blocksec.jpg b/public/logos/blocksec.jpg
new file mode 100644
index 00000000..4b10be5e
Binary files /dev/null and b/public/logos/blocksec.jpg differ
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 @@
-
-
-
- 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;
diff --git a/src/app/api/series/[slug]/route.ts b/src/app/api/series/[slug]/route.ts
index 3000ee7b..47c52114 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,
- ): Promise | null> => {
- const sig = filterSig({ chain, region });
+ kind: string | undefined,
+ venue: string | undefined,
+ ): Promise | null> => {
+ const sig = filterSig({ chain, region, kind, venue });
const stored = await readMaterialized(slug, sig);
if (stored) {
const fromBlob =
@@ -42,10 +46,13 @@ 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"],
+ // 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"] },
);
@@ -103,11 +110,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
@@ -115,24 +154,25 @@ 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([
- 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(
@@ -142,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/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/export-video-section.tsx b/src/components/export-video-section.tsx
index af926fc5..51c6d6c0 100644
--- a/src/components/export-video-section.tsx
+++ b/src/components/export-video-section.tsx
@@ -2,10 +2,12 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
-import { Video, X, Loader2, Download, Copy, Share2 } from "lucide-react";
+import { Video, X, Check, Loader2, Download, Copy, Share2 } from "lucide-react";
import type { Benchmark } from "@/types/benchmark";
import { EXPORT_VIDEO_ENABLED } from "@/lib/export-video/config";
import { fetchBenchSeries } from "@/lib/export-video/fetch-series";
+import { ProviderLogo } from "@/components/provider-logo";
+import { fmtUnit } from "@/lib/format";
import {
RANGE_IDS,
RANGE_LABEL,
@@ -16,6 +18,26 @@ 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>;
+
+/** 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;
@@ -88,7 +110,6 @@ function ExportVideoModal({ slug, title, benchmark }: Props) {
function ModalBody({
slug,
- title,
benchmark,
onClose,
}: Props & { onClose: () => void }) {
@@ -107,45 +128,162 @@ 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);
- // Sort the provider list by p50 so the leader sits at the top of the
- // multi-select (same order share-section.tsx uses).
+ // 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 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 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
+ // 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) return; // the aggregate prop already covers "all"
+ const qs = new URLSearchParams();
+ for (const { dim, value } of activeDims) qs.set(dim, value);
+ let cancelled = false;
+ fetch(`/api/bench/${encodeURIComponent(slug)}/variant?${qs.toString()}`)
+ .then((r) => (r.ok ? (r.json() as Promise) : null))
+ .then((v) => {
+ if (!cancelled) setVariantState({ key: dimKey, bench: v ?? null });
+ })
+ .catch(() => {
+ if (!cancelled) setVariantState({ key: dimKey, bench: null });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [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;
+
+ // 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]);
+
+ // 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
+ // 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(
() =>
- [...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],
+ rankedResults.map((r) => ({
+ slug: r.slug,
+ name: r.name,
+ live: hasData(r),
+ })),
+ [rankedResults],
);
+ const liveCount = providers.filter((p) => p.live).length;
- const toggleProvider = (s: string) =>
- setSelected((prev) => {
- const next = new Set(prev);
- if (next.has(s)) next.delete(s);
- else next.add(s);
- return next;
+ // Preview rows: video rank (position among the selected live rows,
+ // which IS the order the race renders), live flag, inclusion flag.
+ const previewRows = useMemo(() => {
+ let rank = 0;
+ return rankedResults.map((r) => {
+ const live = hasData(r);
+ const on = live && selected.has(r.slug);
+ return { r, live, on, rank: on ? ++rank : null };
});
-
- // 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.
- const searchParams = useSearchParams();
- const chain = searchParams.get("chain");
- const region = searchParams.get("region");
+ }, [rankedResults, selected]);
const onRender = async () => {
if (selected.size === 0) {
@@ -154,9 +292,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) {
@@ -208,10 +357,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 (
- Export video · {benchmark.title}
+ Export video · {videoTitle}
+ {/* 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) => (
+
- {/* 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. */}