diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml index 528eec40..1941f041 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -13,7 +13,11 @@ concurrency: jobs: deploy: runs-on: ubuntu-latest - timeout-minutes: 15 + # 30 min: the build prerenders every bench/product/OG route against + # Prom through a query concurrency cap, and the per-page budget is + # 240s (next.config.ts). 15 min started killing builds once the HL + # bench grew past 60 providers. + timeout-minutes: 30 env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} diff --git a/benchmarks/hyperliquid-hip3-deployers.yml b/benchmarks/hyperliquid-hip3-deployers.yml new file mode 100644 index 00000000..e8c8f2eb --- /dev/null +++ b/benchmarks/hyperliquid-hip3-deployers.yml @@ -0,0 +1,284 @@ +# OpenChainBench. Bench № 035 + +slug: hyperliquid-hip3-deployers +number: "035" +title: Hyperliquid HIP-3 deployer revenue leaderboard +seo_title: "Hyperliquid HIP-3 dexes 2026: deployer fee revenue USD live (trade.xyz, Ventuals and 5 others) over 24h, 7d, 30d" +seo_description: "Live USD revenue collected by every HIP-3 builder-deployed dex on Hyperliquid via the on chain deployerFee field. trade.xyz, Ventuals and 5 other deployers ranked by 24h, 7d and 30d fees, with volume, unique traders and market counts. Data source is a local hl node tailing the mainnet fill stream." +subtitle: How much USD deployer fee revenue each HIP-3 builder-deployed dex collected over the rolling 24h, 7 day and 30 day windows. Data from a local hl node tailing every fill on mainnet. +category: Trading +status: live +metric: Deployer fees collected (USD) +unit: usd +higher_is_better: true + +disclaimer: | + Ranking is by raw deployer fee revenue in USD, not by value for traders. Bigger number means more fees collected via the on chain deployerFee field on the dex's namespaced markets. For the trader cost perspective, switch to the Effective fee bps companion tab. Core Hyperliquid markets (no namespace) carry no deployer fee and are excluded. Some dex namespaces are not yet matched to a public brand; they are listed under their on chain namespace until identified. + +seo_intro: | + This page answers one question. Which HIP-3 builder-deployed dex on + Hyperliquid collected the most deployer fee revenue in USD over the + last 24 hours, 7 days and 30 days. HIP-3 lets any team that stakes + 500,000 HYPE deploy its own perpetual markets on HyperCore under a + dedicated namespace (xyz:AAPL, vntl:MAG7, km:US500), set the fee + policy for those markets, and collect a deployer cut on every fill. + The mechanism powers the tokenized stock, index and commodity perps + wave on Hyperliquid: trade.xyz alone routinely clears several billion + dollars of daily notional across more than 70 equity and commodity + markets. The bench ranks every namespace observed on mainnet by the + dollar amount its deployer collected through the on chain deployerFee + field, alongside routed volume, unique trader counts and the number + of live markets. Data comes from a local hl node operated on OCB + infrastructure tailing the Hyperliquid mainnet fill stream; a Go + harness aggregates per dex over rolling windows and exposes + Prometheus gauges that this page consumes. End to end staleness from + fill landing on chain to page render is typically under one minute. + +abstract: | + The bench ranks HIP-3 builder-deployed dexes by the USD value of + deployer fees they collected over rolling 24 hour, 7 day and 30 day + windows. Source data is a local hl node operated on OCB + infrastructure that writes every block of fills to disk as one JSON + line. A Go harness tails these files, attributes each fill carrying + a namespaced coin (xyz:AAPL belongs to the xyz dex) and a deployerFee + value, and increments per dex hourly buckets keyed by the UTC hour + floor of the fill timestamp. At publish time the harness sums the + recent 24, 168 and 720 hourly buckets and exposes the totals as + Prometheus gauges (hl_hip3_deployer_fees_usd_24h, _7d, _30d), plus + routed volume, unique trader counts from per day wallet sets, live + market counts and an effective fee rate in basis points. The dex set + is discovered dynamically from the fill stream, no registry needed, + because HIP-3 namespaces are unique on chain by construction. The + bench does not place trades, does not touch private keys, and does + not depend on any internal Mobula service. + +methodology: + - "Data source. A local hl node operated on OCB infrastructure tails the Hyperliquid mainnet and writes every block of fills to node_fills_by_block hourly YYYYMMDD HH on local disk. Each line is one JSON block, the events array carries one entry per fill, and every fill on a HIP-3 market includes the dex namespaced coin (xyz:AAPL) plus the deployerFee in USDC." + - "Attribution. A fill belongs to a HIP-3 dex when its coin carries a namespace prefix (xyz:AAPL belongs to xyz). Namespaces are unique on chain by construction, so no hand curated registry is needed; a new deployer appears on the leaderboard with its first observed fill. Core Hyperliquid markets carry no namespace and no deployer fee, they are excluded." + - "Cadence. The Go harness re reads the current and previous hourly files every 30 seconds, parses appended lines, and updates per dex hourly buckets keyed by the UTC hour floor of the fill timestamp. Prometheus scrapes the metrics every 30 seconds via a Caddy reverse proxy with basic auth. End to end staleness from fill landing on chain to bench page render is typically under one minute." + - "Headline calculation. For each dex over the last 24 rolling hours we sum the deployerFee USD values of every fill on its namespaced markets. The 7 day and 30 day figures sum the same field over the last 168 and 720 hourly buckets, so the metric is consistent across the three ranges." + - "Volume companion. hl_hip3_deployer_volume_usd_24h, _7d and _30d are computed the same way on the px times sz product of every fill. They feed the Volume column and tab." + - "Users. Unique wallet addresses with at least one fill on the dex. The 24h figure is a union of per hour wallet sets over the rolling window; 7d and 30d are unions of per UTC day wallet sets. After a harness restart the day sets are reseeded from the on disk fill history before the 7d and 30d gauges publish, so they never lowball." + - "Markets. Count of distinct namespaced coins traded on the dex in the last 24 hours. A dex with 70 active equity markets and a dex with one commodity market are different products; the column makes that visible next to the revenue number." + - "Effective fee bps. Deployer fees divided by notional volume, times ten thousand, over the rolling 24h window. The trader perspective on what a representative dollar of flow paid the dex operator." + - "Window mechanics. Hourly buckets per dex go back 30 days in memory and are pruned on every publish. Per fill state is never retained; the largest dex clears about 4 million fills per day, so the bench aggregates at ingestion time instead of keeping fill level granularity." + - "30 day caveat. The node retains hourly fill files for a bounded horizon and the harness backfill reads what exists on disk. Until 30 full days of history accumulate, the 30d figures cover the available horizon and grow toward the full window." + - "Naming. xyz is trade.xyz and vntl is Ventuals, both publicly documented. Other namespaces (cash, km, hyna, flx, para) are listed under their on chain prefix until the operating team is identified; the attribution and the numbers are exact either way." + - "Reproducibility. Harness source is open in the OCB harness tree. Anyone running their own hl node can clone, point the data flag at their node_fills_by_block hourly root, run the binary against a Prometheus scraper, and reproduce these metrics." + - "Failures. Any read error on the hourly files leaves the previous gauge values in place. Hourly bucket pruning is idempotent. Restarts replay the warmup window from disk before going live tail, and reseed the user sets from history in the background." + +findings: + - "{{best_name}} leads the leaderboard at {{best_p50}} in deployer fees collected over the last 24 hours." + - "{{name:xyz}} collects {{p50:xyz}} on the 24h view. Tokenized equities and commodities under the xyz namespace dominate HIP-3 flow, with more than 70 live markets." + - "{{name:vntl}} returns {{p50:vntl}} on the 24h view. Pre IPO valuation perps are a smaller but structurally distinct niche, traders price company valuations rather than listed stock." + - "Deployer revenue concentrates hard. The top namespace typically collects more than the rest of the field combined, mirroring the builder code market where consumer wallets dominate." + - "USD figures move with both volume and the dex's fee policy. A deployer can climb the leaderboard by listing markets that attract more notional or by widening its fee share, the Effective fee bps tab separates the two." + - "The 7 day and 30 day ranges smooth out listing events and promo spikes. Use those for ranking stability, the 24h view for current activity." + +faq: + - q: "What does this benchmark measure?" + a: "The USD value of deployer fees each HIP-3 builder-deployed dex collected over rolling 24 hour, 7 day and 30 day windows. HIP-3 lets a team that stakes 500,000 HYPE deploy its own perpetual markets on Hyperliquid under a dedicated namespace and collect a fee cut on every fill. The bench sums every fill's deployerFee value per namespace and publishes the total per timeframe." + - q: "What is HIP-3?" + a: "Hyperliquid Improvement Proposal 3, builder-deployed perpetuals. It opens HyperCore market deployment to outside teams: stake 500,000 HYPE, deploy markets under your namespace, set the fee policy, collect the deployer cut. It powers the tokenized stock, index and commodity perps on Hyperliquid, which represent a large share of platform volume." + - q: "Where does the data come from?" + a: "A local hl node operated on OCB infrastructure tails the Hyperliquid mainnet. The node writes every block of fills to disk; a Go harness running on the same host reads these files continuously, attributes namespaced fills to their dex, and aggregates per hour. No third party API, no internal Mobula service." + - q: "How are dexes identified?" + a: "By their on chain coin namespace. Every HIP-3 market trades under a prefix (xyz:AAPL, vntl:MAG7, km:US500) that is unique to its deployer. The set is discovered dynamically from the fill stream, so a brand new deployer appears on the leaderboard with its first fill. Namespaces not yet matched to a public brand are listed under the raw prefix." + - q: "Why is trade.xyz so far ahead?" + a: "It operates the deepest tokenized equity and commodity catalog on Hyperliquid, more than 70 markets including the large cap US names, and captures the bulk of HIP-3 open interest. Deployer revenue is volume times fee policy, and xyz leads on both breadth and notional." + - q: "What does the Effective fee bps column tell me?" + a: "Deployer fees divided by notional volume, in basis points. It is the trader perspective: what a representative dollar of flow paid the dex operator. Two dexes with the same revenue can have very different fee rates if one routes ten times the volume." + - q: "Why do 30 day figures look low for some dexes?" + a: "The node keeps hourly fill files for a bounded horizon and the harness backfills what exists on disk. Until 30 full days of history accumulate behind a dex, its 30d figure covers the available horizon and grows toward the full window. The 24h and 7d views are complete." + - q: "How often does the page refresh?" + a: "Every 30 seconds at the harness and scrape level. The page itself uses incremental static regeneration with a 60 second window, so headline values are at most 90 seconds stale plus chain propagation delay." + - q: "Can I cite a value from this page?" + a: "Yes. Every number is a Prometheus query exposed via the OCB API endpoints. The harness source is open at the link in the source field below. Cite the value and the timestamp at the top of the page." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/hyperliquid-frontends + +prometheus: + window: 24h + expected_freshness_seconds: 5400 + +providers: + - slug: xyz + name: trade.xyz + tag: Tokenized US equities, indices and commodities, 70+ markets + formula: "USD deployer fees collected over the rolling 24h window, summed across every fill on xyz namespaced markets in the local hl-node stream." + queries: + p50: hl_hip3_deployer_fees_usd_24h{dex="xyz"} + p90: hl_hip3_deployer_volume_usd_24h{dex="xyz"} + p99: hl_hip3_deployer_effective_fee_bps{dex="xyz"} + mean: avg_over_time(hl_hip3_deployer_fees_usd_24h{dex="xyz"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_hip3_deployer_fills_24h{dex="xyz"} + series: hl_hip3_deployer_fees_usd_24h{dex="xyz"} + + - slug: cash + name: cash + tag: HIP-3 namespace, equities and ETFs, full name pending identification + formula: "USD deployer fees collected over the rolling 24h window, summed across every fill on cash namespaced markets in the local hl-node stream." + queries: + p50: hl_hip3_deployer_fees_usd_24h{dex="cash"} + p90: hl_hip3_deployer_volume_usd_24h{dex="cash"} + p99: hl_hip3_deployer_effective_fee_bps{dex="cash"} + mean: avg_over_time(hl_hip3_deployer_fees_usd_24h{dex="cash"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_hip3_deployer_fills_24h{dex="cash"} + series: hl_hip3_deployer_fees_usd_24h{dex="cash"} + + - slug: km + name: km + tag: HIP-3 namespace, equities and index baskets, full name pending identification + formula: "USD deployer fees collected over the rolling 24h window, summed across every fill on km namespaced markets in the local hl-node stream." + queries: + p50: hl_hip3_deployer_fees_usd_24h{dex="km"} + p90: hl_hip3_deployer_volume_usd_24h{dex="km"} + p99: hl_hip3_deployer_effective_fee_bps{dex="km"} + mean: avg_over_time(hl_hip3_deployer_fees_usd_24h{dex="km"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_hip3_deployer_fills_24h{dex="km"} + series: hl_hip3_deployer_fees_usd_24h{dex="km"} + + - slug: vntl + name: Ventuals + tag: Pre IPO valuation perps under the vntl namespace + formula: "USD deployer fees collected over the rolling 24h window, summed across every fill on vntl namespaced markets in the local hl-node stream." + queries: + p50: hl_hip3_deployer_fees_usd_24h{dex="vntl"} + p90: hl_hip3_deployer_volume_usd_24h{dex="vntl"} + p99: hl_hip3_deployer_effective_fee_bps{dex="vntl"} + mean: avg_over_time(hl_hip3_deployer_fees_usd_24h{dex="vntl"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_hip3_deployer_fills_24h{dex="vntl"} + series: hl_hip3_deployer_fees_usd_24h{dex="vntl"} + + - slug: hyna + name: hyna + tag: HIP-3 namespace, full name pending identification + formula: "USD deployer fees collected over the rolling 24h window, summed across every fill on hyna namespaced markets in the local hl-node stream." + queries: + p50: hl_hip3_deployer_fees_usd_24h{dex="hyna"} + p90: hl_hip3_deployer_volume_usd_24h{dex="hyna"} + p99: hl_hip3_deployer_effective_fee_bps{dex="hyna"} + mean: avg_over_time(hl_hip3_deployer_fees_usd_24h{dex="hyna"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_hip3_deployer_fills_24h{dex="hyna"} + series: hl_hip3_deployer_fees_usd_24h{dex="hyna"} + + - slug: flx + name: flx + tag: HIP-3 namespace, commodities, full name pending identification + formula: "USD deployer fees collected over the rolling 24h window, summed across every fill on flx namespaced markets in the local hl-node stream." + queries: + p50: hl_hip3_deployer_fees_usd_24h{dex="flx"} + p90: hl_hip3_deployer_volume_usd_24h{dex="flx"} + p99: hl_hip3_deployer_effective_fee_bps{dex="flx"} + mean: avg_over_time(hl_hip3_deployer_fees_usd_24h{dex="flx"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_hip3_deployer_fills_24h{dex="flx"} + series: hl_hip3_deployer_fees_usd_24h{dex="flx"} + + - slug: para + name: para + tag: HIP-3 namespace, full name pending identification + formula: "USD deployer fees collected over the rolling 24h window, summed across every fill on para namespaced markets in the local hl-node stream." + queries: + p50: hl_hip3_deployer_fees_usd_24h{dex="para"} + p90: hl_hip3_deployer_volume_usd_24h{dex="para"} + p99: hl_hip3_deployer_effective_fee_bps{dex="para"} + mean: avg_over_time(hl_hip3_deployer_fees_usd_24h{dex="para"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_hip3_deployer_fills_24h{dex="para"} + series: hl_hip3_deployer_fees_usd_24h{dex="para"} + +metric_panels: + - id: volume + label: Volume routed + metric: hl_hip3_deployer_volume_usd_24h + label_key: dex + unit: usd + higher_is_better: true + description: "Notional USD routed through this dex's markets in the last 24 hours." + - id: users + label: Users 24h + metric: hl_hip3_deployer_users_24h + label_key: dex + unit: count + higher_is_better: true + description: "Unique wallets that traded on this dex in the last 24 hours." + - id: markets + label: Markets 24h + metric: hl_hip3_deployer_markets_24h + label_key: dex + unit: count + higher_is_better: true + description: "Distinct namespaced markets traded on this dex in the last 24 hours." + - id: effective_fee + label: Effective fee bps + metric: hl_hip3_deployer_effective_fee_bps + label_key: dex + unit: bps + description: "Deployer fees divided by notional volume times 10000. The trader perspective on cost." + - id: outage + label: Time since last fill + metric: hl_hip3_deployer_last_fill_age_seconds + label_key: dex + unit: sec + description: "Seconds since this dex's most recent fill. An outage signal." + # Data-only panels (tab: false): they feed the ledger's 7d/30d + # timeframe toggle below. + - id: revenue_7d + label: Revenue 7d + metric: hl_hip3_deployer_fees_usd_7d + label_key: dex + unit: usd + higher_is_better: true + tab: false + - id: revenue_30d + label: Revenue 30d + metric: hl_hip3_deployer_fees_usd_30d + label_key: dex + unit: usd + higher_is_better: true + tab: false + - id: volume_7d + label: Volume 7d + metric: hl_hip3_deployer_volume_usd_7d + label_key: dex + unit: usd + higher_is_better: true + tab: false + - id: volume_30d + label: Volume 30d + metric: hl_hip3_deployer_volume_usd_30d + label_key: dex + unit: usd + higher_is_better: true + tab: false + - id: users_7d + label: Users 7d + metric: hl_hip3_deployer_users_7d + label_key: dex + unit: count + higher_is_better: true + tab: false + - id: users_30d + label: Users 30d + metric: hl_hip3_deployer_users_30d + label_key: dex + unit: count + higher_is_better: true + tab: false + +# Honest column labels: the p50/p90/p99 slots are repurposed (USD revenue, +# no percentile semantics). Revenue, Volume and Users follow the 24h/7d/30d +# timeframe toggle via the data-only panels above; Fee bps and Markets are +# 24h figures and their headers say so on every timeframe. +ledger_columns: + - { label: "Revenue", slot: p50, windows: { 7d: revenue_7d, 30d: revenue_30d } } + - { label: "Volume", slot: p90, windows: { 7d: volume_7d, 30d: volume_30d } } + - { label: "Users", panel: users, windows: { 7d: users_7d, 30d: users_30d } } + - { label: "Fee bps", slot: p99, unit: bps } + - { label: "Markets", panel: markets } diff --git a/benchmarks/l1-finality.yml b/benchmarks/l1-finality.yml index f3007ff0..b4ab1580 100644 --- a/benchmarks/l1-finality.yml +++ b/benchmarks/l1-finality.yml @@ -2,12 +2,12 @@ slug: l1-finality number: "006" -title: Fastest L1 blockchain finality, live across 10 chains +title: Fastest L1 blockchain finality, live across 11 chains seo_title: "Fastest L1 finality 2026: TON, SUI, Stellar, Solana, Ethereum" -seo_description: "Fastest L1 blockchain finality, measured live for 10 chains. TON 0.2 s, SUI 0.5 s, Stellar 4 s, Solana 13 s, Ethereum ~16 min. p50 over 24h, open methodology." +seo_description: "Fastest L1 blockchain finality, measured live for 11 chains. TON, SUI and Hedera in seconds, Solana ~13 s, Ethereum ~12.8 min. Live percentiles over 24h, open methodology." subtitle: Wall-clock seconds from latest block to the finalized block on Ethereum, Solana, TON, SUI, Stellar and 5 more chains, refreshed every 10 seconds. seo_intro: | - This page measures L1 finality time live for every major Layer-1 blockchain, with p50 / p90 / p99 refreshed every 10 seconds. Stellar finality time is ~5 seconds, the close interval the Stellar Consensus Protocol locks in via federated Byzantine agreement. Solana finality time goes from sub-second on the processed commitment to ~12.8 s on finalized after 32 confirmed slots. Ethereum finality time is ~12.8 minutes, the 2-epoch Casper FFG window. Hedera finality time clears in 3-5 seconds via Hashgraph aBFT. SUI finality time and TON finality time both sit under one second via Mysticeti DAG-BFT and BAG consensus. BNB and Avalanche finality time land near two seconds through fast-finality forks. Probabilistic chains (Litecoin, Monero, Cardano) settle on a confirmation-depth convention measured here in minutes. + This page measures L1 finality time live for every major Layer-1 blockchain, with p50 / p90 / p99 refreshed every 10 seconds. Stellar finality time is ~5 seconds, the close interval the Stellar Consensus Protocol locks in via federated Byzantine agreement. Solana finality time goes from sub-second on the processed commitment to ~12.8 s on finalized after 32 confirmed slots. Ethereum finality time is ~12.8 minutes, the 2-epoch Casper FFG window. Hedera finality time clears in 3-5 seconds via Hashgraph aBFT. SUI finality time and TON finality time both sit under one second via Mysticeti DAG-BFT and BAG consensus. BNB and Avalanche finality time land near two seconds through fast-finality forks. Probabilistic chains (Litecoin, Monero) settle on a confirmation-depth convention measured here in minutes. faq: - q: "What is blockchain finality?" @@ -17,7 +17,7 @@ faq: - q: "What is Ethereum finality time?" a: "Ethereum finality time is {{p50:ethereum}} (p50, 24h). That is 2 epochs of 32 slots of 12 seconds each, the window Casper FFG needs to justify and finalize a checkpoint. The benchmark measures it live by polling eth_getBlockByNumber(\"latest\") and eth_getBlockByNumber(\"finalized\") every 10 seconds and taking the timestamp delta." - q: "What is Solana finality time?" - a: "Solana exposes two commitments. Processed is optimistic and lands sub-second, typically under 500 ms. Finalized requires 32 confirmed slots and clocks {{p50:solana}} (p50, 24h). The leaderboard value is finalized, the stricter guarantee. Sub-second Solana finality is real but only at the processed commitment level, not finalized." + a: "Solana exposes two commitments. Processed is optimistic and lands sub-second, typically under 500 ms. Finalized requires 32 confirmed slots and clocks {{p50:solana}} (24h average). The leaderboard value is finalized, the stricter guarantee. Sub-second Solana finality is real but only at the processed commitment level, not finalized." - q: "What is SUI finality time?" a: "SUI clocks {{p50:sui}} (p50, 24h) on this benchmark via the Mysticeti DAG-BFT consensus protocol. The chain exposes millisecond-precision timestamps so the measurement is genuinely sub-second. Mysticeti's two-vote commitment pattern reaches deterministic finality without the multi-block confirmation depth used by classical Byzantine fault tolerant chains." - q: "What is TON finality time?" @@ -29,9 +29,9 @@ faq: - q: "What is BNB Chain finality time?" a: "BNB Smart Chain finalizes via the BEP-126 fast-finality fork, dropping the confirmation depth that legacy probabilistic chains require. p50 sits at {{p50:bnb}} (24h), measured via persistent WebSocket subscription that records T1 when block N first appears as latest and T2 when it crosses the finalized threshold." - q: "Which blockchain has the fastest finality time?" - a: "Sub-second BFT chains lead. TON and SUI both clock under one second on this live benchmark. BNB and Avalanche sit around one to two seconds via their fast-finality forks. Solana finalized lands around 12.8 s, Ethereum at 12.8 min, and probabilistic chains (Litecoin, Monero, Cardano) trail at 15 to 30 minutes by confirmation-depth convention." + a: "Sub-second BFT chains lead. TON and SUI both clock under one second on this live benchmark. BNB and Avalanche sit around one to two seconds via their fast-finality forks. Solana finalized lands around 12.8 s, Ethereum at 12.8 min, and probabilistic chains (Litecoin, Monero) trail at 15 to 30 minutes by confirmation-depth convention." - q: "How is L1 finality time measured on this page?" - a: "Two methods, picked per chain. RPC pollers compare latest vs finalized block timestamps every 10 seconds, used for Ethereum, Solana, TRON, Stellar, SUI, TON, Litecoin, Monero, Cardano. WebSocket subscribers record wall-clock time T1 when a block first appears on the head stream and T2 when it crosses the finality threshold, giving millisecond-precision lag for sub-poll chains (BNB, Avalanche)." + a: "Two methods, picked per chain. RPC pollers compare latest vs finalized block timestamps every 10 seconds, used for Ethereum, Solana, TRON, Stellar, SUI, TON, Litecoin, Monero. WebSocket subscribers record wall-clock time T1 when a block first appears on the head stream and T2 when it crosses the finality threshold, giving millisecond-precision lag for sub-poll chains (BNB, Avalanche)." per_chain_explainer: - slug: ethereum @@ -41,7 +41,7 @@ per_chain_explainer: - slug: solana h2: "Solana finality time" body: | - Solana exposes two commitments. Processed is optimistic and lands sub-second, typically under 500 ms. Finalized requires 32 confirmed slots and clocks {{p50:solana}} (p50, 24h) on this benchmark. The leaderboard value is finalized, the stricter guarantee. Sub-second Solana finality is real but only at the processed commitment level. Measured via `getSlot{commitment:"processed"}` and `getSlot{commitment:"finalized"}` translated through block timestamps. + Solana exposes two commitments. Processed is optimistic and lands sub-second, typically under 500 ms. Finalized requires 32 confirmed slots and clocks {{p50:solana}} (24h average) on this benchmark. The leaderboard value is finalized, the stricter guarantee. Sub-second Solana finality is real but only at the processed commitment level. Measured via `getSlot{commitment:"processed"}` and `getSlot{commitment:"finalized"}` translated through block timestamps. - slug: stellar h2: "Stellar finality time" body: | @@ -69,7 +69,7 @@ per_chain_explainer: - slug: tron h2: "TRON finality time" body: | - TRON's solidity confirmation hovers around {{p50:tron}} (p50, 24h), matching the documented 20-block delay at 3-second block intervals. The solidity flag in TRON consensus is the chain's equivalent of finalized: a block survives until 20 confirmations before being considered irreversible. Measured via `/wallet/getnowblock` (head) minus `/walletsolidity/getnowblock` (solidity-confirmed). + TRON's solidity confirmation hovers around {{p50:tron}} (24h average), matching the documented 20-block delay at 3-second block intervals. The solidity flag in TRON consensus is the chain's equivalent of finalized: a block survives until 20 confirmations before being considered irreversible. Measured via `/wallet/getnowblock` (head) minus `/walletsolidity/getnowblock` (solidity-confirmed). - slug: litecoin h2: "Litecoin finality time" body: | @@ -90,7 +90,7 @@ abstract: | finalized block. The harness uses two complementary methods. For chains with finality much longer than our 10 s poll interval we read `latest.timestamp, finalized.timestamp` from the chain RPCs (Ethereum, - Solana, TRON, Litecoin, Monero, Cardano). For chains whose finality + Solana, TRON, Litecoin, Monero). For chains whose finality is faster than the poll interval (BNB, Avalanche, SUI, TON, Stellar) we maintain a persistent WebSocket / SSE subscription, recording wall-clock time T1 when block N is first seen as `latest` and T2 @@ -114,7 +114,6 @@ methodology: - "TON: tonapi.io `/blockchain/masterchain-head` minus 3 seqno." - "Litecoin (probabilistic): blockchair `/stats.best_block_height` minus 6 confirmations via `/dashboards/block/{height}.block.time`." - "Monero (probabilistic): monero-rpc `get_info` minus 10 confirmations via `get_block_header_by_height`." - - "Cardano (probabilistic): koios `/tip` minus 15 confirmations via `/blocks?block_height=eq.`. 15 matches the depth top exchanges (Coinbase / Kraken) treat as final settlement; Ouroboros Praos full settlement is k = 2160 blocks (~12 h) and is not the practical convention." - "Hedera (planned, currently disabled): mirror-node `/api/v1/blocks` only exposes already-final blocks, so true wall-clock lag is impossible from public endpoints. Re-enables once Block Nodes (HIP-1056) leave private preview." - "Result: `lag_seconds = headTimestamp, finalizedTimestamp`. We also record the block-height delta and per-chain fetch latency." - "Excluded by design: XRP (no close-time on `ledger_current`, wall-clock measurement requires WS subscribe to ledger stream, not yet implemented)." @@ -122,9 +121,9 @@ methodology: findings: - "{{name:sui}} returns {{p50:sui}} (p50, 24 h), matching its Mysticeti DAG BFT design. The chain exposes ms timestamps so the measurement is genuinely sub-second." - - "{{name:ethereum}} sits at {{p50:ethereum}} (p50, 24 h), aligned with the 2-epoch finalization spec (32 slots × 12 s × 2)." + - "{{name:ethereum}} sits at {{p50:ethereum}} (24 h average), aligned with the 2-epoch finalization spec (32 slots × 12 s × 2)." - "{{name:tron}}'s solidity confirmation hovers around {{p50:tron}}, exactly the documented 20-block delay at 3 s blocks." - - "{{name:litecoin}} and {{name:monero}} sit at {{p50:litecoin}} and {{p50:monero}} (p50, 24 h) by construction, set by the chosen confirmation depth." + - "{{name:litecoin}} and {{name:monero}} sit at {{p50:litecoin}} and {{p50:monero}} (24 h average) by construction, set by the chosen confirmation depth." - "{{best_name}} currently leads the leaderboard at {{best_p50}} (p50, 24 h) across {{count}} measured L1s. {{worst_name}} trails at {{worst_p50}}." - "Hedera's Hashgraph aBFT is designed to finalize in 3-5 s but is excluded from the live leaderboard because the mirror node only exposes finalized blocks, wall-clock measurement requires the Block Node service (HIP-1056), still in private preview." @@ -190,7 +189,7 @@ providers: - slug: ton name: TON - tag: Masterchain commit, measured via SSE wall-clock + tag: Masterchain commit, polled via tonapi.io formula: "Median wall-clock milliseconds for a TON masterchain block to commit under BAG consensus, polled via tonapi.io with a 3-seqno lookback, p50 over 24h." queries: p50: quantile_over_time(0.50, l1_finality_wallclock_lag_milliseconds{chain="ton"}[24h]) diff --git a/benchmarks/metadata-coverage.yml b/benchmarks/metadata-coverage.yml index 06483702..3aeef004 100644 --- a/benchmarks/metadata-coverage.yml +++ b/benchmarks/metadata-coverage.yml @@ -46,7 +46,7 @@ abstract: | A value of 100% means every field was returned for every fresh token; a value of 50% means half the fields were missing on average. Sort order on - this page is ascending, so the lowest coverage shows first, which is the + this page is descending, so the highest coverage shows first, which is the inverse of latency benchmarks. Readers should compare the columns themselves; numerical p50 is the headline. diff --git a/benchmarks/network-coverage.yml b/benchmarks/network-coverage.yml index 284f4850..6c2a762c 100644 --- a/benchmarks/network-coverage.yml +++ b/benchmarks/network-coverage.yml @@ -4,7 +4,7 @@ slug: network-coverage number: "005" title: Crypto data API with most blockchains supported, live coverage ranking seo_title: "Crypto data API most chains 2026: CoinPaprika, GeckoTerminal" -seo_description: "Crypto data API with the most blockchains supported, ranked live. CoinPaprika 306, GeckoTerminal 264, CoinStats 149, Codex 120, Mobula 79. Audited every six hours." +seo_description: "Crypto data API with the most blockchains supported, ranked live. {{best_name}} leads at {{best_p50}} networks; GeckoTerminal, CoinStats, Codex, Covalent, Dune and Mobula compared. Audited every six hours." subtitle: Number of blockchains each major crypto data API officially supports, audited every six hours against each provider's public network endpoint. category: Aggregators status: live diff --git a/benchmarks/oracle-deviation.yml b/benchmarks/oracle-deviation.yml index 2fce93e0..4bf578e1 100644 --- a/benchmarks/oracle-deviation.yml +++ b/benchmarks/oracle-deviation.yml @@ -42,7 +42,7 @@ abstract: | We measure live oracle disagreement by polling four price oracles every 30 seconds for ten USD-quoted blue chips, then computing the full pairwise deviation matrix per pair. Deviation between two - sources is `|price_a, price_b| / ((price_a + price_b) / 2) * 100` + sources is `|price_a - price_b| / ((price_a + price_b) / 2) * 100` in percent; the bench surfaces it both per source-pair (`ocb_oracle_deviation_pct{source_a, source_b}`) and as the per-pair maximum across all source pairs (`ocb_oracle_max_deviation_pct`). @@ -64,12 +64,12 @@ methodology: - "Sources. Chainlink AggregatorV3 via `eth_call(latestRoundData())` on Ethereum mainnet (no API key, public RPC with fallback), Pyth Network via Hermes REST `/api/latest_price_feeds` batch endpoint (no key), Binance REST `/api/v3/ticker/price` on USDT-quoted symbols (treated as ≈ USD), Coinbase REST `/products/

/ticker` on USD-quoted products. All four are free, no-auth, public endpoints." - "Pairs (10). BTC, ETH, SOL, BNB, AVAX, LINK and POL are tracked on all 4 sources. XRP, ADA, DOGE are tracked on 3 sources (Pyth + Binance + Coinbase) because the Chainlink mainnet AggregatorV3 contracts for these USD feeds are deprecated and no longer updated by node operators, the provider tag flags it." - "Cadence. 30 second polling interval per (source, pair). 4 sources × 10 pairs × 2/minute = 80 requests/minute total, comfortably under every free-tier ceiling (Binance 1200/min, Coinbase 10/s public, Pyth ~30/s soft, public RPC courteous). HTTP timeout is 8 s per request to match the gas-estimation harness." - - "Deviation formula. For every unordered pair of sources `(a, b)` that both have a fresh sample for the same pair: `deviation_pct = |price_a, price_b| / ((price_a + price_b) / 2) * 100`. With 4 sources we compute 6 pairwise deviations per asset; with 3 sources we compute 3. Per-pair headline is the max across all source-pairs (`ocb_oracle_max_deviation_pct`)." + - "Deviation formula. For every unordered pair of sources `(a, b)` that both have a fresh sample for the same pair: `deviation_pct = |price_a - price_b| / ((price_a + price_b) / 2) * 100`. With 4 sources we compute 6 pairwise deviations per asset; with 3 sources we compute 3. Per-pair headline is the max across all source-pairs (`ocb_oracle_max_deviation_pct`)." - "Stale-price guard. A source's sample is considered stale and excluded from the deviation calculation if its last successful update is older than `2 * pollInterval` (60 s). Prevents a dead Coinbase ticker from registering as a 0% deviation against itself by leaving the price gauge fossilized." - "Chainlink round age. The on-chain `updatedAt` timestamp from `latestRoundData()` is published separately as `ocb_oracle_last_round_age_seconds{source=\"chainlink\", pair}`. Chainlink updates only on deviation (typically 0.25-0.5% for blue chips) or heartbeat (~1 h), so a 30-minute age on a quiet ETH minute is normal; a 2-hour age on SOL during a volatile minute is the actual signal." - "USDT ≈ USD assumption. Binance only quotes USDT pairs (BTCUSDT, etc.). We treat USDT as ≈ USD ± 10 bps drift, which is acceptable for a bench whose alert floor is ≥ 10 bps. A real USDT depeg would surface as Binance drifting from the other three sources for *every* pair simultaneously, exactly what we want this bench to flag, not hide." - "MATIC → POL migration. Polygon migrated MATIC → POL 1:1 on Sep 4 2024. The Chainlink mainnet feed contract is still named MATIC/USD but on-chain `description()` confirms it tracks the POL token; Pyth renamed the feed to POL/USD; Coinbase delisted MATIC-USD and only lists POL-USD; Binance kept MATICUSDT as a frozen historical pair AND lists POLUSDT. We point Binance at POLUSDT so all four sources track the same underlying asset. Bench label is kept as `pair=\"MATIC/USD\"` for query continuity." - - "Time-aligned deviation (canonical headline). For every pair of sources (a, b) we anchor on the more recent of their two SourceTSs (Chainlink's on-chain `updatedAt` for Chainlink, fetch time for the continuously-updating sources) and look up the older source's price in a 30-minute per-source rolling history at the anchor moment. The result is published as `ocb_oracle_deviation_at_oracle_ts_pct` and feeds the leaderboard headline `ocb_oracle_max_deviation_pct`. This eliminates the artifact where Chainlink's heartbeat lag inflates 'deviation' by the market's drift between its updates — a researcher grading oracle quality wants to compare Chainlink's price against the market at Chainlink's own updatedAt, not at the harness's fetch instant. The legacy fetch-time gauge (`ocb_oracle_deviation_pct`, aliased as `ocb_oracle_deviation_at_fetch_ts_pct`) is preserved unchanged for backward compatibility. Alignment misses (no history sample within ±10s of the anchor) are counted as `ocb_oracle_alignment_miss_total`. Methodology recommendation from Coinpaprika data team review; matches the convention published by Chaos Labs and Risk DAO oracle risk reports." + - "Time-aligned deviation (canonical headline). For every source pair we anchor on the more recent SourceTS (Chainlink's on-chain `updatedAt`, fetch time for continuous sources) and look up the older source's price in a 30-minute rolling history at that moment. Published as `ocb_oracle_deviation_at_oracle_ts_pct`, feeding `ocb_oracle_max_deviation_pct`. Removes Chainlink heartbeat-lag inflating deviation; alignment misses count as `ocb_oracle_alignment_miss_total`." - "Excluded by design. Redstone (push-pull, no continuous gauge to scrape without integration contract), Uniswap V3 TWAP (per-pool integration + derivation of same CEX prints), DIA (smaller footprint than the four kept), and aggregator-of-aggregators (CoinGecko, CMC, DefiLlama), re-aggregating already-aggregated data adds latency and hides per-source disagreement." findings: diff --git a/benchmarks/perp-fees.yml b/benchmarks/perp-fees.yml index 5bdf758e..3e4ccc57 100644 --- a/benchmarks/perp-fees.yml +++ b/benchmarks/perp-fees.yml @@ -4,7 +4,7 @@ slug: perp-fees number: "007" title: Cheapest perp DEX, live all-in fee on a $1000 ETH 10x long seo_title: "Cheapest perp DEX 2026: Lighter, Hyperliquid, dYdX, GMX ranked" -seo_description: "{{best_name}} leads cheapest perp DEX at {{best_p50}} all-in (p50, 24h). $1000 ETH 10x long. Lighter, Hyperliquid, dYdX v4, GMX v2, gains.trade ranked live." +seo_description: "{{best_name}} leads cheapest perp DEX at {{best_p50}} all-in (24h avg). $1000 ETH 10x long. Lighter, Hyperliquid, dYdX v4, GMX v2, gains.trade ranked live." subtitle: All-in cost in basis points to open a $1000 ETH long 10x position. Taker fee plus half-spread plus impact, measured live from public APIs across Lighter, Hyperliquid, dYdX, GMX and gains.trade. category: Trading status: live @@ -52,12 +52,12 @@ methodology: - "Failures (5xx, timeouts, rate limits) leave the previous gauge in place and increment a per-venue `fetch_errors_total` counter. The page falls back to the last successful sample." findings: - - "{{best_name}} currently leads the leaderboard at {{best_p50}} all-in (p50, 24 h) across {{count}} measured perp venues. The number includes taker fee plus the spread crossed at $1000 notional, not the rack-rate taker fee alone." - - "Lighter charges zero taker fees on ETH-PERP, confirmed live via their public API. {{name:lighter}} clocks {{p50:lighter}} all-in (p50, 24 h), so the headline figure is essentially the half-spread plus orderbook impact crossed at $1000." - - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} all-in (p50, 24 h) on a 4.5 bps base taker. The deep HyperBFT orderbook keeps the spread component compressed, so the all-in number tracks the rack rate closer than any other venue in the cohort." - - "{{name:dydx}} sits at {{p50:dydx}} all-in (p50, 24 h). The published tier-0 taker fee is 5 bps; the rest is the orderbook crossed at $1000, which on dYdX v4 is consistently the tightest non-zero-fee book outside Hyperliquid." - - "GMX position fee is variable: 4 bps on positive impact opens, 6 bps on negative impact. We report the negative branch (conservative upper bound). {{name:gmx}} clocks {{p50:gmx}} all-in (p50, 24 h)." - - "{{worst_name}} trails at {{worst_p50}} all-in (p50, 24 h). The leaderboard reorders intra-day with spread volatility, so a venue cheap on the rack rate can still trail when its book thins out at a $1000 buy." + - "{{best_name}} currently leads the leaderboard at {{best_p50}} all-in (24 h average) across {{count}} measured perp venues. The number includes taker fee plus the spread crossed at $1000 notional, not the rack-rate taker fee alone." + - "Lighter charges zero taker fees on ETH-PERP, confirmed live via their public API. {{name:lighter}} clocks {{p50:lighter}} all-in (24 h average), so the headline figure is essentially the half-spread plus orderbook impact crossed at $1000." + - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} all-in (24 h average) on a 4.5 bps base taker. The deep HyperBFT orderbook keeps the spread component compressed, so the all-in number tracks the rack rate closer than any other venue in the cohort." + - "{{name:dydx}} sits at {{p50:dydx}} all-in (24 h average). The published tier-0 taker fee is 5 bps; the rest is the orderbook crossed at $1000, which on dYdX v4 is consistently the tightest non-zero-fee book outside Hyperliquid." + - "GMX position fee is variable: 4 bps on positive impact opens, 6 bps on negative impact. We report the negative branch (conservative upper bound). {{name:gmx}} clocks {{p50:gmx}} all-in (24 h average)." + - "{{worst_name}} trails at {{worst_p50}} all-in (24 h average). The leaderboard reorders intra-day with spread volatility, so a venue cheap on the rack rate can still trail when its book thins out at a $1000 buy." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/perp-fees @@ -67,13 +67,13 @@ prometheus: faq: - q: "What is the cheapest perpetual futures DEX right now?" - a: "{{best_name}} currently leads the leaderboard at {{best_p50}} all-in (p50, 24 h) for opening a $1000 ETH long 10x position. The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the answer reflects the last 24 hours of live data from each venue's public API, not a frozen rack-rate table." + a: "{{best_name}} currently leads the leaderboard at {{best_p50}} all-in (24 h average) for opening a $1000 ETH long 10x position. The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the answer reflects the last 24 hours of live data from each venue's public API, not a frozen rack-rate table." - q: "Is Lighter really zero fees on perpetual futures?" a: "The taker fee on Lighter is 0 bps, confirmed live via `/orderBookDetails` rather than a marketing page. The all-in cost is not zero, however, because every market order still crosses the half-spread and eats price impact at notional. The Lighter line on this benchmark shows what that residual cost actually is at $1000, which is the right number to compare against a paid venue's all-in figure." - q: "How do Hyperliquid fees compare to Lighter or dYdX in practice?" a: "Hyperliquid quotes 4.5 bps taker but its HyperBFT orderbook is among the deepest of any decentralized venue, so the spread component stays compressed and the all-in figure usually clocks close to the rack rate. Lighter starts from 0 bps taker but pays for it in slightly wider spreads on smaller markets. dYdX v4 sits at 5 bps taker with a comparably tight book. The leaderboard above shows the resulting one-number comparison live." - q: "What is the actual cost of trading on Hyperliquid for a $1000 position?" - a: "{{name:hyperliquid}} is currently at {{p50:hyperliquid}} all-in (p50, 24 h), which means a $1000 ETH 10x long costs that fraction of $1000 to open. Funding is charged separately and is not part of this benchmark; we publish the per-hour funding rate as a side metric (`perp_fees_funding_rate_per_hour_bps`) so traders sizing a multi-hour hold can layer it on top." + a: "{{name:hyperliquid}} is currently at {{p50:hyperliquid}} all-in (24 h average), which means a $1000 ETH 10x long costs that fraction of $1000 to open. Funding is charged separately and is not part of this benchmark; we publish the per-hour funding rate as a side metric (`perp_fees_funding_rate_per_hour_bps`) so traders sizing a multi-hour hold can layer it on top." - q: "Why include spread and impact instead of just taker fee?" a: "Rack-rate taker fee is what every comparison article quotes; it is also what every trader stops being able to read off a marketing page the second they place a market order. Spread plus impact at notional is the rest of the bill. On a tight book the gap is small, on a thin book it can dominate a 5 bps taker. Bundling both into one number is the only way to make a fair across-venue ranking that survives contact with a $1000 trade." - q: "How does GMX v2 fee work and why does this benchmark show the higher number?" diff --git a/benchmarks/pm-data-freshness.yml b/benchmarks/pm-data-freshness.yml index 07d38d87..15a89842 100644 --- a/benchmarks/pm-data-freshness.yml +++ b/benchmarks/pm-data-freshness.yml @@ -69,7 +69,7 @@ faq: - q: "Are these numbers comparable to Kalshi or Limitless?" a: "Not directly. This benchmark measures Polymarket as the underlying venue, since it's the largest and the one most providers proxy. Kalshi and Limitless are separate exchanges with separate data feeds, and providers that cover them often have a different ingestion path. We may add a Kalshi specific tab in a later phase. For now, treat the leaderboard as 'how fresh is your Polymarket data feed'." - q: "How does OpenChainBench measure freshness?" - a: "Three WebSocket clients run in parallel inside the harness, all subscribed to the same basket of top volume Polymarket markets. Every minute we refresh the basket from `gamma-api.polymarket.com`. For each trade event, we compute a signature `(conditionId, price rounded to 3 decimals, size in micros, 5 second time bucket)` and record the wall clock receive time on each provider. The freshness delta is `recv_provider - recv_polymarket` for the same signature. We export the histogram to Prometheus, the leaderboard reads the 24h p50." + a: "Three WebSocket clients run in parallel inside the harness, all subscribed to the same basket of top volume Polymarket markets. Every 5 minutes we refresh the basket from `gamma-api.polymarket.com`. For each trade event, we compute a signature `(conditionId, price rounded to 3 decimals, size in micros, 5 second time bucket)` and record the wall clock receive time on each provider. The freshness delta is `recv_provider - recv_polymarket` for the same signature. We export the histogram to Prometheus, the leaderboard reads the 24h p50." source: https://github.com/MobulaFi/mobula-monorepo/tree/main/miniapps/pm-freshness-bench diff --git a/benchmarks/rpc-capabilities.yml b/benchmarks/rpc-capabilities.yml index d791c114..1457d3d3 100644 --- a/benchmarks/rpc-capabilities.yml +++ b/benchmarks/rpc-capabilities.yml @@ -12,6 +12,52 @@ metric: RPC latency unit: ms higher_is_better: false +# Per-chain landing pages (/benchmarks/rpc-capabilities/). Each +# entry must keep its claims chain-scoped: the leader placeholder +# resolves against bestPerChain, never the cross-chain aggregate, and +# the page itself surfaces per-region leaders when they diverge. +per_chain_explainer: + - slug: ethereum + h2: "Fastest free Ethereum RPC" + body: | + {{best_name:chain:ethereum}} currently leads the free, no-key Ethereum RPC field at {{best_p50:chain:ethereum}} (`eth_blockNumber` p50, 24h), measured against 9 providers, the largest cohort in this bench. Ethereum is also where the silent-failure analysis earns its keep: Cloudflare-eth answers HTTP 200 with a JSON-RPC error field on many methods, and Merkle is excluded after recurring Cloudflare lockouts. Probes run every 15 seconds from us-east, eu-west and Singapore. + - slug: base + h2: "Fastest free Base RPC" + body: | + Coinbase's own `mainnet.base.org` goes head-to-head with PublicNode, dRPC, Tenderly and Merkle on Base, an unusually clean comparison because the chain-official endpoint is operated by the same team that runs the sequencer. The current leader is {{best_name:chain:base}} at {{best_p50:chain:base}} (`eth_blockNumber` p50, 24h) across 6 providers, probed every 15 seconds from three regions with stale-head detection against the cross-provider tip. + - slug: bnb + h2: "Fastest free BNB Chain RPC" + body: | + Binance's `bsc-dataseed1` is the incumbent default on BNB Chain, but PublicNode, dRPC and Merkle have closed the latency gap from EU origins. The current leader is {{best_name:chain:bnb}} at {{best_p50:chain:bnb}} (`eth_blockNumber` p50, 24h) across 5 providers. Every endpoint is probed with the identical call every 15 seconds from us-east, eu-west and Singapore, so the ranking reflects sustained round-trip latency, not a one-off burst. + - slug: arbitrum + h2: "Fastest free Arbitrum RPC" + body: | + Arbitrum carries the second-largest cohort in this bench, 8 no-key providers, and is one of the few chains where Lava and MeowRPC compete alongside PublicNode and the Arbitrum Foundation's own endpoint. The current leader is {{best_name:chain:arbitrum}} at {{best_p50:chain:arbitrum}} (`eth_blockNumber` p50, 24h). Latency is sampled every 15 seconds from three regions; archive-depth checks flag endpoints that serve pruned state as non-archive. + - slug: optimism + h2: "Fastest free Optimism RPC" + body: | + Optimism's field pits the Optimism Foundation endpoint against 5 multi-chain gateways. The current leader is {{best_name:chain:optimism}} at {{best_p50:chain:optimism}} (`eth_blockNumber` p50, 24h) across 6 providers. As on every chain here, the harness classifies each response (`ok`, `http_err`, `jsonrpc_err`, `stale`, `timeout`) so an endpoint stuck on an old head is never ranked as fastest, and probes originate from us-east, eu-west and Singapore. + - slug: avalanche + h2: "Fastest free Avalanche RPC" + body: | + Avalanche's chain-official endpoint competes with 5 no-key multi-chain gateways for the C-Chain. The current leader is {{best_name:chain:avalanche}} at {{best_p50:chain:avalanche}} (`eth_blockNumber` p50, 24h) across 6 providers. The probe is the same single call every 15 seconds from three regions, with stale-head detection flagging any provider more than 20 blocks behind the cross-provider tip. + - slug: polygon + h2: "Fastest free Polygon RPC" + body: | + Polygon has no chain-official endpoint in this bench, so the comparison is purely between multi-chain no-key gateways. The current leader is {{best_name:chain:polygon}} at {{best_p50:chain:polygon}} (`eth_blockNumber` p50, 24h) across 5 providers. Each one answers the identical call every 15 seconds from us-east, eu-west and Singapore, and the result classification separates real latency from silent JSON-RPC failures behind an HTTP 200. + - slug: linea + h2: "Fastest free Linea RPC" + body: | + The no-key field thins out on Linea: 4 providers qualify, all multi-chain gateways. The current leader is {{best_name:chain:linea}} at {{best_p50:chain:linea}} (`eth_blockNumber` p50, 24h). Thinner competition makes the reliability columns matter more than raw speed, a fast endpoint with a high `stale` or `timeout` rate is a worse default than a slightly slower consistent one. Probes run every 15 seconds from three regions. + - slug: scroll + h2: "Fastest free Scroll RPC" + body: | + Scroll is one of the smallest cohorts in this bench, 4 no-key providers, all multi-chain gateways. The current leader is {{best_name:chain:scroll}} at {{best_p50:chain:scroll}} (`eth_blockNumber` p50, 24h). The harness runs the identical probe every 15 seconds from us-east, eu-west and Singapore, with stale-head detection against the cross-provider tip so a frozen endpoint cannot top the table. + - slug: mantle + h2: "Fastest free Mantle RPC" + body: | + Mantle rounds out the long tail with 4 qualifying no-key providers, all multi-chain gateways. The current leader is {{best_name:chain:mantle}} at {{best_p50:chain:mantle}} (`eth_blockNumber` p50, 24h). Like every chain in this bench the number is a sustained median, the same call every 15 seconds from three regions over a rolling 24 hours, not a marketing burst, and archive-depth support is audited separately. + seo_intro: | This benchmark answers the question every developer reaching for a free public RPC asks before pasting a URL into their dapp. which @@ -45,7 +91,7 @@ seo_intro: | abstract: | We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every major no-key Ethereum-compatible - public RPC endpoint, on Ethereum mainnet, Base and BNB Chain. The + public RPC endpoint, across 10 EVM chains. The harness emits three metric families from one binary because they share the same (provider × chain) client matrix and same 15 s scrape interval: (a) `rpc_latency_milliseconds` for the speed @@ -96,6 +142,7 @@ source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-cap prometheus: window: 24h + freshness_metric: rpc_latency_milliseconds # Chain selector. tabs at the top of the page. Server injects # `chain="X"` into every PromQL query for the active tab. The special @@ -124,6 +171,12 @@ dimensions: - { value: eu-west, label: EU-West } - { value: sgp, label: Singapore } +# One instant query feeding the exact per-cell (chain x region) rankings +# behind scoped badges and leadership claims. Cross-region averages hide +# region-restricted leaders (dRPC wins every chain from sgp only), so +# badge scoping reads these cells instead of the aggregate ranks. +rank_matrix_query: avg by (provider, chain, region) (ocb:rpc_latency_milliseconds:p50_24h) + # Real metrics emitted by the rpc-capabilities harness: # rpc_latency_milliseconds{provider, chain} gauge # rpc_latency_milliseconds_histogram{provider, chain} histogram @@ -143,22 +196,22 @@ providers: tag: Allnodes-operated, 70+ chains, archive on most formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key endpoint." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="publicnode"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="publicnode"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="publicnode"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode"}[24h])) - success: sum(rate(rpc_call_total{provider="publicnode", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="publicnode"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="publicnode"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="publicnode", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="publicnode", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="publicnode", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", region="sgp"}[1h]) - slug: drpc @@ -166,22 +219,22 @@ providers: tag: Decentralized RPC mesh, consensus-checked formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's decentralized mesh." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="drpc"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="drpc"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="drpc"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc"}[24h])) - success: sum(rate(rpc_call_total{provider="drpc", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="drpc"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="drpc"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc"}) / sum(ocb:rpc_call:rate_24h{provider="drpc"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="drpc", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="drpc", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="drpc", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="drpc", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="drpc", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="drpc", region="sgp"}[1h]) - slug: 1rpc @@ -189,22 +242,22 @@ providers: tag: Privacy-preserving gateway by Automata Network formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's Automata gateway." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="1rpc"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="1rpc"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="1rpc"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc"}[24h])) - success: sum(rate(rpc_call_total{provider="1rpc", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="1rpc"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="1rpc"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="1rpc"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="1rpc"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="1rpc"}) / sum(ocb:rpc_call:rate_24h{provider="1rpc"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="1rpc"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="1rpc", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="1rpc", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="1rpc", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", region="sgp"}[1h]) - slug: meowrpc @@ -212,22 +265,22 @@ providers: tag: Free public RPC, no registration formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to MeowRPC's no-key endpoint." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="meowrpc"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="meowrpc"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="meowrpc"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="meowrpc"}[24h])) - success: sum(rate(rpc_call_total{provider="meowrpc", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="meowrpc"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="meowrpc"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="meowrpc"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="meowrpc"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="meowrpc"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="meowrpc"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="meowrpc"}) / sum(ocb:rpc_call:rate_24h{provider="meowrpc"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="meowrpc"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="meowrpc"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="meowrpc", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="meowrpc", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="meowrpc", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="meowrpc", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="meowrpc", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="meowrpc", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="meowrpc", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="meowrpc", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="meowrpc", region="sgp"}[1h]) - slug: flashbots @@ -235,22 +288,22 @@ providers: tag: Private-mempool RPC, anti-sandwich formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to the Flashbots Protect read proxy." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="flashbots"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="flashbots"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="flashbots"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="flashbots"}[24h])) - success: sum(rate(rpc_call_total{provider="flashbots", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="flashbots"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="flashbots"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="flashbots"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="flashbots"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="flashbots"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="flashbots"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="flashbots"}) / sum(ocb:rpc_call:rate_24h{provider="flashbots"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="flashbots"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="flashbots"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="flashbots", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="flashbots", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="flashbots", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="flashbots", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="flashbots", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="flashbots", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="flashbots", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="flashbots", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="flashbots", region="sgp"}[1h]) - slug: cloudflare @@ -258,22 +311,22 @@ providers: tag: Permissioned-mode for many JSON-RPC methods formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to cloudflare-eth (check success column for jsonrpc_err share)." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="cloudflare"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="cloudflare"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="cloudflare"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="cloudflare"}[24h])) - success: sum(rate(rpc_call_total{provider="cloudflare", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="cloudflare"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="cloudflare"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cloudflare"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="cloudflare"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="cloudflare"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="cloudflare"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="cloudflare"}) / sum(ocb:rpc_call:rate_24h{provider="cloudflare"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="cloudflare"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="cloudflare"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="cloudflare", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cloudflare", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="cloudflare", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="cloudflare", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cloudflare", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="cloudflare", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="cloudflare", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cloudflare", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="cloudflare", region="sgp"}[1h]) - slug: base-official @@ -281,22 +334,22 @@ providers: tag: Coinbase-operated, Base mainnet RPC formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Coinbase's `mainnet.base.org` endpoint." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="base-official"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="base-official"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="base-official"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="base-official"}[24h])) - success: sum(rate(rpc_call_total{provider="base-official", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="base-official"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="base-official"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="base-official"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="base-official"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="base-official"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="base-official"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="base-official"}) / sum(ocb:rpc_call:rate_24h{provider="base-official"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="base-official"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="base-official"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="base-official", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="base-official", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="base-official", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="base-official", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="base-official", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="base-official", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="base-official", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="base-official", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="base-official", region="sgp"}[1h]) - slug: binance @@ -304,22 +357,22 @@ providers: tag: BNB Chain dataseed RPC, Binance-operated formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Binance's `bsc-dataseed1.binance.org` endpoint." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="binance"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="binance"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="binance"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="binance"}[24h])) - success: sum(rate(rpc_call_total{provider="binance", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="binance"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="binance"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="binance"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="binance"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="binance"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="binance"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="binance"}) / sum(ocb:rpc_call:rate_24h{provider="binance"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="binance"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="binance"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="binance", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="binance", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="binance", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="binance", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="binance", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="binance", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="binance", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="binance", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="binance", region="sgp"}[1h]) - slug: tenderly @@ -327,22 +380,22 @@ providers: tag: Multi-chain public gateway, 9 chains, no key formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to `gateway.tenderly.co/public/`." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="tenderly"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="tenderly"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="tenderly"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly"}[24h])) - success: sum(rate(rpc_call_total{provider="tenderly", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="tenderly"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="tenderly"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="tenderly", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="tenderly", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="tenderly", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", region="sgp"}[1h]) - slug: nodies @@ -350,22 +403,22 @@ providers: tag: POKT Network's decentralized public RPC successor, 7+ chains formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies' `*-pokt.nodies.app` endpoint." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="nodies"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="nodies"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="nodies"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="nodies"}[24h])) - success: sum(rate(rpc_call_total{provider="nodies", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="nodies"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="nodies"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nodies"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="nodies"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="nodies"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="nodies"}) / sum(ocb:rpc_call:rate_24h{provider="nodies"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="nodies"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="nodies"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="nodies", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="nodies", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="nodies", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="nodies", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="nodies", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="nodies", region="sgp"}[1h]) - slug: lava @@ -373,22 +426,22 @@ providers: tag: Decentralized permissionless RPC mesh (ETH + Arbitrum no-key) formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Lava Network's no-key mesh endpoint." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="lava"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="lava"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="lava"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="lava"}[24h])) - success: sum(rate(rpc_call_total{provider="lava", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="lava"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="lava"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="lava"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="lava"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="lava"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="lava"}) / sum(ocb:rpc_call:rate_24h{provider="lava"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="lava"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="lava"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="lava", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="lava", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="lava", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="lava", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="lava", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="lava", region="sgp"}[1h]) - slug: merkle @@ -396,22 +449,22 @@ providers: tag: Base + BSC public no-key gateway (Ethereum hit by Cloudflare 20-min lockout, excluded) formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Merkle's Base/BSC no-key gateway." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="merkle"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="merkle"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="merkle"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="merkle"}[24h])) - success: sum(rate(rpc_call_total{provider="merkle", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="merkle"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="merkle"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="merkle"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="merkle"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="merkle"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="merkle"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="merkle"}) / sum(ocb:rpc_call:rate_24h{provider="merkle"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="merkle"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="merkle"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="merkle", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="merkle", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="merkle", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="merkle", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="merkle", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="merkle", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="merkle", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="merkle", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="merkle", region="sgp"}[1h]) - slug: arbitrum-official @@ -419,22 +472,22 @@ providers: tag: Arbitrum Foundation public RPC, Arbitrum One only formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to the Arbitrum Foundation's `arb1.arbitrum.io/rpc`." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="arbitrum-official"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="arbitrum-official"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="arbitrum-official"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="arbitrum-official"}[24h])) - success: sum(rate(rpc_call_total{provider="arbitrum-official", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="arbitrum-official"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="arbitrum-official"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="arbitrum-official"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="arbitrum-official"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="arbitrum-official"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="arbitrum-official"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="arbitrum-official"}) / sum(ocb:rpc_call:rate_24h{provider="arbitrum-official"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="arbitrum-official"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="arbitrum-official"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="arbitrum-official", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="arbitrum-official", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="arbitrum-official", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="arbitrum-official", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="arbitrum-official", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="arbitrum-official", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="arbitrum-official", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="arbitrum-official", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="arbitrum-official", region="sgp"}[1h]) - slug: optimism-official @@ -442,22 +495,22 @@ providers: tag: Optimism Foundation public RPC, Optimism mainnet only formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to the Optimism Foundation's `mainnet.optimism.io`." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="optimism-official"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="optimism-official"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="optimism-official"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="optimism-official"}[24h])) - success: sum(rate(rpc_call_total{provider="optimism-official", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="optimism-official"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="optimism-official"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="optimism-official"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="optimism-official"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="optimism-official"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="optimism-official"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="optimism-official"}) / sum(ocb:rpc_call:rate_24h{provider="optimism-official"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="optimism-official"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="optimism-official"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="optimism-official", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="optimism-official", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="optimism-official", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="optimism-official", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="optimism-official", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="optimism-official", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="optimism-official", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="optimism-official", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="optimism-official", region="sgp"}[1h]) - slug: avalanche-official @@ -465,20 +518,20 @@ providers: tag: Ava Labs C-Chain public RPC, Avalanche C-Chain only formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Ava Labs' `api.avax.network/ext/bc/C/rpc`." queries: - p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="avalanche-official"}[24h])) - p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="avalanche-official"}[24h])) - p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="avalanche-official"}[24h])) - mean: avg(avg_over_time(rpc_latency_milliseconds{provider="avalanche-official"}[24h])) - success: sum(rate(rpc_call_total{provider="avalanche-official", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="avalanche-official"}[24h])) - sample_size: sum(increase(rpc_call_total{provider="avalanche-official"}[24h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="avalanche-official"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="avalanche-official"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="avalanche-official"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="avalanche-official"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="avalanche-official"}) / sum(ocb:rpc_call:rate_24h{provider="avalanche-official"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="avalanche-official"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="avalanche-official"}[1h])) regions: - region: us-east - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="avalanche-official", region="us-east"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="avalanche-official", region="us-east"}) series: avg_over_time(rpc_latency_milliseconds{provider="avalanche-official", region="us-east"}[1h]) - region: eu-west - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="avalanche-official", region="eu-west"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="avalanche-official", region="eu-west"}) series: avg_over_time(rpc_latency_milliseconds{provider="avalanche-official", region="eu-west"}[1h]) - region: ap-southeast - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="avalanche-official", region="sgp"}[24h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="avalanche-official", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="avalanche-official", region="sgp"}[1h]) diff --git a/benchmarks/stablecoin-peg-usdt-anchored.yml b/benchmarks/stablecoin-peg-usdt-anchored.yml index ffdcca6f..758de8af 100644 --- a/benchmarks/stablecoin-peg-usdt-anchored.yml +++ b/benchmarks/stablecoin-peg-usdt-anchored.yml @@ -59,18 +59,18 @@ abstract: | methodology: - "Source: Binance REST `/api/v3/ticker/bookTicker?symbol=` polled every 5 seconds per pair. No API key required. Mid-price = (bid + ask) / 2 in USDT." - "Pairs: USDCUSDT ($2.8B daily, reference anchor for USDT-vs-USD drift), FDUSDUSDT ($41M daily, FDUSD's primary book), USDEUSDT ($1.8M daily, the book that flashed to $0.65 on October 10 2025)." - - "Metric: `peg_deviation_usdt_anchored_bps{stable, venue=\"binance\"}` = |mid_price, 1.0000| × 10000. 1 bp = 0.01% = 0.0001 USDT off pair-reference." + - "Metric: `peg_deviation_usdt_anchored_bps{stable, venue=\"binance\"}` = |mid_price - 1.0000| × 10000. 1 bp = 0.01% = 0.0001 USDT off pair-reference." - "Aggregation: p50 / p90 / p99 over 24 hours via Prometheus `quantile_over_time`. p99 is the headline because swap cost during stress is what matters for inventory rotation, not the calm-minute median." - - "USDC reference anchor: USDC/USDT spread is the floor below which no other pair can credibly score, because that gap reflects USDT's own drift away from USD reaching back into the pair. Read other stables' true vs-USDT drift as `their_p99, usdc_p99` to control for it." + - "USDC reference anchor: USDC/USDT spread is the floor below which no other pair can credibly score, because that gap reflects USDT's own drift away from USD reaching back into the pair. Read other stables' true vs-USDT drift as `their_p99 - usdc_p99` to control for it." - "Depeg event flag (shared with bench № 014): binary `peg_depeg_event_flag{stable}` fires when the per-minute aggregated price is outside [0.97, 1.03] for ≥5 consecutive minutes. The flag clears after 30 minutes back inside the band. Conservative so it does not flap during normal stress." - "Out of scope: this is a swap-cost / pair-spread bench, NOT a peg-robustness bench. Reserve attestations, basis-trade health, redemption windows, on-chain pool liquidity and other fundamental peg signals live on `stablecoin-peg` (bench № 014) or off-chain in each issuer's transparency dashboard." findings: - - "{{best_name}} currently has the tightest pair on Binance at {{best_p50}} (p99 spread vs 1.0000 USDT, 24 h) across {{count}} measured stables. That is the worst 1% of per-minute mid-price distance from the pair reference, in basis points." - - "{{name:usdc}} sits at {{p50:usdc}} (p99 spread vs USDT, 24 h). USDC is the reference anchor. its spread is mostly USDT's own drift away from USD reaching back, so subtract this number from each other stable's p99 to read its true vs-USDT drift. A stable scoring below USDC is in fact tracking USDT more tightly than USDC is." - - "{{name:fdusd}} clocks {{p50:fdusd}} (p99 spread vs USDT, 24 h) on Binance FDUSDUSDT. FDUSD's depth lives almost entirely here ($41M daily) so the order-book number is the only live signal that matters for inventory rotation. First Digital's USD reserves are audited monthly but Hong Kong-clearing is slower than Circle's same-day, which surfaces here as wider tails during risk-off minutes." - - "{{name:usde}} returns {{p50:usde}} (p99 spread vs USDT, 24 h) on Binance USDEUSDT. Ethena's USDe is anchored by a delta-neutral perp basis trade, not USD reserves, which makes its tail behaviour fundamentally different. The October 10 2025 flash to $0.65 on this exact book is the cautionary precedent. a tight calm-minute spread does not imply a robust peg under perp-market stress." - - "{{worst_name}} trails at {{worst_p50}} (p99 spread vs USDT, 24 h). The gap between leader and laggard mostly reflects book depth and how each stable's redemption / arbitrage mechanism resists Binance order-book stress during high-volatility minutes." + - "{{best_name}} currently has the tightest pair on Binance at {{best_p50}} (median per-minute worst spread vs 1.0000 USDT, 24 h) across {{count}} measured stables. That is the worst 1% of per-minute mid-price distance from the pair reference, in basis points." + - "{{name:usdc}} sits at {{p99:usdc}} (p99 spread vs USDT, 24 h). USDC is the reference anchor. its spread is mostly USDT's own drift away from USD reaching back, so subtract this number from each other stable's p99 to read its true vs-USDT drift. A stable scoring below USDC is in fact tracking USDT more tightly than USDC is." + - "{{name:fdusd}} clocks {{p99:fdusd}} (p99 spread vs USDT, 24 h) on Binance FDUSDUSDT. FDUSD's depth lives almost entirely here ($41M daily) so the order-book number is the only live signal that matters for inventory rotation. First Digital's USD reserves are audited monthly but Hong Kong-clearing is slower than Circle's same-day, which surfaces here as wider tails during risk-off minutes." + - "{{name:usde}} returns {{p99:usde}} (p99 spread vs USDT, 24 h) on Binance USDEUSDT. Ethena's USDe is anchored by a delta-neutral perp basis trade, not USD reserves, which makes its tail behaviour fundamentally different. The October 10 2025 flash to $0.65 on this exact book is the cautionary precedent. a tight calm-minute spread does not imply a robust peg under perp-market stress." + - "{{worst_name}} trails at {{worst_p50}} (median per-minute worst spread vs USDT, 24 h). The gap between leader and laggard mostly reflects book depth and how each stable's redemption / arbitrage mechanism resists Binance order-book stress during high-volatility minutes." faq: - q: "What does this benchmark actually measure?" @@ -78,7 +78,7 @@ faq: - q: "Is this the same as 'which stablecoin is safest to hold'?" a: "No, and it would be misleading to read it that way. USDC is cash-backed and audited monthly. FDUSD is also cash-backed and audited monthly but Hong Kong-clearing. USDe is not USD-backed at all but anchored by a delta-neutral perp basis trade. These carry fundamentally different reserve risks that no order-book spread can capture. A tight calm-minute pair spread on Binance does not imply a robust peg under stress. for fundamental peg signals (reserve attestations, on-chain pool depth, basis-trade health) read the issuer's transparency dashboard, not this bench." - q: "What is USDC doing on a USDT-anchored bench?" - a: "USDC serves as a reference anchor. {{name:usdc}} currently shows {{p50:usdc}} (p99 spread vs USDT, 24 h) on Binance USDCUSDT, the most liquid stablecoin pair on the venue ($2.8B daily). USDC is the cleanest cash-backed stablecoin in the cohort, so any drift it shows here is mostly USDT's own drift away from USD reaching back into the pair. Subtract USDC's p99 from any other stable's p99 to get its true vs-USDT drift, controlling for USDT's own USD drift." + a: "USDC serves as a reference anchor. {{name:usdc}} currently shows {{p99:usdc}} (p99 spread vs USDT, 24 h) on Binance USDCUSDT, the most liquid stablecoin pair on the venue ($2.8B daily). USDC is the cleanest cash-backed stablecoin in the cohort, so any drift it shows here is mostly USDT's own drift away from USD reaching back into the pair. Subtract USDC's p99 from any other stable's p99 to get its true vs-USDT drift, controlling for USDT's own USD drift." - q: "Why FDUSD and USDe specifically?" a: "Because they have no other honest live signal. FDUSD's depth lives almost entirely on Binance FDUSDUSDT ($41M daily, no comparable USD-quoted venue). USDe's main spot book is Binance USDEUSDT ($1.8M daily, same situation). The USD-anchored bench `stablecoin-peg` (№ 014) covers USDC / USDT / DAI which have real USD-quoted venues (Kraken, Bitstamp, Curve). This companion bench is the only place where FDUSD and USDe can be measured on a live order book at all." - q: "Did USDe really flash to $0.65 on Binance?" diff --git a/benchmarks/stablecoin-peg.yml b/benchmarks/stablecoin-peg.yml index 89dd6e18..9b1a0cf1 100644 --- a/benchmarks/stablecoin-peg.yml +++ b/benchmarks/stablecoin-peg.yml @@ -38,7 +38,7 @@ abstract: | We measure live stablecoin peg deviation by pulling mid-price ticks from every venue that exposes a USD-quoted pair for a given stable, then aggregating per-minute into a liquidity-weighted - median. Deviation is `|aggregated_price, 1.00|` in basis points, + median. Deviation is `|aggregated_price - 1.00|` in basis points, recorded as both a gauge (latest) and a histogram (per-minute samples). p99 over 24 h via `quantile_over_time` is the headline leaderboard number. USDT-quoted pairs (Binance USDC/USDT, FDUSD, @@ -58,33 +58,33 @@ methodology: - "Sources per stable, USDC: Binance USDC/USDT (USDT-anchored, secondary), Kraken USDCUSD, Bitstamp usdcusd. USDT: Kraken USDTUSD, Bitstamp usdtusd. FDUSD: Binance FDUSDUSDT (USDT-anchored, secondary). USDe: Binance USDEUSDT (USDT-anchored, secondary). DAI: Curve 3pool `get_dy` forward (USDC → DAI) and reverse (DAI → USDC)." - "Cadence: 5 seconds for CEX REST tickers, 12 seconds for Curve `get_dy` on-chain (matches Ethereum block time). Per-venue samples are grouped into 60-second buckets for the liquidity-weighted median aggregation." - "Aggregation: per-minute liquidity-weighted median across USD-quoted venues (`Quote = QuoteUSD`). Weight is 24 h USD volume estimate. The aggregated price drives `peg_deviation_bps` (per-minute median, retained for backward compatibility) and `peg_deviation_bps_histogram`." - - "Primary headline: `peg_deviation_worst_bps` — the per-minute MAX |price − $1.00| across every venue sample in the 60-second bucket, in basis points. The leaderboard ranks stables on `quantile_over_time(0.99, peg_deviation_worst_bps[24h])`. Switching from per-minute median to per-minute max surfaces the sub-minute depeg wicks (e.g. a 5-second print to $0.92 on one venue) that the median would smooth into invisibility — matching the OHLC convention used by every TradFi reference rate. The companion gauges `peg_minute_min_bps`, `peg_minute_open_bps` and `peg_minute_close_bps` expose the full bar shape so consumers can reconstruct the within-minute distribution." - - "Cross-venue gap (OCB-only number): `max(price across USD-quoted venues), min(price)`, per minute, in basis points. Surfaces the windows where Coinbase quotes $1.0003 while Kraken quotes $0.9978. Stored as `peg_cross_venue_gap_bps`." + - "Primary headline: `peg_deviation_worst_bps`, the per-minute MAX |price - $1.00| across all venue samples in the 60-second bucket, in basis points. The leaderboard ranks stables on `quantile_over_time(0.99, peg_deviation_worst_bps[24h])`. Per-minute max surfaces sub-minute depeg wicks that a median would smooth away, matching the OHLC convention of TradFi reference rates. Gauges `peg_minute_min_bps`, `peg_minute_open_bps`, `peg_minute_close_bps` expose the bar shape." + - "Cross-venue gap (OCB-only number): `max(price across USD-quoted venues) - min(price)`, per minute, in basis points. Surfaces the windows where Coinbase quotes $1.0003 while Kraken quotes $0.9978. Stored as `peg_cross_venue_gap_bps`." - "USDT-anchored secondary metric: Binance USDC/USDT, FDUSD/USDT and USDE/USDT are exposed on `peg_deviation_usdt_anchored_bps{venue}` separately so the USD-anchored primary leaderboard is not contaminated by USDT's own peg deviation." - "Time outside band: total seconds in the trailing 24 h during which the per-minute aggregated price fell outside [0.995, 1.005] (±50 bps). Split into `peg_time_below_peg_24h_seconds` (< 0.995) and `peg_time_above_peg_24h_seconds` (> 1.005) because Circle redemption only clears above-peg, so the direction tells you which failure mode is active." - "Depeg event flag: binary `peg_depeg_event_flag` set to 1 when the per-minute aggregated price has been outside [0.97, 1.03] for ≥5 consecutive minutes; cleared after 30 minutes back inside. Conservative so it does not flap during normal stress events." - - "Outlier rule (multi-venue consensus): a single sample more than 2% off peg is kept only when at least one OTHER venue has also been outside the same band in the same direction within the last 30 seconds. Single-venue glitches (one CEX returns a stale or fat-finger print while every other venue is at $1.00) are dropped as `dropped_isolated`. Multi-venue confirmation (Kraken AND Bitstamp both at $0.87 = real depeg) is kept as `kept_corroborated` so the percentile metric surfaces the event. Replaces the previous flat 20% drop / 10% cap which would have clipped USDC at $0.87 during the March 2023 SVB depeg to $0.90, erasing the event in the percentile metric. Sanity floor: anything more than 50% off peg is treated as a parser bug and dropped regardless. Methodology recommendation from Coinpaprika data team review." + - "Outlier rule (multi-venue consensus): a sample more than 2% off peg is kept only when at least one other venue was outside the same band in the same direction within the last 30 seconds. Isolated single-venue glitches are dropped as `dropped_isolated`; corroborated moves (Kraken AND Bitstamp both at $0.87) are kept as `kept_corroborated`, so a real depeg like USDC during SVB in March 2023 survives into the percentile metric. Anything more than 50% off peg is treated as a parser bug and dropped." - "Excluded by design: aggregator-only prices (CoinGecko, Coinmarketcap, DefiLlama) because they are themselves liquidity-weighted medians of the venues we already poll directly. Algo-stables that have already failed (UST, USDR) are out of scope; the bench tracks live, currently-redeemable stables." findings: - - "{{best_name}} currently leads at {{best_p50}} (p99 deviation, 24 h) across {{count}} measured stablecoins. The number is the worst 1% of the per-minute aggregated price's distance from $1.00, in basis points, on USD-quoted venues only." - - "{{name:usdc}} returns {{p50:usdc}} (p99 deviation, 24 h). USDC's peg is anchored by Circle's primary-market redemption window with US banks, so USD-quoted CEX deviations are usually mean-reverted within minutes by arbitrageurs hitting Coinbase or Kraken." - - "{{name:usdt}} sits at {{p50:usdt}} (p99 deviation, 24 h). USDT carries the secondary-market premium of being the dominant pair currency on Binance and most non-US CEXes, so its CEX deviation is structurally biased above $1 during risk-on minutes and below during risk-off." - - "{{name:dai}} clocks {{p50:dai}} (p99 deviation, 24 h) measured on Curve 3pool. DAI's CEX coverage is essentially dead in 2026 so the on-chain pool quote is the only honest signal; the forward vs reverse swap rates surface arbitrage gaps that an averaged CEX tracker would silently smooth out." + - "{{best_name}} currently leads at {{best_p50}} (median per-minute worst deviation, 24 h) across {{count}} measured stablecoins. The number is the worst 1% of the per-minute aggregated price's distance from $1.00, in basis points, on USD-quoted venues only." + - "{{name:usdc}} returns {{p99:usdc}} (p99 deviation, 24 h). USDC's peg is anchored by Circle's primary-market redemption window with US banks, so USD-quoted CEX deviations are usually mean-reverted within minutes by arbitrageurs hitting Coinbase or Kraken." + - "{{name:usdt}} sits at {{p99:usdt}} (p99 deviation, 24 h). USDT carries the secondary-market premium of being the dominant pair currency on Binance and most non-US CEXes, so its CEX deviation is structurally biased above $1 during risk-on minutes and below during risk-off." + - "{{name:dai}} clocks {{p99:dai}} (p99 deviation, 24 h) measured on Curve 3pool. DAI's CEX coverage is essentially dead in 2026 so the on-chain pool quote is the only honest signal; the forward vs reverse swap rates surface arbitrage gaps that an averaged CEX tracker would silently smooth out." - "USDe and FDUSD are USDT-anchored on Binance (no deep USD-quoted venue exists for either) and live on the companion bench `stablecoin-peg-usdt-anchored` (bench № 015), which is the only honest comparison surface for them. Mixing them into a USD-anchored leaderboard would compare apples to oranges since their deviation includes USDT's own drift." - - "{{worst_name}} trails at {{worst_p50}} (p99 deviation, 24 h) on the active filter. The gap between leader and laggard widens on volatile minutes, the cross-venue gap column ({{p50:usdc}} etc.) surfaces how synchronized the venues are at the exact moment of stress." + - "{{worst_name}} trails at {{worst_p50}} (median per-minute worst deviation, 24 h) on the active filter. The gap between leader and laggard widens on volatile minutes, the cross-venue gap column surfaces how synchronized the venues are at the exact moment of stress." faq: - q: "Which stablecoin is the most stable right now?" - a: "{{best_name}} currently leads the leaderboard at {{best_p50}} (p99 deviation, 24 h) across {{count}} measured stablecoins on USD-quoted venues. The leaderboard re-sorts every minute against fresh Prometheus samples, so the answer reflects the last 24 hours of measured per-minute aggregated price, not a static white-paper figure." + a: "{{best_name}} currently leads the leaderboard at {{best_p50}} (median per-minute worst deviation, 24 h) across {{count}} measured stablecoins on USD-quoted venues. The leaderboard re-sorts every minute against fresh Prometheus samples, so the answer reflects the last 24 hours of measured per-minute aggregated price, not a static white-paper figure." - q: "What is stablecoin peg deviation in basis points?" a: "Peg deviation is the absolute distance between a stablecoin's price and $1.00, expressed in basis points where 1 bp = 0.0001 = 0.01%. A stablecoin trading at $0.9978 has 22 bps of deviation; at $1.0050 it has 50 bps. The industry uses bps because the deviations are small. tens of bps is normal noise, hundreds of bps is stress, thousands of bps is a depeg event. The benchmark records both per-minute deviation and the worst-1% tail (p99) over 24 h, the integration-grade number for any product that holds inventory in the stable." - q: "How does USDC compare to USDT on stability?" - a: "{{name:usdc}} returns {{p50:usdc}} (p99 deviation, 24 h) on USD-quoted venues (Kraken, Bitstamp). {{name:usdt}} returns {{p50:usdt}} on USD-quoted venues (Kraken, Bitstamp). USDC's peg is anchored by Circle's same-day USD redemption with US banks, USDT's by Tether's larger but slower-clearing redemption window. The leaderboard surfaces the live gap and the cross-venue spread per minute so the comparison is grounded in measured data rather than a sentiment-driven preference." + a: "{{name:usdc}} returns {{p99:usdc}} (p99 deviation, 24 h) on USD-quoted venues (Kraken, Bitstamp). {{name:usdt}} returns {{p50:usdt}} on USD-quoted venues (Kraken, Bitstamp). USDC's peg is anchored by Circle's same-day USD redemption with US banks, USDT's by Tether's larger but slower-clearing redemption window. The leaderboard surfaces the live gap and the cross-venue spread per minute so the comparison is grounded in measured data rather than a sentiment-driven preference." - q: "Did USDe really depeg to $0.65 in October 2025?" a: "Yes, on Binance USDEUSDT specifically, the night of October 10 2025. Ethena's USDe is anchored by a delta-neutral basis trade rather than fiat reserves, and a sharp liquidation cascade on perp positions briefly cleared the orderbook on Binance well below peg. The benchmark exposes this kind of event live via the `peg_depeg_event_flag` (set when a stable has been outside [$0.97, $1.03] for ≥5 consecutive minutes) and via the USDT-anchored secondary metric. USDe's USD-anchored primary deviation cannot be measured because no major USD venue lists a deep USDe/USD pair; the USDT-anchored value is the honest live signal." - q: "How is the stablecoin peg measured on OpenChainBench?" - a: "We poll Binance, Kraken and Bitstamp REST tickers every 5 seconds for every (stable, venue) pair, and the Curve 3pool `get_dy` on-chain every 12 seconds (matches Ethereum block time). Per-venue samples are grouped into 60-second buckets and aggregated into a liquidity-weighted median per stable (weight = 24h USD volume). Deviation is `|aggregated_price, 1.00|` in basis points, recorded as both a gauge and a histogram. p99 over 24 h via Prometheus `quantile_over_time` is the headline. USDT-quoted pairs are exposed on a separate secondary metric so USDT's own peg deviation does not contaminate the USD-anchored primary." + a: "We poll Binance, Kraken and Bitstamp REST tickers every 5 seconds for every (stable, venue) pair, and the Curve 3pool `get_dy` on-chain every 12 seconds (matches Ethereum block time). Per-venue samples are grouped into 60-second buckets and aggregated into a liquidity-weighted median per stable (weight = 24h USD volume). Deviation is `|aggregated_price - 1.00|` in basis points, recorded as both a gauge and a histogram. p99 over 24 h via Prometheus `quantile_over_time` is the headline. USDT-quoted pairs are exposed on a separate secondary metric so USDT's own peg deviation does not contaminate the USD-anchored primary." - q: "Why ignore CoinGecko or CoinMarketCap prices?" a: "Aggregator prices (CoinGecko, CoinMarketCap, DefiLlama) are themselves liquidity-weighted medians of the venues this benchmark already polls directly, refreshed every 1-5 minutes. Re-aggregating an aggregator adds latency, hides the venue-level cross-spread that actually matters during stress (USDC briefly $0.9978 on Kraken while $1.0003 on Coinbase), and inherits whatever venue selection the aggregator chose. The benchmark goes to the source venues directly so the cross-venue gap is visible and the depeg event flag fires within 5 minutes of the price actually breaking the band." @@ -123,7 +123,7 @@ providers: - slug: usdc name: USDC tag: Circle, USD-redeemed primary, audited on Kraken + Bitstamp - formula: "Median |aggregated_price − $1.00| in bps, where aggregated_price is the per-minute liquidity-weighted median of USDCUSD ticks on Kraken and Bitstamp." + formula: "24h median of the per-minute worst |aggregated_price − $1.00| in bps; aggregated_price is the per-minute liquidity-weighted median of USDCUSD ticks on Kraken and Bitstamp." queries: p50: quantile_over_time(0.50, peg_deviation_worst_bps{stable="usdc"}[24h]) p90: quantile_over_time(0.90, peg_deviation_worst_bps{stable="usdc"}[24h]) @@ -136,7 +136,7 @@ providers: - slug: usdt name: USDT tag: Tether, dominant CEX pair currency, audited on Kraken + Bitstamp - formula: "Median |aggregated_price − $1.00| in bps, where aggregated_price is the per-minute liquidity-weighted median of USDTUSD ticks on Kraken and Bitstamp." + formula: "24h median of the per-minute worst |aggregated_price − $1.00| in bps; aggregated_price is the per-minute liquidity-weighted median of USDTUSD ticks on Kraken and Bitstamp." queries: p50: quantile_over_time(0.50, peg_deviation_worst_bps{stable="usdt"}[24h]) p90: quantile_over_time(0.90, peg_deviation_worst_bps{stable="usdt"}[24h]) @@ -149,7 +149,7 @@ providers: - slug: dai name: DAI tag: MakerDAO, audited via Curve 3pool get_dy (on-chain only) - formula: "Median |dai_price − $1.00| in bps, derived from Curve 3pool get_dy forward (USDC → DAI) and reverse (DAI → USDC) every 12s over 24h." + formula: "24h median of the per-minute worst |dai_price − $1.00| in bps, derived from Curve 3pool get_dy forward (USDC → DAI) and reverse (DAI → USDC) every 12s over 24h." queries: p50: quantile_over_time(0.50, peg_deviation_worst_bps{stable="dai"}[24h]) p90: quantile_over_time(0.90, peg_deviation_worst_bps{stable="dai"}[24h]) diff --git a/benchmarks/token-deployment-cost.yml b/benchmarks/token-deployment-cost.yml index d5b50042..167c1e5b 100644 --- a/benchmarks/token-deployment-cost.yml +++ b/benchmarks/token-deployment-cost.yml @@ -26,7 +26,7 @@ faq: - q: "Why is Cardano always similar?" a: "The Cardano min UTxO formula is deterministic. The coins_per_utxo_size protocol parameter is set by governance and moves rarely. A standard native asset bundle (32 byte policy hash plus short asset name) sits around 70 bytes, and the on chain mint transaction fee is a few cents of ADA. The USD figure on the leaderboard moves with ADA price, not with congestion." - q: "How does the L2 number include the L1 data fee?" - a: "Two paths depending on the rollup. Arbitrum Nitro: the chain bakes the L1 component into the returned gas via NodeInterface, so the plain eth_estimateGas figure already covers most of it (caveat: under L1 congestion the public RPC undercounts the L1 component by 10 to 50 percent and would need the explicit gasEstimateL1Component call). OP Stack rollups (Optimism, Base, Blast, Mantle, opBNB, Celo): eth_estimateGas returns L2 execution only, and we add the L1 data fee via OVM_GasPriceOracle.getL1Fee at 0x420000000000000000000000000000000000000F on the deploy calldata. Scroll, Linea and zkSync embed the data cost directly in their gas accounting." + a: "Two paths depending on the rollup. Arbitrum Nitro: the chain bakes the L1 component into the returned gas via NodeInterface, so the plain eth_estimateGas figure already covers most of it (caveat: under L1 congestion the public RPC undercounts the L1 component by 10 to 50 percent and would need the explicit gasEstimateL1Component call). OP Stack rollups (Optimism, Base, Blast, Mantle, opBNB, Celo): eth_estimateGas returns L2 execution only, and we add the L1 data fee via OVM_GasPriceOracle.getL1Fee at 0x420000000000000000000000000000000000000F on the deploy calldata. Scroll and Linea embed the data cost directly in their gas accounting." - q: "Where can I see the contract source?" a: "The canonical OpenZeppelin v5.0.2 ERC20 source and its compiled artifact (solc 0.8.24, optimizer runs 200) live in the harness directory under contracts/Token.sol and contracts/Token.json. The same bytecode is shipped to every EVM chain, so the cross chain gas comparison is on identical contract code. Reproduce locally with forge build and verify against the embedded constant in cmd/script/evm.go." - q: "Can I cite a value from this page?" @@ -337,7 +337,7 @@ providers: name: Stellar layer: l1 tag: Issuer + distribution + trustline, 2-account flow - formula: "3 × base_reserve_in_stroops + 2 × base_fee_in_stroops × XLM USD price." + formula: "(3 × base_reserve_in_stroops + 2 × base_fee_in_stroops) × XLM USD price." queries: p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="stellar"}[24h]) p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="stellar"}[24h]) diff --git a/benchmarks/validator-yield.yml b/benchmarks/validator-yield.yml index cb79fd16..192b077e 100644 --- a/benchmarks/validator-yield.yml +++ b/benchmarks/validator-yield.yml @@ -51,8 +51,8 @@ abstract: | cardinality bounded (the full set is ~3000); Hyperliquid exposes all ~30 active. The per-chain leaderboard number is `ocb_chain_median_net_yield_bps{chain}`, the median across the - per-validator gauges. p90 / p99 over a 24h window via - `quantile_over_time` surface the right-tail validators (best + per-validator gauges. p90 / p99 via an instant + `quantile()` across the tracked validators surface the right tail (best net yield among those tracked). Caveats. (a) Solana's median is dragged below the modal value by ~42 validators currently emitting 0 APY, commission set to 100% or no leader slots this diff --git a/next.config.ts b/next.config.ts index b5c0fb5b..87b62af2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -50,6 +50,14 @@ const nextConfig: NextConfig = { turbopack: { root: __dirname, }, + // Build-time page budget. The Prom client serializes queries through a + // global concurrency cap (src/lib/prometheus.ts); the FIRST page each + // build worker prerenders pays the whole multi-bench Prom load and can + // exceed the default 60 s budget (observed 2026-06-10: staging build + // failing on /products/ + OG-image routes after the cap landed). + // Later pages reuse the worker cache and render in milliseconds, so + // only that first-page budget needs headroom. + staticPageGenerationTimeout: 240, // Tree-shake lucide-react down to just the icons we actually import. // Without this hint Next's App Router can include the full barrel // (~1k icons, ~25 KB gzipped) on routes that touch lucide indirectly. diff --git a/scripts/audit-bench-data.ts b/scripts/audit-bench-data.ts new file mode 100644 index 00000000..cf1707f1 --- /dev/null +++ b/scripts/audit-bench-data.ts @@ -0,0 +1,183 @@ +/** + * One-shot data audit: executes every scalar query of every bench spec + * against the live Prometheus gateway and reports anomalies (empty + * results, zero/identical values, implausible success rates, dimension + * values absent from the metric labels). Read-only; safe to run anytime. + * + * bun scripts/audit-bench-data.ts [--prom https://...] + */ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import yaml from "js-yaml"; + +const PROM = + process.argv.includes("--prom") + ? process.argv[process.argv.indexOf("--prom") + 1] + : "https://prom-gateway-production.up.railway.app"; + +type Spec = { + slug: string; + status?: string; + unit?: string; + prometheus?: { url?: string }; + dimensions?: Record; + rank_matrix_query?: string; + providers: { + slug: string; + queries?: Record; + }[]; +}; + +let active = 0; +const waiters: (() => void)[] = []; +async function slot() { + if (active < 8) { + active++; + return; + } + await new Promise((r) => waiters.push(r)); +} +function release() { + const n = waiters.shift(); + if (n) n(); + else active--; +} + +async function q(promql: string, base = PROM): Promise { + await slot(); + try { + const url = new URL("/api/v1/query", base); + url.searchParams.set("query", promql); + const res = await fetch(url, { signal: AbortSignal.timeout(15_000) }); + const j = (await res.json()) as { + status: string; + data?: { resultType: string; result: unknown[] }; + }; + if (j.status !== "success") return "ERROR"; + const r = j.data; + if (!r) return "ERROR"; + if (r.resultType === "scalar") { + const v = Number((r.result as [number, string])[1]); + return Number.isFinite(v) ? v : null; + } + if (r.resultType === "vector" && r.result.length > 0) { + const v = Number( + (r.result[0] as { value: [number, string] }).value[1], + ); + return Number.isFinite(v) ? v : null; + } + return null; + } catch { + return "ERROR"; + } finally { + release(); + } +} + +async function labelValues( + metric: string, + label: string, + base = PROM, +): Promise> { + await slot(); + try { + const url = new URL("/api/v1/query", base); + url.searchParams.set("query", `count by (${label}) (${metric})`); + const res = await fetch(url, { signal: AbortSignal.timeout(15_000) }); + const j = (await res.json()) as { + data?: { result: { metric: Record }[] }; + }; + return new Set( + (j.data?.result ?? []).map((s) => s.metric[label]).filter(Boolean), + ); + } catch { + return new Set(); + } finally { + release(); + } +} + +function extractMetric(promql: string): string | null { + const m = promql.match(/([a-zA-Z_:][a-zA-Z0-9_:]*)\s*\{/); + return m ? m[1] : null; +} + +const dir = path.join(process.cwd(), "benchmarks"); +const files = (await fs.readdir(dir)).filter((f) => f.endsWith(".yml")); +const report: string[] = []; + +for (const file of files.sort()) { + const spec = yaml.load(await fs.readFile(path.join(dir, file), "utf8")) as Spec; + if (!spec?.providers) continue; + // Respect per-spec Prometheus (federation: some benches declare their own). + const base = spec.prometheus?.url ?? PROM; + const issues: string[] = []; + const p50s = new Map(); + + await Promise.all( + spec.providers.map(async (p) => { + const qs = (p.queries ?? {}) as Record; + const checks: [string, string][] = []; + for (const k of ["p50", "p90", "p99", "success", "sample_size"]) { + if (typeof qs[k] === "string") checks.push([k, qs[k]]); + } + const vals = await Promise.all(checks.map(([, query]) => q(query, base))); + checks.forEach(([k], i) => { + const v = vals[i]; + if (v === "ERROR") issues.push(`${p.slug}.${k}: QUERY ERROR`); + else if (v === null) issues.push(`${p.slug}.${k}: EMPTY`); + else { + if (k === "p50") { + if (v <= 0) issues.push(`${p.slug}.p50: ${v} (<=0)`); + p50s.set(p.slug, v); + } + if (k === "success") { + const pct = v > 1 ? v : v * 100; + if (pct <= 0) issues.push(`${p.slug}.success: 0%`); + if (pct > 100.5) issues.push(`${p.slug}.success: ${pct.toFixed(1)}% (>100)`); + } + if (k === "sample_size" && v === 0) issues.push(`${p.slug}.sample_size: 0`); + } + }); + }), + ); + + // Identical p50 across 3+ providers = suspicious copy-paste / constant. + const byVal = new Map(); + for (const [s, v] of p50s) { + const key = Math.round(v * 1000) / 1000; + byVal.set(key, [...(byVal.get(key) ?? []), s]); + } + for (const [v, slugs] of byVal) { + if (slugs.length >= 3) issues.push(`identical p50=${v} across: ${slugs.join(",")}`); + } + + // Dimension values present in the actual metric labels? + const firstQuery = (spec.providers[0]?.queries as Record)?.p50; + const metric = firstQuery ? extractMetric(firstQuery) : null; + if (metric && spec.dimensions) { + for (const dim of ["chain", "region"] as const) { + const declared = (spec.dimensions[dim] ?? []) + .map((d) => d.value) + .filter((v) => v !== "all"); + if (declared.length === 0) continue; + const present = await labelValues(metric, dim, base); + if (present.size === 0) continue; // metric may not carry the label at this granularity + const missing = declared.filter((v) => !present.has(v)); + if (missing.length > 0) + issues.push(`dimension ${dim}: declared but absent from ${metric}: ${missing.join(",")}`); + } + } + + if (spec.rank_matrix_query) { + const v = await q(`count(${spec.rank_matrix_query})`, base); + if (v === null || v === "ERROR" || v === 0) + issues.push(`rank_matrix_query: ${v === 0 ? "0 series" : String(v)}`); + } + + const head = `${spec.slug}${spec.status === "draft" ? " [draft]" : ""}`; + if (issues.length === 0) report.push(`OK ${head} (${p50s.size}/${spec.providers.length} providers live)`); + else report.push(`WARN ${head}\n ${issues.join("\n ")}`); +} + +console.log(report.join("\n")); diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 07bda322..202c7bd5 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -52,7 +52,29 @@ export default function AboutPage() { 13 live benchmarks. ~150 (provider × chain) probe pairs. Every metric is queryable on the public Prometheus and reproducible from the harness source.

- + +

+ OpenChainBench is funded by Mobula because we needed honest infrastructure benchmark data internally and saw no neutral source existed. The site is open-sourced because the industry needs it. +

+

+ Mobula competes in five live benchmarks: aggregator head lag, bridge fee, bridge quote latency, network coverage and metadata coverage. Mobula currently leads aggregator head lag and bridge quote latency. This is a real conflict of interest and we want it visible above the fold, not buried. +

+

+ Three safeguards stand against the conflict: +

+
    +
  • ·Open Prometheus data. Every number on the site is a literal quantile_over_time query. Anyone can hit our /api/citable or /api/stat/<slug> endpoint and re-derive the leaderboard with their own aggregation. If Mobula were inflating its rank, the raw data would show it.
  • +
  • ·Open harness source. Every harness is on GitHub under harnesses/. Clone, run docker compose up, your /metrics endpoint emits the same numbers ours does within 30 seconds.
  • +
  • ·Public methodology review. We invite external review and ship the fixes publicly. In June 2026 the Coinpaprika data team flagged four issues on the stablecoin peg and oracle deviation benches. We shipped three of them in pull requests #349, #352 and #353 within twenty-four hours and pushed back on the fourth with citations to CME, Chainlink and CoinGecko convention.
  • +
+

+ We do not run a paid tier. We do not sell ranking slots. We do not take provider sponsorship in exchange for inclusion. There is no token. If you spot any deviation from this policy, file a private security advisory and we will treat it as the integrity incident it would be. +

+

+ Hosting and infrastructure costs are paid by Mobula. The site runs on Vercel; harnesses run on Railway. We are open to grant funding that preserves editorial independence, but we will not accept funding from any party we benchmark. +

+ +

Every benchmark is a YAML spec plus a harness. The spec describes what to measure, which providers, which Prometheus queries hold the numbers; the harness runs continuously on Railway and exposes those metrics. A single shared Prometheus scrapes every harness; the site queries Prometheus directly and re-renders every minute. Every provider is rendered with equal visual weight. readers do their own ranking.

@@ -62,7 +84,7 @@ export default function AboutPage() { walks through the steps. New providers, new metrics, new chains. all welcome via pull request.

- +
  • ·Read the{" "}live benchmarks{" "}.
  • ·Reproduce any number. the{" "}methodology{" "}page tells you how.
  • @@ -70,7 +92,7 @@ export default function AboutPage() {
  • ·Discuss ideas in{" "}GitHub Discussions{" "}or follow{" "}@OpenChainBench.
- +

See a number you can't reproduce? File a{" "} diff --git a/src/app/alternatives/[slug]/page.tsx b/src/app/alternatives/[slug]/page.tsx index fd301803..b247df73 100644 --- a/src/app/alternatives/[slug]/page.tsx +++ b/src/app/alternatives/[slug]/page.tsx @@ -12,7 +12,7 @@ import { getBenchCreatedAt } from "@/lib/seo/bench-dates"; import { capDescription } from "@/lib/seo-text"; import { SectionLabel, SummaryStat } from "@/components/summary-stat"; import { SITE } from "@/data/site"; -import { loadAlternative, loadAlternativeSlugs } from "@/lib/alternatives"; +import { loadAlternative } from "@/lib/alternatives"; import { safeJsonLd } from "@/lib/jsonld"; import { ProviderLogo } from "@/components/provider-logo"; import { ProviderTypeBadge } from "@/components/provider-type-badge"; @@ -20,11 +20,17 @@ import { isRegion } from "@/lib/brand"; export const revalidate = 60; +// Same budget as /products/[slug]: on-demand renders span the whole bench +// catalog and the 60s default killed them mid-flight. +export const maxDuration = 300; + type Params = { slug: string }; -export async function generateStaticParams() { - const slugs = await loadAlternativeSlugs(); - return slugs.map((slug) => ({ slug })); +// Rendered ON DEMAND (first request, then ISR-cached), same reasoning as +// /products/[slug]: prerendering these at build multiplies the full +// multi-bench Prom load per build worker and blew the page budget. +export async function generateStaticParams(): Promise<{ slug: string }[]> { + return []; } export async function generateMetadata({ diff --git a/src/app/api/badge/[slug]/[provider]/route.ts b/src/app/api/badge/[slug]/[provider]/route.ts index d54fbfa7..5f29beed 100644 --- a/src/app/api/badge/[slug]/[provider]/route.ts +++ b/src/app/api/badge/[slug]/[provider]/route.ts @@ -1,24 +1,27 @@ /** * Embeddable SVG badge. One badge per (benchmark, provider). * - * Endpoint shape: /api/badge//?chain= + * Endpoint shape: + * /api/badge//?chain=®ion= * * Returns an SVG showing the provider's current rank + headline figure * on that bench. Cache-Control is short so the figure refreshes within * a few minutes of a new run. * - * The optional `?chain=` query param scopes the rank computation to a - * single chain (e.g. `?chain=solana`). When present, the badge: - * - computes rank within the providers that have a measurement on - * that chain (read from `benchmark.bestPerChain`). - * - prints the chain label as a subscript on the SVG so embedders - * don't mistakenly broadcast a chain-restricted "#1" as a global - * finish (e.g. a Solana-only provider being aggregate #1 mechanically - * on a cross-chain bench). + * The optional `?chain=` / `?region=` query params scope the rank + * computation. When the bench carries exact per-cell rankings (from its + * `rank_matrix_query`), the rank comes from the matching cell, including + * the combined scope `?chain=bnb®ion=sgp`, and the scope labels are + * printed as a subscript on the SVG so embedders don't broadcast a + * chain- or region-restricted "#1" as a global finish (e.g. dRPC leading + * only from Singapore reading as the worldwide chain leader). * - * When NO `chain` is provided, the badge falls back to the unfiltered - * aggregate AND adds an "all chains" textual hint to the SVG so the - * scope of the rank is visible at a glance. + * `?chain=` without cell data falls back to the legacy bestPerChain + * approximation. `?region=` requires cell data (404 otherwise). + * + * When NO scope is provided, the badge falls back to the unfiltered + * aggregate AND adds an "all chains" / "all regions" textual hint to the + * SVG so the scope of the rank is visible at a glance. */ import { type NextRequest, NextResponse } from "next/server"; @@ -33,12 +36,19 @@ export const revalidate = 300; type Params = { slug: string; provider: string }; -const H = 36; -// Width is fixed but generous so most benchmark titles fit without -// truncation. Anything over ~32 chars gets ellipsis. -const W = 360; -const LEFT_W = 78; -const TITLE_MAX = 32; +const H = 44; +// Text column starts right of the spinning logo sphere. +const TEXT_X = 48; +// The badge font is monospace, so line widths are predictable from the +// character count: SF Mono / Menlo advance is ~0.6em. Width is computed +// per request from the longest line so the full title always fits (no +// ellipsis), bounded to keep a malicious-length spec from emitting a +// billboard. +const CH_11 = 6.8; // 11px line (rank + title) +const CH_10 = 6.2; // 10px line (value + suffix) +const CH_8 = 5.4; // 8px scope subscript (incl. 0.6 letter-spacing) +const W_MIN = 300; +const W_MAX = 760; function rankOf( results: { slug: string; ms: { p50: number } }[], @@ -102,6 +112,27 @@ function rankOfChain( }; } +/** + * Exact scoped rank from the bench's per-cell rankings (populated by + * `rank_matrix_query` in the spec). Cell keys are `|` with + * "all" standing in for an unscoped side (derived marginals included). + * Returns null when the bench has no cell data, the cell is empty this + * cycle, or the provider isn't ranked in it. + */ +function rankOfCell( + b: Benchmark, + providerSlug: string, + chain: string | null, + region: string | null, +): { rank: number; total: number; value: number } | null { + const cell = b.cellRanks?.[`${chain ?? "all"}|${region ?? "all"}`]; + if (!cell || cell.length === 0) return null; + const lower = providerSlug.toLowerCase(); + const idx = cell.findIndex((e) => e.slug.toLowerCase() === lower); + if (idx === -1) return null; + return { rank: idx + 1, total: cell.length, value: cell[idx].p50 }; +} + function valueSuffix(unit: string): string { if (unit === "count") return "(24h)"; if (unit === "pct" || unit === "bps") return "(24h avg)"; @@ -118,6 +149,11 @@ function chainLabel(b: Benchmark, chain: string): string { return b.dimensions?.chain?.find((c) => c.value === chain)?.label ?? chain; } +/** Returns the human label for a region value from the bench's spec. */ +function regionLabel(b: Benchmark, region: string): string { + return b.dimensions?.region?.find((r) => r.value === region)?.label ?? region; +} + export async function GET( req: NextRequest, { params }: { params: Promise }, @@ -140,44 +176,83 @@ export async function GET( }); } - // Chain scoping. Query param is normalized to lowercase and validated - // against the bench's declared chain dimensions; an unknown chain is - // treated as a 400 rather than silently falling back to "all", so an - // embedder who mistypes can fix it instead of shipping a misleading - // unfiltered figure under a chain badge. + // Scope params are normalized to lowercase and validated against the + // bench's declared dimensions; an unknown value is treated as a 400 + // rather than silently falling back to "all", so an embedder who + // mistypes can fix it instead of shipping a misleading unfiltered + // figure under a scoped badge. The canonical dimension value (not the + // raw param) feeds the cell lookup. const url = new URL(req.url); - const chainParam = url.searchParams.get("chain")?.toLowerCase().trim() || null; - if (chainParam) { - const known = b.dimensions?.chain?.some( - (c) => c.value.toLowerCase() === chainParam, - ); - if (!known) { - return new NextResponse("unknown chain", { - status: 400, - headers: { "cache-control": "public, s-maxage=60" }, - }); - } + const rawChain = url.searchParams.get("chain")?.toLowerCase().trim() || null; + const rawRegion = url.searchParams.get("region")?.toLowerCase().trim() || null; + const chainParam = rawChain + ? (b.dimensions?.chain?.find((c) => c.value.toLowerCase() === rawChain) + ?.value ?? null) + : null; + const regionParam = rawRegion + ? (b.dimensions?.region?.find((d) => d.value.toLowerCase() === rawRegion) + ?.value ?? null) + : null; + if ((rawChain && !chainParam) || (rawRegion && !regionParam)) { + return new NextResponse(rawChain && !chainParam ? "unknown chain" : "unknown region", { + status: 400, + headers: { "cache-control": "public, s-maxage=60" }, + }); } let r: { rank: number; total: number; value: number } | null; let scopeLabel: string; - if (chainParam) { - const scoped = rankOfChain(b, provider, chainParam); - if (!scoped) { - return new NextResponse("not found", { status: 404 }); + if (chainParam || regionParam) { + const cell = rankOfCell(b, provider, chainParam, regionParam); + if (cell) { + r = cell; + } else if (chainParam && !regionParam) { + // Legacy approximation for chain-dimensioned benches without a + // rank_matrix_query in their spec. + const scoped = rankOfChain(b, provider, chainParam); + if (!scoped) { + return new NextResponse("not found", { + status: 404, + // Short TTL: a scoped miss is usually transient (bench cache + // entry predating cellRanks, or a Prom hiccup on the matrix + // query), so don't let the CDN pin the 404 for long. + headers: { "cache-control": "public, s-maxage=60" }, + }); + } + r = { rank: scoped.rank, total: scoped.total, value: scoped.value }; + } else { + return new NextResponse("not found", { + status: 404, + headers: { "cache-control": "public, s-maxage=60" }, + }); } - r = { rank: scoped.rank, total: scoped.total, value: scoped.value }; - scopeLabel = chainLabel(b, chainParam); + scopeLabel = [ + chainParam ? chainLabel(b, chainParam) : null, + regionParam ? regionLabel(b, regionParam) : null, + ] + .filter(Boolean) + .join(" · "); } else { r = rankOf(b.results, provider, b.higherIsBetter); - if (!r) return new NextResponse("not found", { status: 404 }); - // Add an "all chains" hint when the bench declares chain dimensions so - // embedders can read the scope. Benches without chain dimensions get - // no scope label (it would be noise). - scopeLabel = + if (!r) { + return new NextResponse("not found", { + status: 404, + headers: { "cache-control": "public, s-maxage=60" }, + }); + } + // Hint the aggregate scope when the bench declares dimensions so + // embedders can read it. Benches without dimensions get no scope + // label (it would be noise). + scopeLabel = [ (b.dimensions?.chain?.filter((c) => c.value !== "all").length ?? 0) > 0 ? "all chains" - : ""; + : null, + (b.dimensions?.region?.filter((d) => d.value !== "all").length ?? 0) > 0 + ? "all regions" + : null, + ] + .filter(Boolean) + .join(" · "); } // Colour signals rank. green for #1, dark ink for everyone else. @@ -185,34 +260,103 @@ export async function GET( const rankLabel = `#${r.rank}/${r.total}`; const value = fmtUnit(r.value, b.unit); const suffix = valueSuffix(b.unit); - const title = truncate(b.title, TITLE_MAX); + // Full title by default; ellipsis only kicks in past the W_MAX bound + // (schema allows 200-char titles, the canvas does not). + const titleBudget = Math.floor( + (W_MAX - TEXT_X - rankLabel.length * CH_11 - 7 - 8 - 30) / CH_11, + ); + const title = truncate(b.title, titleBudget); - // Provider initials in the bottom-left corner. mirrors the brand - // chip the site uses internally. - const ocbBrand = "OCB"; + // Size the canvas to the longest line. Line 1 ends before the OCB + // wordmark in the top-right corner (~30px incl. margin); line 2 is the + // figure plus the optional scope subscript. + const line1W = + TEXT_X + + rankLabel.length * CH_11 + + 7 + + title.length * CH_11 + + 8 + + 30; + const line2W = + TEXT_X + + `${value} ${suffix}`.length * CH_10 + + (scopeLabel ? 7 + scopeLabel.length * CH_8 : 0) + + 12; + const W = Math.ceil(Math.min(Math.max(W_MIN, line1W, line2W), W_MAX)); - // Scope marker: rendered as a small subscript next to the rank when - // present. Keeps the badge layout stable when absent (most benches). - const scopeAriaSuffix = scopeLabel ? ` (${scopeLabel})` : ""; - const scopeSvg = scopeLabel - ? `${escapeXml(scopeLabel.toUpperCase())}` + // Scope marker: small caps tspan appended to the figure line, so the + // badge height stays constant whether or not a scope is present. + // Escaped at construction: scopeLabel comes from YAML dimension labels + // and lands in XML attribute/text contexts below. + const scopeAriaSuffix = scopeLabel ? ` (${escapeXml(scopeLabel)})` : ""; + const scopeTspan = scopeLabel + ? `${escapeXml(scopeLabel.toUpperCase())}` : ""; - // Bump the SVG canvas height when a scope label is rendered so the - // subscript doesn't clip outside the box on stricter image renderers. - const svgH = scopeLabel ? H + 8 : H; + // The spinning mark: the masthead's 3D brand sphere (site-logo-3d.tsx) + // faked in static SVG. Same texture geometry as the three.js canvas + // (C-ring + grey corner markers packed in the sphere's front face), + // dark skin, limb-darkening overlay and a specular highlight. The yaw + // animation is a true 360: the mark slides toward the right limb with + // cosine foreshortening, hides behind the sphere (plain dark back, + // exactly like the real sphere), then re-enters from the left limb. + // A background-colored cover ring hides the mark's overshoot past the + // sphere's silhouette (clip-path on animated content is unreliable in + // some renderers; an opaque ring is not). CSS animations inside + // SVG run in embeds; the reduced-motion media query freezes the + // mark front-and-center for users who opted out, which is also what + // static renderers without CSS support show. const svg = ` - + OpenChainBench. ${escapeXml(b.title)}. ${rankLabel}${scopeAriaSuffix}, ${value} ${suffix} - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - ${ocbBrand} - ${rankLabel} - ${escapeXml(title)} - ${value} ${suffix} - ${scopeSvg} + ${rankLabel}${escapeXml(title)} + ${value} ${suffix}${scopeTspan} + OCB `; diff --git a/src/app/api/bench/[slug]/variant/route.ts b/src/app/api/bench/[slug]/variant/route.ts new file mode 100644 index 00000000..7a9e9a08 --- /dev/null +++ b/src/app/api/bench/[slug]/variant/route.ts @@ -0,0 +1,93 @@ +/** + * On-demand bench variant for the client-side chain/region/kind tabs. + * + * GET /api/bench//variant?chain=®ion=&kind= + * + * Returns the filtered Benchmark as JSON. Exists so the bench page can + * ship ONLY the aggregate view (the old embedded variant map multiplied + * every ISR regeneration by chains × regions × kinds full provider + * loads). Each (slug, filters) combo is deduped across users by the + * per-variant unstable_cache in the spec loader, so the first tab flip + * per minute pays one Prom roundtrip and everyone else gets cache hits. + */ + +import { type NextRequest, NextResponse } from "next/server"; +import { getBenchmark } from "@/data/benchmarks"; +import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; +import { SLUG_RE } from "@/lib/slug"; + +export const revalidate = 60; + +type Params = { slug: string }; + +export async function GET( + req: NextRequest, + { params }: { params: Promise }, +) { + const rl = rateLimit(clientKey(req, "variant"), 120, 60); + if (!rl.ok) return tooManyRequests(rl.retryAfterSec); + + const { slug } = await params; + if (!SLUG_RE.test(slug)) { + return new NextResponse("bad_input", { + status: 400, + headers: { "cache-control": "public, s-maxage=60" }, + }); + } + const aggregate = await getBenchmark(slug); + if (!aggregate || aggregate.editorialStatus !== "live") { + return new NextResponse("not found", { + status: 404, + headers: { "cache-control": "public, s-maxage=60" }, + }); + } + + // Validate every filter against the declared dimensions and use the + // canonical value: these end up in PromQL label selectors downstream. + const url = new URL(req.url); + const filters: { chain?: string; region?: string; kind?: string } = {}; + for (const dim of ["chain", "region", "kind"] as const) { + const raw = url.searchParams.get(dim)?.toLowerCase().trim(); + if (!raw || raw === "all") continue; + const known = (aggregate.dimensions?.[dim] ?? []).find( + (d) => d.value.toLowerCase() === raw, + ); + if (!known) { + return new NextResponse(`unknown ${dim}`, { + status: 400, + headers: { "cache-control": "public, s-maxage=60" }, + }); + } + filters[dim] = known.value; + } + + const variant = + Object.keys(filters).length > 0 + ? ((await getBenchmark(slug, filters)) ?? aggregate) + : aggregate; + + // Editorial copy resolves chain placeholders against the aggregate's + // stashes (computed unfiltered only); without this override a chain + // tab would surface raw `{{best_name:chain:X}}` strings. + const payload = + variant === aggregate + ? variant + : { + ...variant, + findings: aggregate.findings, + faq: aggregate.faq, + seoIntro: aggregate.seoIntro, + abstract: aggregate.abstract, + methodology: aggregate.methodology, + perChainExplainer: aggregate.perChainExplainer, + bestPerChain: aggregate.bestPerChain, + worstPerChain: aggregate.worstPerChain, + }; + + return NextResponse.json(payload, { + headers: { + "cache-control": "public, s-maxage=60, stale-while-revalidate=300", + Vary: "Accept-Encoding", + }, + }); +} diff --git a/src/app/benchmarks/[slug]/[chain]/page.tsx b/src/app/benchmarks/[slug]/[chain]/page.tsx new file mode 100644 index 00000000..b4672892 --- /dev/null +++ b/src/app/benchmarks/[slug]/[chain]/page.tsx @@ -0,0 +1,637 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft, ArrowUpRight } from "lucide-react"; +import { getBenchmark } from "@/data/benchmarks"; +import { liveResults } from "@/lib/provider-filters"; +import { fmtUnit } from "@/lib/format"; +import { capDescription } from "@/lib/seo-text"; +import { SITE } from "@/data/site"; +import { safeJsonLd } from "@/lib/jsonld"; +import { CATEGORY_COLOR } from "@/lib/category-colors"; +import type { Benchmark, ProviderResult } from "@/types/benchmark"; + +// Dedicated per-chain landing pages. Only chains that have a hand-written +// `per_chain_explainer` entry in the bench YAML get a route here - that +// unique editorial body is what keeps these pages from being doorway +// duplicates of the parent bench. The parent's `?chain=` query filter +// stays a client-side UI affordance; THIS route is the indexable, +// self-canonical document targeting long-tail queries like "ethereum +// finality time" or "fastest free polygon rpc". +// +// Two bench shapes resolve here: +// - "row": the chain IS a leaderboard row (l1-finality, where +// results are chains). Page shows the chain's measured +// value + rank against sibling chains. +// - "dimension": the chain is a filter dimension (rpc-capabilities, +// where results are providers probed per chain). Page +// shows the provider leaderboard scoped to that chain, +// with per-region leaders called out when they differ - +// a provider that wins on 6 of 10 chains in one region +// must never be presented as the global winner. +export const revalidate = 60; + +// Dimension pages fan out to (chain x region) variant fetches; a cold +// render can take a while when the unstable_cache is empty. +export const maxDuration = 300; + +type Params = { slug: string; chain: string }; + +function explainerChains(b: Benchmark): string[] { + const resultSlugs = new Set(b.results.map((r) => r.slug)); + const chainValues = new Set( + (b.dimensions?.chain ?? []) + .map((c) => c.value) + .filter((v) => v.toLowerCase() !== "all"), + ); + return (b.perChainExplainer ?? []) + .map((e) => e.slug) + .filter((s) => resultSlugs.has(s) || chainValues.has(s)); +} + +// Rendered ON DEMAND, like product pages and OG images. Even the +// "cheap" row-shaped pages turned out not to be free at build: the +// catalog payload is >2MB so unstable_cache refuses to store it, every +// page reloads the full catalog, and chain pages took 240s+ in failed +// builds. No Prom calls at build; pages warm via ISR + sitemap crawl. +export async function generateStaticParams() { + return []; +} + +type Explainer = { slug: string; h2: string; body: string }; + +type RowShape = { + shape: "row"; + benchmark: Benchmark; + explainer: Explainer; + result: ProviderResult; + sorted: ProviderResult[]; + rank: number; +}; + +type DimensionShape = { + shape: "dimension"; + benchmark: Benchmark; + explainer: Explainer; + chainLabel: string; + providers: ProviderResult[]; + leader: ProviderResult | null; + regionLeaders: { region: string; leader: ProviderResult }[]; +}; + +type ChainPageData = RowShape | DimensionShape; + +/** The spec loader's renderTemplate only resolves placeholders for + * providers that are "live" this cycle. When data is in a transient + * gap, raw `{{p50:ethereum}}` / `{{best_name:chain:base}}` tokens + * would leak into the H1 copy and the meta description. Resolve what + * we can against the full results array, degrade the rest to neutral + * copy so template syntax never ships to the SERP. */ +function resolveLeftoverPlaceholders(text: string, b: Benchmark): string { + return text + .replace( + /\{\{\s*(p50|p90|p99|mean|name):([a-z0-9-]+)\s*\}\}/gi, + (whole, keyword: string, slug: string) => { + const row = b.results.find( + (r) => r.slug.toLowerCase() === slug.toLowerCase(), + ); + const k = keyword.toLowerCase(); + if (k === "name") return row ? row.name : whole; + const raw = row?.ms[k as "p50" | "p90" | "p99" | "mean"]; + if (!raw || raw <= 0) return "measured live"; + return fmtUnit(raw, b.unit); + }, + ) + .replace(/\{\{\s*(best_name|worst_name):chain:[a-z0-9_-]+\s*\}\}/gi, "the live leader") + .replace(/\{\{\s*(best_p50|worst_p50):chain:[a-z0-9_-]+\s*\}\}/gi, "measured live"); +} + +function sortLive(results: ProviderResult[], higherIsBetter: boolean) { + return [...liveResults(results)].sort((a, b) => + higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50, + ); +} + +async function loadChainPage( + slug: string, + chain: string, +): Promise { + const benchmark = await getBenchmark(slug); + if (!benchmark) return null; + const found = (benchmark.perChainExplainer ?? []).find( + (e) => e.slug === chain, + ); + if (!found) return null; + const explainer = { + ...found, + h2: resolveLeftoverPlaceholders(found.h2, benchmark), + body: resolveLeftoverPlaceholders(found.body, benchmark), + }; + + // Shape 1: the chain is a leaderboard row (l1-finality). + const result = benchmark.results.find((r) => r.slug === chain); + if (result) { + const sorted = sortLive(benchmark.results, benchmark.higherIsBetter); + const rank = sorted.findIndex((r) => r.slug === chain) + 1; + return { shape: "row", benchmark, explainer, result, sorted, rank }; + } + + // Shape 2: the chain is a filter dimension (rpc-capabilities). + const chainOption = (benchmark.dimensions?.chain ?? []).find( + (c) => c.value === chain && c.value.toLowerCase() !== "all", + ); + if (!chainOption) return null; + const scoped = (await getBenchmark(slug, { chain })) ?? benchmark; + const providers = sortLive(scoped.results, benchmark.higherIsBetter); + const leader = providers[0] ?? null; + + // Per-region leaders on this chain. The cross-region scoped leaderboard + // is the headline, but when regional winners diverge the key-facts line + // says so explicitly instead of crowning one provider globally. + const regions = (benchmark.dimensions?.region ?? []).filter( + (r) => r.value.toLowerCase() !== "all", + ); + const regionLeaders: { region: string; leader: ProviderResult }[] = []; + if (regions.length > 0) { + const variants = await Promise.all( + regions.map(async (r) => ({ + label: r.label, + bench: await getBenchmark(slug, { chain, region: r.value }), + })), + ); + for (const v of variants) { + if (!v.bench) continue; + const lead = sortLive(v.bench.results, benchmark.higherIsBetter)[0]; + if (lead) regionLeaders.push({ region: v.label, leader: lead }); + } + } + + return { + shape: "dimension", + benchmark, + explainer, + chainLabel: chainOption.label, + providers, + leader, + regionLeaders, + }; +} + +/** Meta descriptions must not leak inline markdown from the YAML body + * (backticks around RPC method names, bold, links). */ +function stripInlineMarkdown(text: string): string { + return text + .replace(/`([^`]*)`/g, "$1") + .replace(/\*\*([^*]*)\*\*/g, "$1") + .replace(/\*([^*]*)\*/g, "$1") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +function asOfDate(lastRunAt: string | undefined): string { + const d = lastRunAt ? new Date(lastRunAt) : new Date(); + return d.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +/** "dRPC in US-East and EU-West; PublicNode in Singapore." Groups + * regions by their leading provider, preserving region order. */ +function regionLeaderSentence( + regionLeaders: { region: string; leader: ProviderResult }[], +): string | null { + if (regionLeaders.length < 2) return null; + const names = new Set(regionLeaders.map((r) => r.leader.name)); + if (names.size === 1) { + return `${regionLeaders[0].leader.name} leads in all ${regionLeaders.length} regions measured.`; + } + const byLeader = new Map(); + for (const r of regionLeaders) { + const list = byLeader.get(r.leader.name) ?? []; + list.push(r.region); + byLeader.set(r.leader.name, list); + } + const parts = [...byLeader.entries()].map( + ([name, regions]) => `${name} in ${regions.join(" and ")}`, + ); + return `Regional leaders differ: ${parts.join("; ")}.`; +} + +/** Mid-sentence metric phrase: "Finality time" -> "finality time" but + * "RPC latency" stays as-is (leading acronym must not become "rpc"). */ +function metricPhrase(metric: string): string { + return /^[A-Z]{2}/.test(metric) + ? metric + : metric.charAt(0).toLowerCase() + metric.slice(1); +} + +function buildKeyFacts(data: ChainPageData): string { + const b = data.benchmark; + const date = asOfDate(b.lastRunAt); + if (data.shape === "row") { + const { result, sorted, rank } = data; + const subject = `${result.name} ${metricPhrase(b.metric)}`; + if (result.ms.p50 <= 0) { + return `${subject} is measured continuously on this benchmark. Live numbers will appear here as soon as the harness reports fresh samples.`; + } + const p50 = fmtUnit(result.ms.p50, b.unit); + const p90 = fmtUnit(result.ms.p90, b.unit); + const p99 = fmtUnit(result.ms.p99, b.unit); + return ( + `As of ${date}, ${subject} is ${p50} at the median (p50, 24h window), with ${p90} at p90 and ${p99} at p99.` + + (rank > 0 + ? ` ${result.name} ranks #${rank} of ${sorted.length} chains measured on this benchmark.` + : "") + ); + } + const { leader, providers, chainLabel, regionLeaders } = data; + if (!leader) { + return `${chainLabel} ${metricPhrase(b.metric)} is measured continuously on this benchmark. Live numbers will appear here as soon as the harness reports fresh samples.`; + } + const base = `As of ${date}, ${leader.name} leads ${chainLabel} ${metricPhrase(b.metric)} at ${fmtUnit(leader.ms.p50, b.unit)} (p50, 24h window), measured across ${providers.length} providers.`; + const regional = regionLeaderSentence(regionLeaders); + return regional ? `${base} ${regional}` : base; +} + +function pageTitle(data: ChainPageData): string { + const b = data.benchmark; + if (data.shape === "row") { + return data.result.ms.p50 > 0 + ? `${data.explainer.h2}: ${fmtUnit(data.result.ms.p50, b.unit)} p50 live` + : `${data.explainer.h2}: live benchmark`; + } + return data.leader + ? `${data.explainer.h2}: ${data.leader.name} leads at ${fmtUnit(data.leader.ms.p50, b.unit)}` + : `${data.explainer.h2}: live benchmark`; +} + +export async function generateMetadata({ + params, +}: { + params: Promise; +}): Promise { + const { slug, chain } = await params; + const data = await loadChainPage(slug, chain); + if (!data) return {}; + const title = pageTitle(data); + const description = capDescription( + stripInlineMarkdown(data.explainer.body), + 158, + ); + const canonical = `${SITE.url}/benchmarks/${data.benchmark.slug}/${chain}`; + const ogImage = `${SITE.url}/api/og/${data.benchmark.slug}`; + return { + title, + description, + alternates: { canonical }, + openGraph: { + title, + description, + type: "article", + url: canonical, + images: [ogImage], + }, + twitter: { + card: "summary_large_image", + title, + description, + images: [ogImage], + }, + }; +} + +export default async function BenchmarkChainPage({ + params, +}: { + params: Promise; +}) { + const { slug, chain } = await params; + const data = await loadChainPage(slug, chain); + if (!data) notFound(); + const { benchmark, explainer } = data; + + const benchmarkUrl = `${SITE.url}/benchmarks/${benchmark.slug}`; + const pageUrl = `${benchmarkUrl}/${chain}`; + const catColor = CATEGORY_COLOR[benchmark.category]; + const crumbName = + data.shape === "row" ? data.result.name : data.chainLabel; + const keyFacts = buildKeyFacts(data); + const gatedChains = new Set(explainerChains(benchmark)); + + const jsonLd = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "TechArticle", + "@id": `${pageUrl}#article`, + headline: explainer.h2, + description: capDescription(stripInlineMarkdown(explainer.body), 158), + url: pageUrl, + mainEntityOfPage: pageUrl, + articleBody: `${keyFacts} ${stripInlineMarkdown(explainer.body)}`, + image: `${SITE.url}/api/og/${benchmark.slug}`, + dateModified: benchmark.lastRunAt, + author: { "@id": `${SITE.url}/#org` }, + publisher: { "@id": `${SITE.url}/#org` }, + about: { "@id": `${benchmarkUrl}#dataset` }, + isPartOf: { "@id": `${benchmarkUrl}#article` }, + }, + { + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Home", item: SITE.url }, + { + "@type": "ListItem", + position: 2, + name: "Benchmarks", + item: `${SITE.url}/benchmarks`, + }, + { + "@type": "ListItem", + position: 3, + name: benchmark.title, + item: benchmarkUrl, + }, + { + "@type": "ListItem", + position: 4, + name: crumbName, + item: pageUrl, + }, + ], + }, + ], + }; + + return ( +
+ + +

    +
  1. + + Home + +
  2. +
  3. /
  4. +
  5. + + Benchmarks + +
  6. +
  7. /
  8. +
  9. + + {benchmark.title} + +
  10. +
  11. /
  12. +
  13. + {crumbName} +
  14. +
+ + + + + Full benchmark: {benchmark.title} + + +
+ + {benchmark.category} + + {crumbName} +
+ +

+ {explainer.h2} +

+ + {/* Dated key-facts line. Server-rendered first so the citable, + date-stamped stat lands in the first ~100 words of the document. */} +

+ {keyFacts} +

+ +
+ {explainer.body.split(/\n\n+/).map((para, i) => ( +

{para.trim()}

+ ))} +
+ + {data.shape === "row" ? ( + + ) : ( + + )} + + {/* Sibling chain pages, for the dimension shape (the row shape's + comparison list already links siblings). */} + {data.shape === "dimension" && ( + + )} + +

+ Methodology, charts and the full ledger live on the{" "} + + {benchmark.title} + {" "} + page. Raw data:{" "} + + JSON endpoint + + + . +

+ + ); +} + +/** Row shape: cross-chain ranking with the current chain highlighted. + * Every sibling with its own explainer links to its dedicated page + * (internal mesh); chains without one deep-link to their anchor on the + * parent bench. */ +function RowComparison({ + data, + chain, + gatedChains, +}: { + data: RowShape; + chain: string; + gatedChains: Set; +}) { + const { benchmark, result, sorted } = data; + if (sorted.length < 2) return null; + return ( +
+

+ How {result.name} compares +

+

+ Live p50 over the last 24 hours across every chain on this benchmark, + ranked {benchmark.higherIsBetter ? "highest" : "lowest"} first. +

+
    + {sorted.map((r, i) => { + const isCurrent = r.slug === chain; + const href = gatedChains.has(r.slug) + ? `/benchmarks/${benchmark.slug}/${r.slug}` + : `/benchmarks/${benchmark.slug}#${r.slug}`; + const row = ( + + + + #{i + 1} + + + {r.name} + + + + {fmtUnit(r.ms.p50, benchmark.unit)} + + + ); + return ( +
  1. + {isCurrent ? ( + row + ) : ( + + {row} + + )} +
  2. + ); + })} +
+
+ ); +} + +/** Dimension shape: provider leaderboard scoped to this chain, plus the + * per-region leader breakdown when the bench declares regions. */ +function DimensionLeaderboard({ data }: { data: DimensionShape }) { + const { benchmark, chainLabel, providers, regionLeaders } = data; + if (providers.length === 0) return null; + return ( + <> +
+

+ {chainLabel} leaderboard +

+

+ Live p50 over the last 24 hours, scoped to {chainLabel} only, ranked{" "} + {benchmark.higherIsBetter ? "highest" : "lowest"} first. Cross-region + average; the regional breakdown below shows where leaders diverge. +

+
    + {providers.map((r, i) => ( +
  1. + + + + + #{i + 1} + + + {r.name} + + + + {fmtUnit(r.ms.p50, benchmark.unit)} + + + +
  2. + ))} +
+
+ + {regionLeaders.length > 1 && ( +
+

+ Leader by region on {chainLabel} +

+
    + {regionLeaders.map((r) => ( +
  • + {r.region} + + {r.leader.name} · {fmtUnit(r.leader.ms.p50, benchmark.unit)} + +
  • + ))} +
+
+ )} + + ); +} + +function SiblingChains({ + benchmark, + current, + gatedChains, +}: { + benchmark: Benchmark; + current: string; + gatedChains: Set; +}) { + const siblings = (benchmark.dimensions?.chain ?? []).filter( + (c) => + c.value !== current && + c.value.toLowerCase() !== "all" && + gatedChains.has(c.value), + ); + if (siblings.length === 0) return null; + return ( + + ); +} diff --git a/src/app/benchmarks/[slug]/opengraph-image.tsx b/src/app/benchmarks/[slug]/opengraph-image.tsx index 6670ba06..dc917d7b 100644 --- a/src/app/benchmarks/[slug]/opengraph-image.tsx +++ b/src/app/benchmarks/[slug]/opengraph-image.tsx @@ -1,5 +1,5 @@ import { ImageResponse } from "next/og"; -import { getBenchmark, getBenchmarkSlugs } from "@/data/benchmarks"; +import { getBenchmark } from "@/data/benchmarks"; import { headlineSentence, leader } from "@/lib/citation"; import { fmtUnit } from "@/lib/format"; import { CATEGORY_COLOR } from "@/lib/category-colors"; @@ -10,9 +10,12 @@ export const alt = "OpenChainBench. Open benchmarks for crypto infrastructure"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; +// On demand only. Returning slugs here feeds static params to the whole +// [slug] segment, so the PAGE gets prerendered at build too (240s+ +// timeouts on heavy benches killed deploys on 2026-06-11) even though +// page.tsx itself returns []. export async function generateStaticParams() { - const slugs = await getBenchmarkSlugs(); - return slugs.map((slug) => ({ slug })); + return []; } // Emit one OG image per (slug, chain) combo so social shares of diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index c95e2c08..8497772e 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -3,11 +3,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import Link from "next/link"; import { ArrowLeft, ArrowUpRight, ChevronDown } from "lucide-react"; -import { - getBenchmark, - getBenchmarks, - getBenchmarkSlugs, -} from "@/data/benchmarks"; +import { getBenchmark, getBenchmarks } from "@/data/benchmarks"; import { Pill } from "@/components/pill"; import { BenchmarkBody } from "@/components/benchmark-body"; import { ChainHeadingsSummary } from "@/components/chain-headings-summary"; @@ -46,61 +42,45 @@ import type { Benchmark } from "@/types/benchmark"; // the route still prerenders cleanly. export const revalidate = 60; -// Cold start render does up to ~200 Prom calls (provider × percentiles × -// series queries) which can exceed Vercel's default 10s function timeout -// on benches with 20+ providers. Once ISR warms, the page is cached and -// fast — this only affects the first hit per revalidate window. Bumped -// to 60s to absorb the slow cold path. The actual hot path latency is -// served from the CDN cache so the user-facing P99 stays sub-second. -export const maxDuration = 60; +// Cold start render does thousands of Prom calls on the heaviest benches +// (hyperliquid-frontends: 75 providers × 7 queries + 11 panels × values + +// series), serialized through the Prom client's concurrency cap. At 60s +// the ISR regeneration itself was killed ("Vercel Runtime Timeout Error: +// Task timed out after 60 seconds"), so the cache could NEVER replace a +// build-time render that had failed its panel queries — staging served +// empty panel values for hours (2026-06-11). 300s gives the regeneration +// room to finish; the hot path is CDN-cached and stays sub-second. +export const maxDuration = 300; type Params = { slug: string }; -export async function generateStaticParams() { - const slugs = await getBenchmarkSlugs(); - return slugs.map((slug) => ({ slug })); +// Rendered ON DEMAND (first request, then ISR-cached). Prerendering the +// 25+ bench pages at build pushed the full multi-bench Prom load through +// the CI runner, whose DNS resolver throttles under hundreds of lookups; +// observed 2026-06-11: /benchmarks/pm-data-freshness failing 3×60s +// export attempts and killing the deploy. Without the embedded variant +// matrix an on-demand first render is a few seconds once per deploy per +// slug, then the CDN serves it. +export async function generateStaticParams(): Promise<{ slug: string }[]> { + return []; } export async function generateMetadata({ params, - searchParams, }: { params: Promise; - searchParams?: Promise>; }): Promise { const { slug } = await params; - // Next 16 ships searchParams as a Promise. Reading it here would normally - // tip the segment into "dynamic", but generateMetadata is allowed to - // consume request data without affecting the parent page's static - // rendering — the page.tsx body still resolves searchParams client-side - // through BenchmarkBody. - const sp = (await searchParams) ?? {}; - const rawChain = Array.isArray(sp.chain) ? sp.chain[0] : sp.chain; - // Always re-fetch unfiltered first — used for canonical fields and the - // default copy. When a chain is requested, fetch the filtered variant - // so headline sentence + template placeholders resolve against the - // chain-scoped leader rather than the cross-chain aggregate. - const baseBench = await getBenchmark(slug); - if (!baseBench) return {}; - const chainOption = (baseBench.dimensions?.chain ?? []).find( - (c) => c.value.toLowerCase() === (rawChain ?? "").toLowerCase(), - ); - const isChainScoped = Boolean(chainOption && chainOption.value !== "all"); - const filteredBench = isChainScoped - ? (await getBenchmark(slug, { chain: chainOption!.value })) ?? baseBench - : baseBench; - // Use the filtered bench for headline + template substitutions so the - // OG/Twitter card and meta description reference the chain-specific - // leader rather than the cross-chain aggregate (the headline of the - // unfiltered bench is misleading when a single chain dominates the - // baseline — e.g. Solana skewing the "fastest data API" claim on the - // Bench-001 aggregate view). - const b = filteredBench; - const chainLabel = chainOption?.label ?? null; - const baseTitle = b.seoTitle ?? b.title; - const metaTitle = isChainScoped && chainLabel - ? `${baseTitle} on ${chainLabel}` - : baseTitle; + // DO NOT read searchParams here. Awaiting it in generateMetadata opts + // the whole route into dynamic rendering (`cache-control: no-store`, + // zero CDN caching) — measured at 43s TTFB on cold rpc-capabilities + // hits. Chain-scoped metadata lives on the dedicated + // /benchmarks/[slug]/[chain] pages; `?chain=` URLs on this route share + // the aggregate metadata and the canonical, which is what we want for + // link-signal consolidation anyway. + const b = await getBenchmark(slug); + if (!b) return {}; + const metaTitle = b.seoTitle ?? b.title; // Description precedence (most-to-least specific): // 1. `seo_description` from the YAML - hand-crafted snippet with the // long-tail query phrases we want to rank for. @@ -119,15 +99,9 @@ export async function generateMetadata({ // longer is cut mid-word which hurts CTR. Trim cleanly so we control the // truncation rather than letting Google decide where to slice. if (description) description = capDescription(description, 158); - // Canonical NEVER carries `?chain=...`. Per-chain variants share the - // same canonical URL so Google consolidates link signal on the hub - // page instead of treating each tab as a separate document. The OG - // url is the chain-scoped one so social previews don't all collapse - // to the same target. - const canonical = `${SITE.url}/benchmarks/${baseBench.slug}`; - const ogUrl = isChainScoped - ? `${canonical}?chain=${chainOption!.value}` - : canonical; + // Canonical NEVER carries `?chain=...`. Per-chain variants live on the + // dedicated /benchmarks/[slug]/[chain] pages with their own metadata. + const canonical = `${SITE.url}/benchmarks/${b.slug}`; return { title: metaTitle, description, @@ -151,7 +125,7 @@ export async function generateMetadata({ title: metaTitle, description, type: "article", - url: ogUrl, + url: canonical, }, twitter: { card: "summary_large_image", title: metaTitle, description }, }; @@ -181,58 +155,21 @@ export default async function BenchmarkPage({ const region = regionOptions[0]?.value ?? null; const kind = kindOptions[0]?.value ?? null; - // Pre-fetch every (chain × region × kind) variant in parallel so client flips - // are zero round-trip. unstable_cache dedupes each (slug, filters) combo - // across users - first miss warms it, every later viewer gets it instant. - // `all` is the "no filter" sentinel - same as the unscoped fetch. - const chainsForFetch = chainOptions.length > 0 ? chainOptions.map((c) => c.value) : [null]; - const regionsForFetch = regionOptions.length > 0 ? regionOptions.map((r) => r.value) : [null]; - const kindsForFetch = kindOptions.length > 0 ? kindOptions.map((k) => k.value) : [null]; - - const variantPairs = chainsForFetch.flatMap((c) => - regionsForFetch.flatMap((r) => - kindsForFetch.map((k) => [c, r, k] as const) - ) - ); - const [variantList, all] = await Promise.all([ - Promise.all( - variantPairs.map(async ([c, r, k]) => { - const filters: { chain?: string; region?: string; kind?: string } = {}; - if (c && c !== "all") filters.chain = c; - if (r && r !== "all") filters.region = r; - if (k && k !== "all") filters.kind = k; - const b = await getBenchmark(slug, filters); - return [variantKey(c, r, k), b ?? aggregate] as const; - }) - ), - getBenchmarks(), - ]); - // Variants only contribute chart / leaderboard / extras to the displayed - // bench (those legitimately differ per (chain, region) filter). Editorial - // copy (findings, faq, seoIntro, abstract, methodology) is the SAME on - // every tab and only resolves chain placeholders against the aggregate's - // bestPerChain/worstPerChain stash (computed unfiltered only), so we - // override these fields onto every variant. Without this, switching to - // a chain tab surfaces raw `{{best_name:chain:X}}` strings. - const variants: Record = Object.fromEntries( - variantList.map(([key, v]) => [ - key, - v === aggregate - ? v - : { - ...v, - findings: aggregate.findings, - faq: aggregate.faq, - seoIntro: aggregate.seoIntro, - abstract: aggregate.abstract, - methodology: aggregate.methodology, - perChainExplainer: aggregate.perChainExplainer, - bestPerChain: aggregate.bestPerChain, - worstPerChain: aggregate.worstPerChain, - }, - ]), - ); - const benchmark = variants[variantKey(chain, region, kind)] ?? aggregate; + // Variants (chain × region × kind) are NOT embedded anymore. The old + // pre-fetch awaited every variant (rpc-capabilities: 39 full provider + // loads) on EVERY ISR regeneration and shipped them all in the page + // payload — regenerations took 30-60 s, and any visitor landing on a + // blocking render path (post-deploy, cache eviction) ate that wait. + // BenchmarkBody now fetches a variant on demand from + // /api/bench/[slug]/variant when a tab is flipped (per-variant + // unstable_cache keeps that at one cheap Prom roundtrip per 60 s + // across all users), and renders the aggregate while it loads. + const all = await getBenchmarks(); + const variants: Record = { + [variantKey(chain, region, kind)]: aggregate, + [variantKey(null, null, null)]: aggregate, + }; + const benchmark = aggregate; const isDraft = benchmark.status === "draft"; const isAwaiting = isDraft && benchmark.editorialStatus === "live"; @@ -515,6 +452,11 @@ export default async function BenchmarkPage({ phrases land in static HTML for crawlers to index. */} {!isDraft && } + {/* Dimension benches (chains as filters, not rows) don't render + ChainHeadingsSummary, so the dedicated per-chain pages need + their own server-rendered discovery links here. */} + {!isDraft && } + {/* FAQ section - every question/answer mirrors a FAQPage JSON-LD entry above. Google requires the content to be visible on the page, so we render the same text here. */} @@ -616,6 +558,41 @@ function variantKey( return `${chain ?? "__none"}|${region ?? "__none"}|${kind ?? "__none"}`; } +/** Links to /benchmarks// pages for dimension-shaped + * benches. Row-shaped benches (l1-finality) already link their pages + * through the ChainHeadingsSummary headings, so this only renders + * chains that exist as dimension values, not as result rows. */ +function PerChainPagesNav({ benchmark }: { benchmark: Benchmark }) { + const resultSlugs = new Set(benchmark.results.map((r) => r.slug)); + const explainerSlugs = new Set( + (benchmark.perChainExplainer ?? []).map((e) => e.slug), + ); + const chains = (benchmark.dimensions?.chain ?? []).filter( + (c) => + c.value.toLowerCase() !== "all" && + explainerSlugs.has(c.value) && + !resultSlugs.has(c.value), + ); + if (chains.length === 0) return null; + return ( + + ); +} + function DraftNotice({ source }: { source: string }) { return (
diff --git a/src/app/benchmarks/[slug]/twitter-image.tsx b/src/app/benchmarks/[slug]/twitter-image.tsx index 3e2b3683..f0c5e96c 100644 --- a/src/app/benchmarks/[slug]/twitter-image.tsx +++ b/src/app/benchmarks/[slug]/twitter-image.tsx @@ -1,5 +1,5 @@ import { ImageResponse } from "next/og"; -import { getBenchmark, getBenchmarkSlugs } from "@/data/benchmarks"; +import { getBenchmark } from "@/data/benchmarks"; import { headlineSentence, leader } from "@/lib/citation"; import { fmtUnit } from "@/lib/format"; import { CATEGORY_COLOR } from "@/lib/category-colors"; @@ -10,9 +10,10 @@ export const alt = "OpenChainBench. Open benchmarks for crypto infrastructure"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; +// On demand only, same trap as opengraph-image.tsx: non-empty params +// here force build-time prerender of the [slug] page segment. export async function generateStaticParams() { - const slugs = await getBenchmarkSlugs(); - return slugs.map((slug) => ({ slug })); + return []; } // Mirror opengraph-image: emit one Twitter card per (slug, chain) combo diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 44a5e3e3..90b83294 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -92,6 +92,10 @@ const ORG_JSONLD = { sameAs: [ SITE.github, `https://x.com/${SITE.twitter.replace(/^@/, "")}`, + // Wikidata entity: anchors the OpenChainBench brand in the + // Knowledge Graph so the brand query resolves to this domain + // instead of the unrelated "OpenBench" homonyms. + "https://www.wikidata.org/wiki/Q140172649", ], }, { diff --git a/src/app/page.tsx b/src/app/page.tsx index d3755ff6..f9a260f0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -42,8 +42,9 @@ export default async function HomePage() { Open-source KPIs from onchain products.

- State of the art across the most challenging benchmarks for crypto - infrastructure, data providers, and bridge nodes. + OpenChainBench measures the state of the art across the most + challenging benchmarks for crypto infrastructure, data providers, + and bridge nodes.

({ slug })); +// Rendered ON DEMAND, same reasoning as the product page: prerendering +// ~200 OG images at build multiplies the full Prom load per build worker +// and was the last route standing in failed builds. +export async function generateStaticParams(): Promise<{ slug: string }[]> { + return []; } export default async function OG({ diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index bdbbe46f..7aa0ca0f 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import Link from "next/link"; import { ArrowLeft, ArrowUpRight } from "lucide-react"; -import { getProvider, getProviderSlugs } from "@/lib/providers"; +import { getProvider } from "@/lib/providers"; import { ProviderLogo } from "@/components/provider-logo"; import { CATEGORY_COLOR } from "@/lib/category-colors"; import { fmtUnit } from "@/lib/format"; @@ -16,11 +16,24 @@ import { safeJsonLd } from "@/lib/jsonld"; export const revalidate = 60; +// On-demand first render loads every bench (provider profile spans the +// whole catalog). 60s default killed cold renders and ISR regenerations +// mid-flight, freezing stale caches (observed on /benchmarks 2026-06-11: +// "Vercel Runtime Timeout Error: Task timed out after 60 seconds" on +// every regeneration, page stuck on build-time data for hours). +export const maxDuration = 300; + type Params = { slug: string }; -export async function generateStaticParams() { - const slugs = await getProviderSlugs(); - return slugs.map((slug) => ({ slug })); +// Rendered ON DEMAND (first request, then ISR-cached). Prerendering the +// ~200 product pages at build forced every build worker through the full +// multi-bench Prom load and blew the per-page budget once the HL bench +// grew past 60 providers (observed 2026-06-11: builds failing on +// /products/ after 240s). The empty params list keeps the route +// statically optimized; dynamicParams (default true) renders each slug +// on first hit, and the OG image route follows the same behavior. +export async function generateStaticParams(): Promise<{ slug: string }[]> { + return []; } export async function generateMetadata({ @@ -81,6 +94,142 @@ export default async function ProviderPage({ return a.benchmark.title.localeCompare(b.benchmark.title); }); + // Embeddable badge cards. Scope rules, most exact source first: + // + // 1. Benches with a `rank_matrix_query` AND region dimensions use the + // exact per-cell rankings (chain × region). The per-chain ranks the + // legacy path relies on are cross-region averages: a provider that + // only wins from Singapore (dRPC) still reads as the chain leader + // because one fast region drags the mean down. Cells fix that: + // - leads EVERY cell → one unscoped global badge. + // - leads a full chain row (all regions) → one "on " badge. + // - leads a full region column (all chains) → one "from " + // badge (skipped when its cells are already claimed by rows). + // - leftover isolated cells → "on from " badges. + // 2. Benches with chain dimensions but no cell data keep the per-chain + // logic: global badge only for a true cross-chain leader, otherwise + // one badge per chain led. + // 3. Benches without dimensions: one global badge per aggregate #1. + type BadgeCard = { + key: string; + title: string; + chain?: { value: string; label: string }; + region?: { value: string; label: string }; + benchSlug: string; + }; + const badgeCards: BadgeCard[] = []; + for (const a of sorted) { + const chainDims = (a.benchmark.chainDimensions ?? []).filter( + (c) => c.value !== "all", + ); + const regionDims = (a.benchmark.regionDimensions ?? []).filter( + (r) => r.value !== "all", + ); + const cellRanks = a.benchmark.cellRanks; + const me = a.result.slug.toLowerCase(); + const benchSlug = a.benchmark.slug; + const title = a.benchmark.title; + + let handledByCells = false; + if (cellRanks && regionDims.length > 0) { + const finestKeys = Object.keys(cellRanks).filter((k) => { + const [c, r] = k.split("|"); + const chainOk = chainDims.length > 0 ? c !== "all" : c === "all"; + return chainOk && r !== "all"; + }); + if (finestKeys.length > 0) { + handledByCells = true; + const wonKeys = new Set( + finestKeys.filter( + (k) => cellRanks[k][0]?.slug.toLowerCase() === me, + ), + ); + // Collapsed claims require FULL declared coverage, not just the + // cells that happen to have data this cycle. Without this, a + // degraded matrix (one surviving cell) would mint an unscoped + // global "#1" from a single win. + const expectedCells = + Math.max(chainDims.length, 1) * regionDims.length; + if ( + wonKeys.size === finestKeys.length && + finestKeys.length === expectedCells + ) { + badgeCards.push({ key: benchSlug, title, benchSlug }); + continue; + } + if (wonKeys.size === 0) continue; + const chainOf = (k: string) => k.split("|")[0]; + const regionOf = (k: string) => k.split("|")[1]; + const covered = new Set(); + for (const c of chainDims) { + const row = finestKeys.filter((k) => chainOf(k) === c.value); + // Row collapse only when every DECLARED region reported a cell + // for this chain and the provider won them all. + if (row.length !== regionDims.length || !row.every((k) => wonKeys.has(k))) continue; + badgeCards.push({ + key: `${benchSlug}-${c.value}`, + title, + chain: c, + benchSlug, + }); + for (const k of row) covered.add(k); + } + for (const r of regionDims) { + const col = finestKeys.filter((k) => regionOf(k) === r.value); + const expectedCols = Math.max(chainDims.length, 1); + if (col.length !== expectedCols || !col.every((k) => wonKeys.has(k))) continue; + if (col.every((k) => covered.has(k))) continue; + badgeCards.push({ + key: `${benchSlug}-r-${r.value}`, + title, + region: r, + benchSlug, + }); + for (const k of col) covered.add(k); + } + for (const k of wonKeys) { + if (covered.has(k)) continue; + const chain = chainDims.find((c) => c.value === chainOf(k)); + const region = regionDims.find((r) => r.value === regionOf(k)); + if (!region) continue; + badgeCards.push({ + key: `${benchSlug}-${chainOf(k)}-${regionOf(k)}`, + title, + ...(chain ? { chain } : {}), + region, + benchSlug, + }); + } + continue; + } + } + if (handledByCells) continue; + + const perChain = a.rankPerChain ?? {}; + const wonChains = chainDims.filter((c) => perChain[c.value]?.rank === 1); + const isGlobalNumberOne = a.rank === 1; + if (chainDims.length === 0) { + if (isGlobalNumberOne) { + badgeCards.push({ key: benchSlug, title, benchSlug }); + } + continue; + } + const leadsAllChains = + chainDims.length > 0 && wonChains.length === chainDims.length; + if (isGlobalNumberOne && leadsAllChains) { + badgeCards.push({ key: benchSlug, title, benchSlug }); + continue; + } + for (const c of wonChains) { + badgeCards.push({ + key: `${benchSlug}-${c.value}`, + title, + chain: c, + benchSlug, + }); + } + } + const url = `${SITE.url}/products/${p.slug}`; const sameAs: string[] = []; if (reg?.url) sameAs.push(reg.url); @@ -392,7 +541,7 @@ export default async function ProviderPage({ - {p.wins > 0 && ( + {badgeCards.length > 0 && (

Embeddable badges @@ -403,7 +552,7 @@ export default async function ProviderPage({ stays accurate without redeploying.

    - {sorted.filter((a) => a.rank === 1).map((a) => { + {badgeCards.map((card) => { // Absolute URL is the one shipped to embedders (it has to // work from any third-party origin), but the in-page preview // uses a relative path so it loads under the current @@ -411,25 +560,56 @@ export default async function ProviderPage({ // shows the browser's broken-image glyph on every non-prod // origin (staging Preview URLs, Vercel branch previews, etc.) // because the CSP refuses the cross-origin fetch. - const badgePath = `/api/badge/${a.benchmark.slug}/${p.slug}`; + const scopeParams = new URLSearchParams(); + if (card.chain) scopeParams.set("chain", card.chain.value); + if (card.region) scopeParams.set("region", card.region.value); + const qs = scopeParams.size > 0 ? `?${scopeParams.toString()}` : ""; + const badgePath = `/api/badge/${card.benchSlug}/${p.slug}${qs}`; const badgeUrl = `${SITE.url}${badgePath}`; - const targetUrl = `${SITE.url}/benchmarks/${a.benchmark.slug}`; - const html = `Ranked #1 on OpenChainBench: ${a.benchmark.title}`; + const targetUrl = `${SITE.url}/benchmarks/${card.benchSlug}${qs}`; + const scopeSuffix = `${card.chain ? ` on ${card.chain.label}` : ""}${card.region ? ` from ${card.region.label}` : ""}`; + const scopeLabels = [card.chain?.label, card.region?.label] + .filter(Boolean) + .join(" · "); + const cardTitle = scopeLabels + ? `${card.title} — ${scopeLabels}` + : card.title; + const altText = `Ranked #1 on OpenChainBench: ${card.title}${scopeSuffix}`; + const html = `${altText}`; + const markdown = `[![${altText}](${badgeUrl})](${targetUrl})`; + // Pre-baked X intent. Providers click → tweet draft opens + // with the ranking claim, the bench URL and the OCB handle + // already filled in. Removes the friction of writing the + // post themselves and gives us the canonical anchor text + // back as a tagged tweet on every share. + const tweetText = `Independently benchmarked #1 on ${card.title}${scopeSuffix} by @OpenChainBench.\n\nReproducible methodology, live data:`; + const tweetIntent = `https://x.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(targetUrl)}`; return ( -
  • +
  • - {a.benchmark.title} + {cardTitle}

    {/* eslint-disable-next-line @next/next/no-img-element */} {`Ranked
    +
    Copy HTML @@ -438,6 +618,14 @@ export default async function ProviderPage({ {html}
    +
    + + Copy Markdown + +
    +{markdown}
    +                    
    +
  • ); })} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index b5ac44ef..a7d9ecef 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -109,16 +109,15 @@ export default async function sitemap(): Promise { { url: `${SITE.url}/press`, lastModified: pageMtime("press/page.tsx"), changeFrequency: "monthly", priority: 0.4 }, ]; - // Bench routes. The hub URL (no query string) is the canonical entry - // and ranks highest. Per-chain variants (`?chain=X`) are emitted as - // secondary URLs so Google's crawler discovers the chain-honest - // metadata / OG card pairs for each filter. We skip the "all" sentinel - // (which maps to the canonical hub) and any chain dimension whose - // value would collide with the hub after URL-encoding. Each variant - // shares the parent bench's `lastModified` because the chain filter - // doesn't change the underlying scrape cadence — they all refresh as - // a single Prom poll. Priority is dropped one tier on variants so - // Search Console reads the hub as the head of the cluster. + // Bench routes. The hub URL is the canonical entry and ranks highest. + // `?chain=X` query variants are deliberately NOT emitted: those URLs + // declare a canonical pointing at the unfiltered hub, so listing them + // told Google to index pages that self-identify as duplicates (GSC + // filed them under "Duplicate, Google chose different canonical"). + // The indexable per-chain surface is the dedicated route + // `/benchmarks//`, generated only for chains that carry a + // hand-written `per_chain_explainer` entry (unique editorial content, + // self-canonical). See src/app/benchmarks/[slug]/[chain]/page.tsx. const benchmarkRoutes: MetadataRoute.Sitemap = benchmarks.flatMap((b) => { const last = b.lastRunAt ? new Date(b.lastRunAt) : BUILD_TIME; const entries: MetadataRoute.Sitemap = [ @@ -129,15 +128,19 @@ export default async function sitemap(): Promise { priority: 0.95, }, ]; - const chains = (b.dimensions?.chain ?? []).filter( - (c) => c.value && c.value.toLowerCase() !== "all", + const resultSlugs = new Set(b.results.map((r) => r.slug)); + const chainValues = new Set( + (b.dimensions?.chain ?? []) + .map((c) => c.value) + .filter((v) => v.toLowerCase() !== "all"), ); - for (const c of chains) { + for (const e of b.perChainExplainer ?? []) { + if (!resultSlugs.has(e.slug) && !chainValues.has(e.slug)) continue; entries.push({ - url: `${SITE.url}/benchmarks/${b.slug}?chain=${encodeURIComponent(c.value)}`, + url: `${SITE.url}/benchmarks/${b.slug}/${e.slug}`, lastModified: last, changeFrequency: "hourly", - priority: 0.8, + priority: 0.85, }); } return entries; diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index 761a87fd..1d2fa837 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -188,10 +188,42 @@ export function BenchmarkBody({ const effectiveChain = chainOptions.length > 0 ? (chain ?? fallbackChain) : null; const effectiveRegion = regionOptions.length > 0 ? (region ?? fallbackRegion) : null; const effectiveKind = kindOptions.length > 0 ? (kind ?? fallbackKind) : null; - const benchmark = - variants[variantKey(effectiveChain, effectiveRegion, effectiveKind)] ?? - variants[variantKey(null, null, null)] ?? - Object.values(variants)[0]; + + // The page ships ONLY the aggregate view (embedding every variant made + // ISR regenerations take 30-60 s). Filtered variants are fetched here + // on demand; while one loads, the aggregate keeps rendering so the tab + // flip never blanks the page. Failed fetches keep the aggregate (the + // tab still works, numbers stay cross-dimension) and may retry on the + // next flip. + const [variantMap, setVariantMap] = useState>(variants); + const activeKey = variantKey(effectiveChain, effectiveRegion, effectiveKind); + const aggregateBench = + variants[variantKey(null, null, null)] ?? Object.values(variants)[0]; + useEffect(() => { + if (variantMap[activeKey] || !aggregateBench) return; + const isAll = (v: string | null) => !v || v === "all"; + if (isAll(effectiveChain) && isAll(effectiveRegion) && isAll(effectiveKind)) { + setVariantMap((m) => ({ ...m, [activeKey]: aggregateBench })); + return; + } + const qs = new URLSearchParams(); + if (!isAll(effectiveChain)) qs.set("chain", effectiveChain!); + if (!isAll(effectiveRegion)) qs.set("region", effectiveRegion!); + if (!isAll(effectiveKind)) qs.set("kind", effectiveKind!); + let cancelled = false; + fetch(`/api/bench/${aggregateBench.slug}/variant?${qs.toString()}`) + .then((r) => (r.ok ? r.json() : null)) + .then((v: Benchmark | null) => { + if (!cancelled && v) setVariantMap((m) => ({ ...m, [activeKey]: v })); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeKey]); + + const benchmark = variantMap[activeKey] ?? aggregateBench; if (!benchmark) return null; // L1/L2 layer counts. When both > 0 the bench mixes L1 and L2 chains @@ -309,7 +341,7 @@ export function BenchmarkBody({ .map((o) => [ o.value, summarize( - variants[variantKey(effectiveChain, effectiveRegion, o.value)], + variantMap[variantKey(effectiveChain, effectiveRegion, o.value)], ), ]) .filter(([, v]) => v !== null) as [string, ChainMeta][] @@ -326,7 +358,7 @@ export function BenchmarkBody({ chainOptions .map((o) => [ o.value, - summarize(variants[variantKey(o.value, effectiveRegion, effectiveKind)]), + summarize(variantMap[variantKey(o.value, effectiveRegion, effectiveKind)]), ]) .filter(([, v]) => v !== null) as [string, ChainMeta][] )} @@ -342,7 +374,7 @@ export function BenchmarkBody({ regionOptions .map((o) => [ o.value, - summarize(variants[variantKey(effectiveChain, o.value, effectiveKind)]), + summarize(variantMap[variantKey(effectiveChain, o.value, effectiveKind)]), ]) .filter(([, v]) => v !== null) as [string, ChainMeta][] )} @@ -446,14 +478,19 @@ export function BenchmarkBody({ )} {view === "timeseries" && ( <> - {benchmark.metricPanels && benchmark.metricPanels.length > 0 && ( - - )} + {(() => { + const tabPanels = (benchmark.metricPanels ?? []).filter( + (p) => p.tab !== false, + ); + return tabPanels.length > 0 ? ( + + ) : null; + })()}

diff --git a/src/components/chain-headings-summary.tsx b/src/components/chain-headings-summary.tsx index 9c348ca0..75f7c6c2 100644 --- a/src/components/chain-headings-summary.tsx +++ b/src/components/chain-headings-summary.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import type { Benchmark } from "@/types/benchmark"; import { liveResults } from "@/lib/provider-filters"; import { fmtUnit } from "@/lib/format"; @@ -69,7 +70,19 @@ export function ChainHeadingsSummary({ benchmark }: { benchmark: Benchmark }) { return (

- {heading} + {/* Chains with an explainer have a dedicated landing page + (/benchmarks//); the heading links there so + crawlers discover the per-chain documents from the hub. */} + {explainer ? ( + + {heading} + + ) : ( + heading + )}

diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index a6821a3c..3e44069e 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -1,9 +1,14 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; -import type { Benchmark, MetricPanel, ProviderResult } from "@/types/benchmark"; +import type { + Benchmark, + LedgerColumn, + MetricPanel, + ProviderResult, +} from "@/types/benchmark"; import { ChainCoverageChip } from "@/components/chain-coverage-chip"; import { Hint } from "@/components/hint"; import { Sparkline } from "@/components/sparkline"; @@ -34,19 +39,67 @@ export function LedgerTable({ benchmark, activePanel, topN }: Props) { const { results, extras } = benchmark; const unit = activePanel?.unit ?? benchmark.unit; const higherIsBetter = activePanel?.higherIsBetter ?? benchmark.higherIsBetter; - // Single source of value per row — `r.ms.p50` for the headline metric, - // `panel.values[slug]` when a panel tab is active. Used for sort, - // filter, the inline data bar, and the displayed value in the p50 - // column. - const pickValue = (r: ProviderResult): number => - activePanel ? (activePanel.values[r.slug] ?? 0) : r.ms.p50; const panelActive = !!activePanel; - const secondary = results[0]?.secondary?.label; + // Custom column mode: benches that repurpose the p50/p90/p99/mean slots + // (USD revenue leaderboards) declare ledger_columns in their YAML so + // every column carries an honest label + unit, and panel-backed columns + // (e.g. unique users) surface inline instead of behind a tab click. + // Disabled while a panel tab is active — the panel sort already owns + // the table and the aggregate columns are dashed out. + const customCols = !panelActive ? benchmark.ledgerColumns : undefined; + const panelById = useMemo( + () => new Map((benchmark.metricPanels ?? []).map((p) => [p.id, p])), + [benchmark.metricPanels], + ); + const secondary = customCols ? undefined : results[0]?.secondary?.label; + + // Resolve one custom column's value for a row. Slot columns read the + // repurposed headline slots; panel columns read the panel's values map + // (null when the provider returned no data for that metric this cycle). + const colValue = (r: ProviderResult, col: LedgerColumn): number | null => { + if (col.slot) return r.ms[col.slot]; + const v = panelById.get(col.panel ?? "")?.values[r.slug]; + return v != null && Number.isFinite(v) ? v : null; + }; + const colUnit = (col: LedgerColumn): string => + col.unit ?? + (col.panel ? (panelById.get(col.panel)?.unit ?? unit) : unit); + + // Timeframe toggle. Columns that declare `windows` (7d/30d panel-id + // sources) flip to the selected window's values; columns without keep + // their 24h figure and the header says so. Rendered only when at least + // one column declares windows. + const [windowKey, setWindowKey] = useState<"24h" | "7d" | "30d">("24h"); + const hasWindows = !!customCols?.some( + (c) => c.windows && Object.keys(c.windows).length > 0, + ); + const colValueW = (r: ProviderResult, col: LedgerColumn): number | null => { + if (windowKey !== "24h" && col.windows?.[windowKey]) { + const v = panelById.get(col.windows[windowKey])?.values[r.slug]; + return v != null && Number.isFinite(v) ? v : null; + } + return colValue(r, col); + }; + const colLabel = (col: LedgerColumn): string => { + if (!hasWindows) return col.label; + const w = windowKey !== "24h" && col.windows?.[windowKey] ? windowKey : "24h"; + return `${col.label} (${w})`; + }; + + // Single source of value per row — headline slot p50, the active + // window's first custom column, or `panel.values[slug]` when a panel + // tab is active. Used for sort, filter, the inline data bar, and the + // displayed value in the headline column. + const pickValue = (r: ProviderResult): number => { + if (activePanel) return activePanel.values[r.slug] ?? 0; + if (customCols) return colValueW(r, customCols[0]) ?? 0; + return r.ms.p50; + }; // Detected from the first provider's results — if ANY provider declares // slot_p50/slot_p99 in its YAML queries, every row gets the column (with // "-" for providers that don't declare it). Used by Solana-native benches // where slot_delta is the canonical metric and ms is wall-clock derived. - const hasSlots = results.some((r) => r.slots != null); + const hasSlots = !customCols && results.some((r) => r.slots != null); // Drop unscored providers (availability=unavailable AND p50=0). They // stay in the underlying spec so /products/ pages still resolve // and SEO coverage holds, but they're noise in a "ranked by performance" @@ -103,6 +156,28 @@ export function LedgerTable({ benchmark, activePanel, topN }: Props) { return (

+ {hasWindows && ( +
+ + Timeframe + + {(["24h", "7d", "30d"] as const).map((w) => ( + + ))} +
+ )} @@ -110,12 +185,18 @@ export function LedgerTable({ benchmark, activePanel, topN }: Props) { Product + - @@ -138,10 +219,28 @@ export function LedgerTable({ benchmark, activePanel, topN }: Props) { - - - - + {customCols ? ( + customCols.map((c, idx) => ( + + )) + ) : panelActive ? ( + // Panel sort owns the table: a single honest "Value" column + // instead of p50/p90/p99/Mean headers over dashed-out cells + // (a USD volume sort labeled "p50" reads as a bug). + + ) : ( + <> + + + + + + )} @@ -150,7 +249,11 @@ export function LedgerTable({ benchmark, activePanel, topN }: Props) { @@ -168,6 +271,10 @@ export function LedgerTable({ benchmark, activePanel, topN }: Props) { panelActive={panelActive} hasSecondary={!!secondary} hasSlots={hasSlots} + customCells={customCols?.map((c) => ({ + v: colValueW(r, c), + unit: colUnit(c), + }))} series={ activePanel ? (activePanel.seriesByProvider?.[r.slug] ?? []) @@ -195,6 +302,7 @@ function Row({ panelActive, hasSecondary, hasSlots, + customCells, series, sparkMin, sparkMax, @@ -210,6 +318,9 @@ function Row({ panelActive: boolean; hasSecondary: boolean; hasSlots: boolean; + /** 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[]; sparkMin: number; sparkMax: number; @@ -294,14 +405,18 @@ function Row({ {isOffline ? ( ) : ( <> - {/* p50 with inline data bar */} + {/* Headline column with inline data bar */} - - - + {customCells ? ( + customCells.slice(1).map((c, idx) => ( + + )) + ) : panelActive ? null : ( + <> + + + + + )} diff --git a/src/components/time-series-chart.tsx b/src/components/time-series-chart.tsx index 86a08650..bda16838 100644 --- a/src/components/time-series-chart.tsx +++ b/src/components/time-series-chart.tsx @@ -1275,6 +1275,12 @@ function fmtTick(v: number, unit: string) { if (abs >= 1e3) return `${(v / 1e3).toFixed(1)}K`; return `${Math.round(v)}`; } + if (unit === "sec") { + if (v >= 86400) return `${(v / 86400).toFixed(1)}d`; + if (v >= 3600) return `${(v / 3600).toFixed(1)}h`; + if (v >= 60) return `${(v / 60).toFixed(0)}m`; + return `${v.toFixed(v >= 10 ? 0 : 1)}s`; + } if (unit === "s") { const s = v / 1000; if (s >= 60) return `${(s / 60).toFixed(0)}m`; diff --git a/src/lib/citation.ts b/src/lib/citation.ts index 63d22db8..fc22fd75 100644 --- a/src/lib/citation.ts +++ b/src/lib/citation.ts @@ -30,12 +30,21 @@ export function leader(b: Benchmark): { name: string; slug: string; value: numbe return { name: sorted[0].name, slug: sorted[0].slug, value: sorted[0].ms.p50 }; } +/** Honest window wording per unit. "(p50, 24h)" is only true for latency + * style benches; USD revenue and count benches repurpose the p50 slot as + * a plain rolling-window figure and percentile wording would mislead. */ +export function windowSuffix(unit: string): string { + if (unit === "usd" || unit === "count") return "(24h)"; + if (unit === "pct" || unit === "bps") return "(24h avg)"; + return "(p50, 24h)"; +} + /** Short factual sentence ready to paste into an article. Templated, no LLM. */ export function headlineSentence(b: Benchmark): string { const top = leader(b); if (!top) return `${b.title}. Awaiting first run.`; const value = fmtUnit(top.value, b.unit); - return `${top.name} leads ${b.metric.toLowerCase()} at ${value} (p50, 24h) on ${b.title}.`; + return `${top.name} leads ${b.metric.toLowerCase()} at ${value} ${windowSuffix(b.unit)} on ${b.title}.`; } /** Pasteable attribution string. Standard convention: " Source: OpenChainBench (url)". */ diff --git a/src/lib/format.ts b/src/lib/format.ts index 38674b6c..553a4337 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -5,6 +5,16 @@ export function fmtUnit(value: number, unit: string) { // Legacy: bps stored. Convert to percent for display. return formatPercent(value / 100); } + if (unit === "sec") { + // True seconds (unlike "s", whose input is ms by latency-bench + // convention). Used by gauges like hl_*_last_fill_age_seconds. + const s = value; + if (s >= 172800) return `${(s / 86400).toFixed(1)} d`; + if (s >= 3600) return `${(s / 3600).toFixed(s >= 36000 ? 0 : 1)} h`; + if (s >= 60) return `${(s / 60).toFixed(1)} min`; + if (s > 0 && s < 0.1) return "<0.1 s"; + return `${s.toFixed(1)} s`; + } if (unit === "s") { const ms = value; const s = ms / 1000; @@ -50,7 +60,7 @@ export function fmtUnit(value: number, unit: string) { if (abs < 0.001) return `$${value.toFixed(6)}`; if (abs < 0.01) return `$${value.toFixed(5)}`; if (abs < 1) return `$${value.toFixed(4)}`; - if (abs < 1000) return `$${value.toLocaleString(undefined, { maximumFractionDigits: 2 })}`; + if (abs < 1000) return `$${value.toLocaleString("en-US", { maximumFractionDigits: 2 })}`; return `$${formatCompactCount(value)}`; } if (value >= 1000) return `${(value / 1000).toFixed(2)} s`; @@ -69,6 +79,14 @@ export function fmtUnit(value: number, unit: string) { */ export function unitSuffix(unit: string, value?: number): string { if (unit === "pct" || unit === "bps") return " %"; + if (unit === "sec") { + if (value !== undefined && Number.isFinite(value)) { + if (value >= 172800) return " d"; + if (value >= 3600) return " h"; + if (value >= 60) return " min"; + } + return " s"; + } if (unit === "s") { // Mirror fmtUnit: ms → s, with auto-flip to minutes at 60s. if (value !== undefined && Number.isFinite(value)) { @@ -88,7 +106,7 @@ export function unitSuffix(unit: string, value?: number): string { export function fmtValue(value: number, unit: string): string { // Keep K/M/B suffixes and $ prefix — they are part of the number, not a // unit. Only strip trailing unit words that the caller renders separately. - return fmtUnit(value, unit).replace(/\s+(ms|s|min|slots?)$/, "").replace(/\s*%$/, ""); + return fmtUnit(value, unit).replace(/\s+(ms|s|min|h|d|slots?)$/, "").replace(/\s*%$/, ""); } /** Compact short-form for large counts so the home table's narrow value @@ -102,7 +120,9 @@ function formatCompactCount(value: number): string { if (abs >= 1e9) return `${(value / 1e9).toFixed(2)}B`; if (abs >= 1e6) return `${(value / 1e6).toFixed(2)}M`; if (abs >= 1e4) return `${(value / 1e3).toFixed(1)}K`; - return value.toLocaleString(); + // Locale pinned: rendered both server-side and client-side ("use client" + // ledger). Browser-default locale produced "5 560,409" on fr-FR machines. + return value.toLocaleString("en-US", { maximumFractionDigits: 2 }); } /** Smart-precision percent formatter. picks decimals based on magnitude diff --git a/src/lib/prometheus.ts b/src/lib/prometheus.ts index 92afbd02..7ee16863 100644 --- a/src/lib/prometheus.ts +++ b/src/lib/prometheus.ts @@ -100,13 +100,15 @@ export class Prometheus { async scalar(promql: string): Promise { try { const res = await this.query(promql); + // 6 significant digits, same rationale as series(): full-precision + // tails bloat cached Benchmark objects toward the 2MB cache limit. if (res.resultType === "scalar") { const v = Number(res.result[1]); - return Number.isFinite(v) ? v : null; + return Number.isFinite(v) ? (v === 0 ? 0 : Number(v.toPrecision(6))) : null; } if (res.resultType === "vector" && res.result.length > 0) { const v = Number(res.result[0].value[1]); - return Number.isFinite(v) ? v : null; + return Number.isFinite(v) ? (v === 0 ? 0 : Number(v.toPrecision(6))) : null; } return null; // legitimately empty - not an error, not logged } catch (err) { @@ -147,9 +149,17 @@ export class Prometheus { } } - // Average values per timestamp, ordered chronologically. + // 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]) => vs.reduce((s, v) => s + v, 0) / vs.length); + 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; } catch { return null; @@ -163,6 +173,13 @@ export class Prometheus { // / metadata / ULA / CGNAT. await assertPublicHost(url); + // Global concurrency cap. At cold start / build every bench loads at + // once, which used to fire hundreds of 24h-window quantile queries + // within seconds and brown out the single Prom instance (providers + // timing out → partial leaderboards). Queueing here keeps the burst + // at a level Prom absorbs; total wall time barely moves because Prom + // was serializing on CPU anyway. + await acquireQuerySlot(); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); try { @@ -187,10 +204,42 @@ export class Prometheus { return json.data; } finally { clearTimeout(timeout); + releaseQuerySlot(); } } } +/** Module-level semaphore for fetchEnvelope. + * + * Sizing matters more than it looks: at 8 slots a bench page that + * pre-fetches its chain × region variants (~1500 queries on + * rpc-capabilities) serializes past the Vercel function timeout, the + * render dies every cycle and unstable_cache freezes the site on the + * last value (observed 2026-06-10: staging stuck for 2h on the deploy + * snapshot). 64 keeps burst pressure bounded (Prom's own + * query.max-concurrency queues the rest) without starving large loads. + * Revisit downward once the heavy benches read precomputed ocb:* + * recording rules instead of raw 24h-window quantiles. */ +const MAX_CONCURRENT_QUERIES = 64; +let activeQueries = 0; +const queryWaiters: (() => void)[] = []; + +function acquireQuerySlot(): Promise { + if (activeQueries < MAX_CONCURRENT_QUERIES) { + activeQueries++; + return Promise.resolve(); + } + return new Promise((resolve) => queryWaiters.push(resolve)); +} + +function releaseQuerySlot(): void { + const next = queryWaiters.shift(); + // Hand the slot directly to the next waiter (activeQueries unchanged) + // or free it when the queue is empty. + if (next) next(); + else activeQueries--; +} + /** PromQL built-in functions and keywords we should skip when scanning * for the first raw metric name in a query string. Updated from the * Prometheus 2.49 function reference - any identifier here is guaranteed @@ -319,6 +368,12 @@ async function dnsLookupWithTimeout(host: string): Promise<{ address: string }[] type HostCheckEntry = { ok: boolean; reason?: string; expiresAt: number }; const HOST_CHECK_TTL_MS = 60_000; const hostCheckCache = new Map(); +// Single-flight guard. When the 60s entry expires mid-burst, every query +// in flight (up to the full concurrency cap) used to fire its OWN +// dns.lookup for the same hostname simultaneously; under build/regen load +// that herd is what produced the "dns: timeout resolving " +// failure storms. First caller does the lookup, the rest await it. +const hostCheckInFlight = new Map>(); async function assertPublicHost(url: URL): Promise { // hostname keeps brackets for IPv6 literals; strip them so isIP can @@ -333,6 +388,16 @@ async function assertPublicHost(url: URL): Promise { return; } + const inFlight = hostCheckInFlight.get(host); + if (inFlight) return inFlight; + const check = doAssertPublicHost(host, now).finally(() => { + hostCheckInFlight.delete(host); + }); + hostCheckInFlight.set(host, check); + return check; +} + +async function doAssertPublicHost(host: string, now: number): Promise { try { if (isIP(host)) { if (isPrivateAddress(host)) { diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 461a27b3..e528c2c2 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -106,6 +106,15 @@ export type ProviderAppearance = { * can compute per-chain rank for this provider without a full bench * re-fetch. */ bestPerChain?: Record; + /** Region dimension values from the spec, when present. Mirrors + * `benchmark.dimensions.region`. */ + regionDimensions?: { value: string; label: string }[]; + /** Exact per-cell rankings from the bench's `rank_matrix_query` + * (key = `|`, "all" for an undeclared dimension or a + * derived marginal). When present this is the authoritative source + * for scoped leadership claims — per-chain ranks built from + * cross-region averages hide region-restricted leaders. */ + cellRanks?: Benchmark["cellRanks"]; }; result: ProviderResult; rank: number; @@ -279,6 +288,8 @@ export const getProviders = cache(async (): Promise => { lastRunAt: b.lastRunAt, chainDimensions: b.dimensions?.chain, bestPerChain: benchBestPerChain, + regionDimensions: b.dimensions?.region, + cellRanks: b.cellRanks, }, result: r, rank: isRanked ? (idx as number) + 1 : 0, diff --git a/src/lib/snapshot.ts b/src/lib/snapshot.ts index 5f5b4746..bb8ffe9b 100644 --- a/src/lib/snapshot.ts +++ b/src/lib/snapshot.ts @@ -32,8 +32,15 @@ * outage. */ +import { after } from "next/server"; import { z } from "zod"; -import type { Benchmark, ProviderResult, ResultExtras } from "@/types/benchmark"; +import type { + Benchmark, + CellRankEntry, + MetricPanel, + ProviderResult, + ResultExtras, +} from "@/types/benchmark"; /** Refuse snapshots older than this on read. 24 h matches the bench * query window — a value older than that isn't meaningful as the @@ -60,6 +67,8 @@ const SnapshotSchema = z.object({ bestPerChain: z.record(z.string(), z.any()).optional(), worstPerChain: z.record(z.string(), z.any()).optional(), providersPerChain: z.record(z.string(), z.array(z.string())).optional(), + cellRanks: z.record(z.string(), z.any()).optional(), + metricPanels: z.array(z.any()).optional(), }); export type SnapshotPayload = { @@ -70,6 +79,11 @@ export type SnapshotPayload = { bestPerChain?: Record; worstPerChain?: Record; providersPerChain?: Record; + cellRanks?: Record; + /** Panel values (series stripped to keep the KV value small). Without + * these a snapshot-served bench loses its chart view tabs and every + * panel-backed ledger column renders "-". */ + metricPanels?: MetricPanel[]; }; function isConfigured(): boolean { @@ -87,35 +101,97 @@ function authHeader(): { Authorization: string } { return { Authorization: `Bearer ${process.env.KV_REST_API_TOKEN}` }; } +/** A fresher snapshot with MORE providers wins over a new render with + * fewer. Window after which a bigger-but-aging snapshot stops blocking + * writes, so a legitimately shrunk provider field (YAML removal, a + * source dead for hours) can still refresh the snapshot. */ +const SNAPSHOT_GUARD_WINDOW_MS = 2 * 60 * 60 * 1000; + /** * Best-effort save. Never throws, never blocks. Call without `await` * from inside a successful Prom path: * * writeSnapshot(spec.slug, { results, extras, sampleSize, lastRunAt }); * - * Returns immediately; the network call resolves in the background. + * Returns immediately; the work resolves in the background. + * + * Degradation guards (both motivated by Prom brownouts, where a cycle + * can pass the 50% quorum yet still be worse than the snapshot it would + * replace): + * - Coverage ratchet: if the existing snapshot is recent (< 2h) and + * has MORE providers than this render, skip the write entirely. + * Without this, repeated brownouts walk the snapshot down to the + * quorum floor and a cold start then serves the degraded board. + * - Stash carry-over: per-chain / per-cell stashes (bestPerChain, + * cellRanks, ...) are computed by separate Prom queries that can + * fail on an otherwise-healthy cycle. When the new render lacks one + * and the existing snapshot has it, carry the old value forward so + * scoped badges / placeholders don't 404 after a cold start. */ export function writeSnapshot(slug: string, payload: SnapshotPayload): void { if (!isConfigured()) return; - const body = JSON.stringify({ - _v: SCHEMA_VERSION, - savedAt: Date.now(), - ...payload, - }); - // Upstash REST: POST /set/{key} with raw body = value. - fetch(`${kvUrl()}/set/${encodeURIComponent(KEY_PREFIX + slug)}`, { - method: "POST", - headers: { ...authHeader(), "Content-Type": "application/json" }, - body, - // Server-side fetch is fine without keepalive; no browser limits. - cache: "no-store", - }).catch((err) => { - console.warn( - `snapshot.write failed for ${slug}: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - }); + const work = (async () => { + try { + const existing = await readSnapshotWithAge(slug); + // KV unreachable (as opposed to "no snapshot yet"): skip the write + // entirely. A degraded render slipping past the guards precisely + // when the infra is under stress is the scenario the guards exist + // for, and a healthy cycle lands 60s later anyway. + if (existing === "error") { + console.warn(`snapshot.write skipped for ${slug}: KV read failed`); + return; + } + const merged: SnapshotPayload = { ...payload }; + if (existing) { + // Compare actual live coverage, not array length: the rendered + // bench pads `results` with an "unavailable" row for every + // declared provider, so lengths are always equal by construction. + const liveCount = (rs: ProviderResult[]) => + rs.filter((r) => r.availability !== "unavailable" && r.ms.p50 > 0) + .length; + const newLive = liveCount(payload.results); + const oldLive = liveCount(existing.payload.results); + if (existing.ageMs < SNAPSHOT_GUARD_WINDOW_MS && oldLive > newLive) { + console.warn( + `snapshot.write skipped for ${slug}: render has ${newLive} live providers, snapshot has ${oldLive}`, + ); + return; + } + merged.bestPerChain ??= existing.payload.bestPerChain; + merged.worstPerChain ??= existing.payload.worstPerChain; + merged.providersPerChain ??= existing.payload.providersPerChain; + merged.cellRanks ??= existing.payload.cellRanks; + merged.metricPanels ??= existing.payload.metricPanels; + } + const body = JSON.stringify({ + _v: SCHEMA_VERSION, + savedAt: Date.now(), + ...merged, + }); + // Upstash REST: POST /set/{key} with raw body = value. + await fetch(`${kvUrl()}/set/${encodeURIComponent(KEY_PREFIX + slug)}`, { + method: "POST", + headers: { ...authHeader(), "Content-Type": "application/json" }, + body, + cache: "no-store", + }); + } catch (err) { + console.warn( + `snapshot.write failed for ${slug}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + })(); + // Vercel can freeze the lambda as soon as the response is sent; an + // unawaited promise then silently never completes. after() keeps the + // function alive until the write lands. Falls back to fire-and-forget + // outside a request scope (build-time prerender, tests). + try { + after(work); + } catch { + void work; + } } /** @@ -132,6 +208,18 @@ export function writeSnapshot(slug: string, payload: SnapshotPayload): void { export async function readSnapshot( slug: string, ): Promise { + const hit = await readSnapshotWithAge(slug); + return hit && hit !== "error" ? hit.payload : null; +} + +/** Same as readSnapshot but exposes the snapshot's age. Used by the + * write-path degradation guards, which need to distinguish "no usable + * snapshot exists" (null → write proceeds) from "KV is unreachable" + * ("error" → write is skipped so a degraded render can't slip past the + * guards while the infra is down). */ +async function readSnapshotWithAge( + slug: string, +): Promise<{ payload: SnapshotPayload; ageMs: number } | null | "error"> { if (!isConfigured()) return null; try { const res = await fetch( @@ -147,7 +235,7 @@ export async function readSnapshot( ); if (!res.ok) { console.warn(`[DRAFT-TRACE] kv_http slug=${slug} status=${res.status}`); - return null; + return "error"; } const env = (await res.json()) as { result?: string | null }; if (!env.result) { @@ -170,19 +258,26 @@ export async function readSnapshot( return null; } return { - results: parsed.data.results as ProviderResult[], - extras: parsed.data.extras as ResultExtras, - sampleSize: parsed.data.sampleSize, - lastRunAt: parsed.data.lastRunAt, - bestPerChain: parsed.data.bestPerChain as - | Record - | undefined, - worstPerChain: parsed.data.worstPerChain as - | Record - | undefined, - providersPerChain: parsed.data.providersPerChain as - | Record - | undefined, + ageMs: age, + payload: { + results: parsed.data.results as ProviderResult[], + extras: parsed.data.extras as ResultExtras, + sampleSize: parsed.data.sampleSize, + lastRunAt: parsed.data.lastRunAt, + bestPerChain: parsed.data.bestPerChain as + | Record + | undefined, + worstPerChain: parsed.data.worstPerChain as + | Record + | undefined, + providersPerChain: parsed.data.providersPerChain as + | Record + | undefined, + cellRanks: parsed.data.cellRanks as + | Record + | undefined, + metricPanels: parsed.data.metricPanels as MetricPanel[] | undefined, + }, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -192,7 +287,7 @@ export async function readSnapshot( ? "kv_timeout" : "kv_neterr"; console.warn(`[DRAFT-TRACE] ${tag} slug=${slug} err=${msg}`); - return null; + return "error"; } } @@ -215,5 +310,14 @@ export function snapshotFromBenchmark(b: Benchmark): SnapshotPayload { worstPerChain: b.worstPerChain, providersPerChain: (b as { providersPerChain?: Record }) .providersPerChain, + cellRanks: b.cellRanks, + // Series stripped: 11 panels x 75 providers x 3 series would multiply + // the KV value size; values + labels are what the tabs strip and the + // panel-backed ledger columns need to survive a snapshot-served render. + metricPanels: b.metricPanels?.map( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ({ seriesByProvider, seriesByProvider7d, seriesByProvider30d, ...rest }) => + rest, + ), }; } diff --git a/src/lib/spec-schema.ts b/src/lib/spec-schema.ts index 611247f5..94243404 100644 --- a/src/lib/spec-schema.ts +++ b/src/lib/spec-schema.ts @@ -238,7 +238,7 @@ export const SpecSchema = z /* Metric */ metric: z.string().min(1).max(100), /** ms / s for latencies; pct for fees as percent of notional; bps for basis points; slots for Solana slot delta. */ - unit: z.enum(["ms", "s", "pct", "bps", "count", "slots", "usd"]), + unit: z.enum(["ms", "s", "sec", "pct", "bps", "count", "slots", "usd"]), /** True when bigger numbers are better (coverage, count). Default false: * latency, fees, drift. every existing bench is "lower is better". */ higher_is_better: z.boolean().default(false), @@ -278,6 +278,17 @@ export const SpecSchema = z * so one missed cycle is tolerated, two missed cycles fire. */ expected_freshness_seconds: z.number().int().positive().optional(), + /** + * Raw harness metric to probe for data freshness (lastRunAt). + * REQUIRED in practice for benches whose queries read ocb:* + * recorded series: recording rules keep emitting from their 24h + * window long after a harness dies, so probing the recorded + * series would mask the outage. + */ + freshness_metric: z + .string() + .regex(/^[a-zA-Z_:][a-zA-Z0-9_:]*$/, "Must be a bare metric name") + .optional(), }) .optional(), @@ -318,6 +329,22 @@ export const SpecSchema = z }) .optional(), + /* Optional single PromQL returning one instant sample per + * (provider[, chain][, region]) — e.g. + * avg by (provider, chain, region) (quantile_over_time(0.50, m[24h])) + * Powers exact per-cell rankings (badge scoping, leadership claims) + * in ONE Prom roundtrip instead of a chains × regions query fan-out. + * Label values must match provider slugs / dimension values. */ + rank_matrix_query: z + .string() + .min(1) + .max(2000) + .refine( + (q) => q.includes("provider"), + "rank_matrix_query must group by the provider label" + ) + .optional(), + providers: z.array(provider).min(1), /** @@ -342,14 +369,108 @@ export const SpecSchema = z /** The PromQL label that holds each provider's slug. Defaults to * "builder"; other benches may use "provider", "venue", etc. */ label_key: z.string().min(1).max(40).default("builder"), - unit: z.enum(["ms", "s", "pct", "bps", "count", "slots", "usd"]), + unit: z.enum(["ms", "s", "sec", "pct", "bps", "count", "slots", "usd"]), higher_is_better: z.boolean().default(false), + /** When false the panel is data-only: it is loaded and can feed + * ledger_columns window variants, but renders no chart tab. + * Used for 7d/30d gauges that power the ledger's timeframe + * toggle without duplicating the headline metric as tabs. */ + tab: z.boolean().default(true), }) ) - .max(8) + // Cap covers visible tabs plus data-only window panels (revenue, + // volume and users each carry a 7d + 30d variant on the HL bench). + .max(12) + .optional(), + + /** + * Optional relabeling of the ledger's aggregate columns. For benches + * whose unit has no percentile semantics (USD revenue leaderboards), + * the p50/p90/p99/mean slots are repurposed; declaring ledger_columns + * renders each column with an honest label and unit instead of the + * default latency headers. `slot` reads the provider's headline slot, + * `panel` reads the values of a metric_panels entry by id. The first + * column is the headline (sort key, data bar, mobile column). + */ + ledger_columns: z + .array( + z + .object({ + label: z.string().min(1).max(28), + slot: z.enum(["p50", "p90", "p99", "mean"]).optional(), + panel: z.string().min(1).max(40).optional(), + unit: z + .enum(["ms", "s", "pct", "bps", "count", "slots", "usd"]) + .optional(), + /** Per-window value sources for the ledger's timeframe toggle. + * Maps a window key to a metric_panels id whose values hold + * this column's figure over that window. Columns without a + * mapping keep their 24h value (and are labeled as such) + * when a longer window is selected. The toggle renders only + * when at least one column declares windows. */ + windows: z + .record(z.enum(["7d", "30d"]), z.string().min(1).max(40)) + .optional(), + }) + .refine((c) => (c.slot != null) !== (c.panel != null), { + message: "ledger column must set exactly one of slot or panel", + }), + ) + .min(1) + .max(6) .optional(), }) - .strict(); + .strict() + .superRefine((spec, ctx) => { + // A region-dimensioned bench without exact cell rankings would fall + // back to cross-region-average badge logic, which is precisely the + // bias the matrix exists to fix (a provider winning from one region + // reads as the global leader). Refuse the spec instead. + const realRegions = (spec.dimensions?.region ?? []).filter( + (r) => r.value !== "all", + ); + if (realRegions.length > 0 && !spec.rank_matrix_query) { + ctx.addIssue({ + code: "custom", + path: ["rank_matrix_query"], + message: + "Benches declaring dimensions.region must provide rank_matrix_query so badge claims are scoped per region", + }); + } + + // A ledger column referencing a panel id that doesn't exist would + // silently render "-" for every provider. Refuse at validate time. + const panelIds = new Set((spec.metric_panels ?? []).map((p) => p.id)); + for (const [i, col] of (spec.ledger_columns ?? []).entries()) { + if (col.panel && !panelIds.has(col.panel)) { + ctx.addIssue({ + code: "custom", + path: ["ledger_columns", i, "panel"], + message: `Unknown metric_panels id "${col.panel}"`, + }); + } + for (const [w, panelId] of Object.entries(col.windows ?? {})) { + if (!panelIds.has(panelId)) { + ctx.addIssue({ + code: "custom", + path: ["ledger_columns", i, "windows", w], + message: `Unknown metric_panels id "${panelId}"`, + }); + } + } + } + // The ledger sorts, bars and badges off p50. The first displayed + // column must be that same number or the table reads as mis-sorted. + const firstCol = spec.ledger_columns?.[0]; + if (firstCol && firstCol.slot !== "p50") { + ctx.addIssue({ + code: "custom", + path: ["ledger_columns", 0, "slot"], + message: + "First ledger column must be slot p50 (the headline the table sorts by)", + }); + } + }); export type Spec = z.infer; export type SpecProvider = z.infer; diff --git a/src/lib/spec.ts b/src/lib/spec.ts index b6c6160c..6c6829de 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -14,7 +14,12 @@ import path from "node:path"; import { cache } from "react"; import { unstable_cache } from "next/cache"; import yaml from "js-yaml"; -import type { Benchmark, MetricPanel, ProviderResult } from "@/types/benchmark"; +import type { + Benchmark, + CellRankEntry, + MetricPanel, + ProviderResult, +} from "@/types/benchmark"; import { Prometheus } from "@/lib/prometheus"; import { SpecSchema, type Spec } from "@/lib/spec-schema"; import { renderBenchmarkText } from "@/lib/bench-template"; @@ -97,7 +102,13 @@ const loadBenchmarkUnfilteredCached = unstable_cache( // older cache entries for the all-benchmarks list didn't include the // new slug, so the bench was 404 on direct hit and absent from search // until the cache aged out. - ["bench-unfiltered-v5"], + // v6: added cellRanks (exact chain × region rankings from + // rank_matrix_query). Cached objects from v5 deploys lack the field, + // which made region-scoped badge URLs 404 after the deploy. + // v7: added ledgerColumns (per-bench ledger column relabeling). + // v8: outage panel unit s -> sec (true seconds); cached v7 objects keep + // the old unit and would render "0.0 s" via the ms-input formatter. + ["bench-unfiltered-v8"], { revalidate: 60, tags: ["benchmarks"] }, ); @@ -158,7 +169,10 @@ const loadAllBenchmarksCached = unstable_cache( // benchmark slice the products page reads. Without bumping this, the // outer cache can keep serving v5-era benchmarks (no providersPerChain) // even after the inner cache is fresh. - ["all-benchmarks-v7"], + // v8: bumped with bench-unfiltered-v6 (cellRanks) for the same reason. + // v9: bumped with bench-unfiltered-v7 (ledgerColumns). + // v10: bumped with bench-unfiltered-v8 (sec unit). + ["all-benchmarks-v10"], { revalidate: 60, tags: ["benchmarks"] }, ); export const loadAllBenchmarks = cache(loadAllBenchmarksCached); @@ -201,7 +215,9 @@ const loadBenchmarkFiltered = unstable_cache( } return bench; }, - ["bench-filters-v3"], + // v4: bumped with bench-unfiltered-v7 (ledgerColumns). + // v5: bumped with bench-unfiltered-v8 (sec unit). + ["bench-filters-v5"], { revalidate: 60, tags: ["benchmarks"] } ); @@ -305,6 +321,7 @@ function buildEditorial( findings: spec.findings, source: spec.source, dimensions: spec.dimensions, + ledgerColumns: spec.ledger_columns, }; } @@ -455,6 +472,11 @@ async function specToBenchmark( if (Object.keys(providers).length > 0) providersPerChain = providers; } + // Exact per-cell rankings (chain × region) from the spec's single + // grouped matrix query. Failures are tolerated: badge/product + // surfaces fall back to the coarser bestPerChain path. + const cellRanks = !isFiltered ? await tryLoadCellRanks(spec) : undefined; + // Resolve {{p50:slug}} / {{best_name}} / {{count}} etc. placeholders // against the freshly loaded numbers so editorial text (findings, // seo_intro, faq) never drifts from the displayed data. @@ -464,6 +486,7 @@ async function specToBenchmark( bestPerChain, worstPerChain, providersPerChain, + cellRanks, }); // Persist a snapshot of the runtime data so a future cold start // during a Prom blackout can still render this bench. Only the @@ -486,6 +509,148 @@ function activeFilterLabels(opts: BenchmarkFilters): Record { return out; } +/** + * Run the spec's `rank_matrix_query` (one instant vector with a sample per + * (provider[, chain][, region])) and fold it into full per-cell rankings. + * + * Output keys are `|` with "all" standing in for an + * undeclared dimension. When BOTH dimensions are declared, marginal cells + * (`|all`, `all|`) are derived by averaging a provider's + * finest-cell values over the collapsed dimension — same semantics as the + * bench page's unscoped `avg(...)` headline queries. + * + * Samples whose provider label doesn't match a spec provider slug, or + * whose chain/region label isn't a declared dimension value, are dropped: + * the matrix is unfiltered PromQL, so stray series (retired providers, + * staging labels) must not leak into rankings. + */ +async function tryLoadCellRanks( + spec: Spec, +): Promise | undefined> { + if (!spec.rank_matrix_query) return undefined; + const url = spec.prometheus?.url ?? process.env.PROMETHEUS_URL; + if (!url) return undefined; + try { + const prom = new Prometheus(url); + const res = await prom.query(spec.rank_matrix_query); + if (res.resultType !== "vector") return undefined; + + const slugByLower = new Map( + spec.providers.map((p) => [p.slug.toLowerCase(), p.slug] as const), + ); + // Canonical dimension value by lowercase, so a harness emitting + // `chain="Base"` still maps onto the declared `base` value instead + // of silently dropping the cell. + const chainByLower = new Map( + (spec.dimensions?.chain ?? []) + .filter((c) => c.value !== "all") + .map((c) => [c.value.toLowerCase(), c.value] as const), + ); + const regionByLower = new Map( + (spec.dimensions?.region ?? []) + .filter((r) => r.value !== "all") + .map((r) => [r.value.toLowerCase(), r.value] as const), + ); + + // key → provider slug → samples (averaged if the grouping left + // residual label splits, e.g. multiple replicas per region). + const acc = new Map>(); + for (const sample of res.result) { + const slug = slugByLower.get((sample.metric.provider ?? "").toLowerCase()); + if (!slug) continue; + const chain = + chainByLower.size > 0 + ? chainByLower.get((sample.metric.chain ?? "").toLowerCase()) + : undefined; + const region = + regionByLower.size > 0 + ? regionByLower.get((sample.metric.region ?? "").toLowerCase()) + : undefined; + if (chainByLower.size > 0 && !chain) continue; + if (regionByLower.size > 0 && !region) continue; + const v = Number(sample.value[1]); + if (!Number.isFinite(v) || v <= 0) continue; + const key = `${chain ?? "all"}|${region ?? "all"}`; + const cell = acc.get(key) ?? new Map(); + const vals = cell.get(slug) ?? []; + vals.push(v); + cell.set(slug, vals); + acc.set(key, cell); + } + if (acc.size === 0) return undefined; + + const mean = (vals: number[]) => + vals.reduce((a, b) => a + b, 0) / vals.length; + const sortCell = (cell: Map): CellRankEntry[] => + [...cell.entries()] + .map(([slug, vals]) => ({ slug, p50: mean(vals) })) + .sort((a, b) => + spec.higher_is_better ? b.p50 - a.p50 : a.p50 - b.p50, + ); + + const out: Record = {}; + for (const [key, cell] of acc) out[key] = sortCell(cell); + + // Marginals, only when both dimensions exist in the finest cells. + // A provider only enters a marginal if it covers EVERY cell of the + // collapsed dimension that exists for that row/column. Without this, + // a provider measured only from its fastest region wins the + // `|all` average by omission (Simpson's bias), and the badge + // for "leads chain X" disagrees with the per-cell wins that earned it. + if (chainByLower.size > 0 && regionByLower.size > 0) { + const regionsOfChain = new Map>(); + const chainsOfRegion = new Map>(); + for (const key of acc.keys()) { + const [chain, region] = key.split("|"); + (regionsOfChain.get(chain) ?? regionsOfChain.set(chain, new Set()).get(chain)!).add(region); + (chainsOfRegion.get(region) ?? chainsOfRegion.set(region, new Set()).get(region)!).add(chain); + } + const marginalFor = ( + groups: Map>, + keyOf: (group: string, member: string) => string, + mKeyOf: (group: string) => string, + ) => { + for (const [group, members] of groups) { + const cell = new Map(); + // Providers present in every member cell of the group. + let eligible: Set | undefined; + for (const member of members) { + const slugs = new Set(acc.get(keyOf(group, member))?.keys() ?? []); + eligible = eligible + ? new Set([...eligible].filter((s) => slugs.has(s))) + : slugs; + } + for (const slug of eligible ?? []) { + const vals: number[] = []; + for (const member of members) { + const v = acc.get(keyOf(group, member))?.get(slug); + if (v) vals.push(mean(v)); + } + if (vals.length > 0) cell.set(slug, [mean(vals)]); + } + if (cell.size > 0) out[mKeyOf(group)] = sortCell(cell); + } + }; + marginalFor( + regionsOfChain, + (chain, region) => `${chain}|${region}`, + (chain) => `${chain}|all`, + ); + marginalFor( + chainsOfRegion, + (region, chain) => `${chain}|${region}`, + (region) => `all|${region}`, + ); + } + return out; + } catch (e) { + console.warn( + `cellRanks skip: ${spec.slug} matrix query failed: ${e instanceof Error ? e.message : String(e)}`, + ); + return undefined; + } +} + /** Inject every active `
- Latency aggregates + {customCols + ? benchmark.metric + : activePanel + ? activePanel.label + : "Latency aggregates"} + + {customCols ? colLabel(customCols[0]) : activePanel ? "Value" : "p50"} p50 Reliability Namep50p90p99Mean + {colLabel(c)} + Valuep50p90p99MeanΔ field Success 24h
Awaiting next successful scrape - {fmtUnit(value, unit)} + {customCells + ? customCells[0].v != null + ? fmtUnit(customCells[0].v, customCells[0].unit) + : "-" + : fmtUnit(value, unit)} - {panelActive ? "—" : fmtUnit(r.ms.p90, unit)} - - {panelActive ? "—" : fmtUnit(r.ms.p99, unit)} - - {panelActive ? "—" : fmtUnit(r.ms.mean, unit)} - + {c.v != null ? fmtUnit(c.v, c.unit) : "-"} + + {fmtUnit(r.ms.p90, unit)} + + {fmtUnit(r.ms.p99, unit)} + + {fmtUnit(r.ms.mean, unit)} + {fieldValue > 0 ? `${deltaSign}${Math.abs(deltaPct).toFixed(0)}%` : "-"}