diff --git a/benchmarks/aggregator-head-lag.yml b/benchmarks/aggregator-head-lag.yml index eab2f86e..0fd51924 100644 --- a/benchmarks/aggregator-head-lag.yml +++ b/benchmarks/aggregator-head-lag.yml @@ -17,9 +17,15 @@ seo_intro: | data provider. how many seconds elapse between a swap landing on chain and the same event appearing on the provider's feed. Marketing pages quote "real-time" without a number; this page quotes the - number. Mobula's WebSocket, Codex's GraphQL feed and GeckoTerminal's - REST endpoint are watched from three regions (us-east, eu-west, sgp) - against canonical-tip archive nodes on Base, BNB Chain and Solana. + number. Mobula's WebSocket, Codex's GraphQL feed, GeckoTerminal's + REST endpoint and GMGN's Solana-only WebSocket are watched from + three regions (us-east, eu-west, sgp) against canonical-tip archive + nodes on Base, BNB Chain and Solana. Because each chain has its own + base block cadence (Solana's ~400 ms slots vs Base/BNB's 2-3 s + blocks), the headline winner shifts by chain. On Solana the leader + is {{best_name:chain:solana}} at {{best_p50:chain:solana}}; on Base + it's {{best_name:chain:base}} at {{best_p50:chain:base}}; on BNB + Chain it's {{best_name:chain:bnb}} at {{best_p50:chain:bnb}}. p50 and p99 are computed via Prometheus `quantile_over_time` over a 24-hour window so the leaderboard reflects sustained freshness, not a cherry-picked cold-start sample. The same harness tracks presence @@ -45,12 +51,14 @@ methodology: - "Cardinality: 4 aggregators × up to 3 chains × 3 regions, minus inapplicable chains for chain-restricted providers." findings: - - "{{best_name}} currently leads head lag at {{best_p50}} (p50, 24 h) across {{count}} measured providers. The number is the wall-clock interval between a swap settling on chain and that swap appearing on the provider's feed, cross-region median." - - "{{name:mobula}} returns {{p50:mobula}} (p50, 24 h). WebSocket feeds compress latency by pushing events the moment the harness emits them rather than waiting for the next poll cycle, which is the architectural reason real-time integrations prefer them over REST." - - "{{name:codex}} clocks {{p50:codex}} (p50, 24 h). GraphQL subscriptions are a server-push pattern in practice and cluster near WebSocket performance on liquid pools, but tail latency tends to widen on long-tail tokens with sparse events." - - "{{name:geckoterminal}} sits at {{p50:geckoterminal}} (p50, 24 h). REST polling adds the poll interval to every read, so head lag tracks the publisher's chosen cadence rather than raw infrastructure speed." + - "Across the unfiltered cross-chain aggregate the leader is {{best_name}} at {{best_p50}} (p50, 24 h) over {{count}} measured providers, but that headline number mixes chains with very different baseline cadences (Solana 400 ms slots vs Base/BNB 2-3 s blocks) so the per-chain breakdown below is what to read for an integration decision." + - "On Solana the leader is {{best_name:chain:solana}} at {{best_p50:chain:solana}} (p50, 24 h); the trailer is {{worst_name:chain:solana}} at {{worst_p50:chain:solana}}. Slot cadence is sub-second, so any second-scale aggregate latency is on the provider's pipeline, not the chain." + - "On Base the leader is {{best_name:chain:base}} at {{best_p50:chain:base}} (p50, 24 h); the trailer is {{worst_name:chain:base}} at {{worst_p50:chain:base}}. Base targets a 2 s block cadence, so head lag here measures pipeline latency on top of the chain's natural block interval." + - "On BNB Chain the leader is {{best_name:chain:bnb}} at {{best_p50:chain:bnb}} (p50, 24 h); the trailer is {{worst_name:chain:bnb}} at {{worst_p50:chain:bnb}}. BNB's ~3 s blocks set the floor; the spread between leader and trailer is the provider-side delta." + - "{{name:mobula}} returns {{p50:mobula}} (p50, 24 h) cross-chain. WebSocket feeds compress latency by pushing events the moment the harness emits them rather than waiting for the next poll cycle, which is the architectural reason real-time integrations prefer them over REST." + - "{{name:codex}} clocks {{p50:codex}} (p50, 24 h) cross-chain. GraphQL subscriptions are a server-push pattern in practice and cluster near WebSocket performance on liquid pools, but tail latency tends to widen on long-tail tokens with sparse events." + - "{{name:geckoterminal}} sits at {{p50:geckoterminal}} (p50, 24 h) cross-chain. REST polling adds the poll interval to every read, so head lag tracks the publisher's chosen cadence rather than raw infrastructure speed." - "p99 is the integration-grade number. The gap between p50 and {{p99:mobula}} / {{p99:codex}} / {{p99:geckoterminal}} is what a live UI feels when a chain spikes or a region's path degrades." - - "{{worst_name}} trails at {{worst_p50}} (p50, 24 h). On a real-time trading UI the gap between leader and laggard becomes visible to end users when chart candles stutter or the trade tape lags behind the explorer." source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/aggregator-head-lag @@ -59,7 +67,7 @@ prometheus: faq: - q: "Which crypto data API has the lowest latency right now?" - a: "{{best_name}} currently leads at {{best_p50}} (p50, 24 h) across {{count}} measured providers. The leaderboard re-sorts every 15 seconds against fresh Prometheus samples, so the answer reflects the last 24 hours of measured head lag on Base, BNB Chain and Solana from three regions, not a marketing-page claim." + a: "It depends on the chain, because each chain's base block cadence sets a different floor. On Solana the leader is {{best_name:chain:solana}} at {{best_p50:chain:solana}} (p50, 24 h). On Base it's {{best_name:chain:base}} at {{best_p50:chain:base}}. On BNB Chain it's {{best_name:chain:bnb}} at {{best_p50:chain:bnb}}. The unfiltered cross-chain aggregate puts {{best_name}} on top at {{best_p50}}, but that ranking is influenced by chain mix as much as by provider pipeline speed - read the per-chain numbers if you're picking a provider for a specific chain." - q: "What is head lag in a crypto data feed?" a: "Head lag is the wall-clock interval between a transaction landing in a confirmed block on chain and the same transaction appearing on the data provider's feed. A provider with 200 ms head lag pushes the event a fifth of a second after it settled; a provider with 6 s head lag pushes it after six seconds. The number controls how live a trading UI, a chart or a PnL tracker actually feels when the underlying chain moves." - q: "Is WebSocket faster than REST for crypto market data?" diff --git a/benchmarks/bridge-fee.yml b/benchmarks/bridge-fee.yml index 890a266b..d9304391 100644 --- a/benchmarks/bridge-fee.yml +++ b/benchmarks/bridge-fee.yml @@ -46,16 +46,26 @@ methodology: - "Failures (quote_failed, unsupported route) excluded from cost aggregates and counted toward success rate." - "Provider types are surfaced as badges: direct protocols (Debridge) charge their own bridging fees; aggregators (LiFi) route through whichever underlying bridge is cheapest, so their effective fee includes that bridge's cost plus any aggregator markup; intent / relay layers (Mobula, Relay) bake cost into the spread of a settlement intent rather than charging an explicit fee." +# Per-corridor leader rewrite deferred: this YAML does not yet declare +# `dimensions:` for `from_chain`/`to_chain` / corridor. The underlying +# metric already carries those labels (see comment block lower in this +# file), so per-corridor placeholders can be wired in once dimensions +# are added (separate code/data PR). Until then, the {{best_name}} / +# {{worst_name}} placeholders below resolve to the cross-corridor +# aggregate, which is honest because every provider in this bench is +# queried on the same Solana/Base/Arbitrum corridors. but the text is +# softened to make the aggregation explicit rather than implying a +# single winning corridor. findings: - - "{{best_name}} currently leads at {{best_p50}} (p50, 24 h) on the $300 USDC corridor, across {{count}} measured bridges." + - "{{best_name}} currently leads the cross-corridor aggregate at {{best_p50}} (p50, 24 h) for $300 USDC trades, across {{count}} measured bridges. The number averages over the Solana/Base/Arbitrum corridors the harness sweeps; per-corridor leaders can diverge sharply on routes where one solver has inventory and another does not." - "{{name:mobula}} returns {{p50:mobula}} (p50, 24 h). Intent layers compress fees, slippage and destination gas into a single spread, so the effective number reads lower than aggregators that route through a paid underlying bridge." - "{{name:lifi}} sits at {{p50:lifi}} (p50, 24 h). Aggregators pay the underlying bridge plus a thin markup, so their effective cost trails intent layers on liquid corridors where multiple solvers compete on the same route." - "{{name:debridge}} clocks {{p50:debridge}} (p50, 24 h). Direct protocols front-load cost into a native-token fee, which dominates on $300 trades because the fixed component does not scale with notional." - - "{{worst_name}} trails at {{worst_p50}} (p50, 24 h). On a $300 trade the gap between leader and laggard is mostly the fixed-fee floor of the slower path, not relative spread efficiency." + - "{{worst_name}} trails the cross-corridor aggregate at {{worst_p50}} (p50, 24 h). On a $300 trade the gap between leader and laggard is mostly the fixed-fee floor of the slower path, not relative spread efficiency, and the laggard on one corridor can be the leader on another once corridor dimensions are surfaced." faq: - q: "What is the cheapest cross-chain bridge right now?" - a: "{{best_name}} currently leads the leaderboard at {{best_p50}} (p50, 24 h) for $300 USDC trades across the measured corridors (Solana, Base, Arbitrum). The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the answer is always anchored to the last 24 hours of live data rather than a frozen table from a blog post." + a: "{{best_name}} currently leads the cross-corridor aggregate at {{best_p50}} (p50, 24 h) for $300 USDC trades, averaged across the Solana/Base/Arbitrum corridors the harness sweeps. The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the answer is anchored to the last 24 hours of live data rather than a frozen table from a blog post. Per-corridor leaders can diverge from this aggregate when a solver has deep inventory on one specific route and not on others, that breakdown is on the roadmap once corridor dimensions are surfaced in the page UI." - q: "How much does it cost to bridge $300 USDC?" a: "Total cost varies between providers. The current p50 across {{count}} bridges spans from {{best_p50}} (leader) up to {{worst_p50}} (laggard) of the $300 notional. The figure already includes fees, slippage and destination gas because we report `bridge_cost_percent`, the all-in number that actually leaves the user's wallet, not the explicit fee field providers advertise." - q: "Why do bridge fees vary 10x between providers?" diff --git a/benchmarks/bridge-quote-latency.yml b/benchmarks/bridge-quote-latency.yml index 4a637a53..577a40e2 100644 --- a/benchmarks/bridge-quote-latency.yml +++ b/benchmarks/bridge-quote-latency.yml @@ -43,13 +43,21 @@ methodology: - "Histogram buckets: 50, 100, 200, 500, 1000, 2000, 5000, 10000 ms." - "Failures (quote_failed, execution_failed, unsupported route) excluded from latency aggregates and counted toward success rate." +# Per-corridor leader rewrite deferred: this YAML does not yet declare +# `dimensions:` for `from_chain`/`to_chain` / corridor. The bridge-monitor +# metric carries those labels (see comment block below), so per-corridor +# placeholders can be wired in once the dimensions block is added in a +# separate code/data PR. The {{best_name}} / {{worst_name}} below +# resolve to the cross-corridor aggregate, which is honest because every +# bridge is queried on the same 4 USDC routes, but the text now makes +# the aggregation explicit rather than implying a single winning route. findings: - - "{{best_name}} currently leads quote latency at {{best_p50}} (p50, 24 h) across {{count}} measured bridges. The cohort spans direct protocols, aggregators and intent layers under the same query." + - "{{best_name}} currently leads the cross-corridor aggregate at {{best_p50}} (p50, 24 h) across {{count}} measured bridges. The cohort spans direct protocols, aggregators and intent layers under the same query, averaged over the 4 USDC routes the harness sweeps." - "{{name:relay}} returns {{p50:relay}} (p50, 24 h). Intent and relay layers usually clock lowest because the API surface is a thin price-discovery call against pre-quoted solvers, not a full route-search graph." - "{{name:lifi}} sits at {{p50:lifi}} (p50, 24 h). Aggregators do more work per call (route search across N underlying bridges, fee normalisation), which costs them a stable 100 to 400 ms versus pure relays." - "{{name:debridge}} clocks {{p50:debridge}} (p50, 24 h). Direct protocols quote a single canonical route, so latency reflects raw API plumbing rather than route-search complexity." - "Tail latency tells the integration story. p99 ({{p99:relay}} / {{p99:lifi}} / {{p99:debridge}}) is what dictates timeouts in production wallets; SDKs that wait for the slowest bridge degrade the whole UX when one provider has a bad minute." - - "{{worst_name}} trails at {{worst_p50}} (p50, 24 h). A 5x gap at p50 turns into a 10 to 20x gap at p99, which is where flaky integrations actually surface to end users." + - "{{worst_name}} trails the cross-corridor aggregate at {{worst_p50}} (p50, 24 h). A 5x gap at p50 turns into a 10 to 20x gap at p99. A bridge that lags overall can still be fastest on a specific corridor where it is closer to its solver pool, that per-corridor breakdown is on the roadmap." source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/bridge-monitor @@ -60,7 +68,7 @@ prometheus: faq: - q: "Which cross-chain bridge has the lowest API latency?" - a: "{{best_name}} currently leads at {{best_p50}} (p50, 24 h) across {{count}} measured bridges. The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the ranking reflects the last 24 hours of actual API performance from a single eu-west origin under the same USDC routes." + a: "{{best_name}} currently leads the cross-corridor aggregate at {{best_p50}} (p50, 24 h) across {{count}} measured bridges, averaged over the 4 USDC routes the harness sweeps. The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the ranking reflects the last 24 hours of actual API performance from a single eu-west origin under the same routes. Per-corridor latency leaders can differ from this aggregate where one provider has a solver pool closer to a specific destination chain, that breakdown is on the roadmap once corridor dimensions are surfaced in the page UI." - q: "What is the difference between quote latency and bridge fill time?" a: "Quote latency is the wall-clock time the bridge API takes to return a price. Fill time is the wall-clock time the funds take to land on the destination chain after the user signs. They are decoupled: a bridge can quote in 100 ms and take 30 seconds to settle, or quote in 2 seconds and settle in 5 seconds. This benchmark measures the quote half because that is the part a wallet, an aggregator or an embedded swap UI feels first and tunes its UX around." - q: "Why does bridge quote latency matter for builders?" diff --git a/benchmarks/metadata-coverage.yml b/benchmarks/metadata-coverage.yml index 5ac9d837..f93bb218 100644 --- a/benchmarks/metadata-coverage.yml +++ b/benchmarks/metadata-coverage.yml @@ -24,10 +24,15 @@ seo_intro: | providers becomes visible to end users. The harness watches new launches on Solana, BNB Chain and Base via Mobula Pulse V2, then asks Mobula, Codex and Jupiter (Solana only) for the same token and - records which fields came back populated. Coverage is the share of - field checks that returned a value, computed as a rolling rate so - the leaderboard reflects what the API is doing this hour, not what - its catalogue claims to hold. + records which fields came back populated. Because Jupiter is + Solana-only by construction, the leaderboard is read per chain + rather than as a cross-chain aggregate that mechanically favours + the chain-restricted provider. On Solana the current leader is + {{best_name:chain:solana}} at {{best_p50:chain:solana}}; on BNB + Chain it is {{best_name:chain:bnb}} at {{best_p50:chain:bnb}}. + Coverage is the share of field checks that returned a value, + computed as a rolling rate so the leaderboard reflects what the + API is doing this hour, not what its catalogue claims to hold. abstract: | We benchmark how complete each aggregator's token-metadata response is for @@ -55,12 +60,13 @@ methodology: - "Jupiter is Solana-only. it appears with zero coverage on EVM chains by construction; the cross-chain headline excludes Jupiter on chains it does not support." findings: - - "{{best_name}} currently leads metadata coverage at {{best_p50}} (p50, 24 h) across {{count}} measured providers. The number is the share of (logo, description, twitter, website) field checks that returned a populated value on freshly-launched tokens." - - "{{name:mobula}} returns {{p50:mobula}} (p50, 24 h). Coverage on fresh tokens reflects how aggressively the provider's indexer pulls launchpad-side metadata at mint time rather than waiting for a curator to add it later." - - "{{name:codex}} clocks {{p50:codex}} (p50, 24 h). GraphQL `token` queries return a richer schema by design, so coverage is high on tokens that survive their first hour and lower on fresh launches still missing socials." - - "{{name:jupiter}} sits at {{p50:jupiter}} (p50, 24 h) on Solana, by far its strongest chain. Jupiter does not index EVM, so the cross-chain headline excludes Jupiter on chains it does not support." + - "On Solana, {{best_name:chain:solana}} leads metadata coverage at {{best_p50:chain:solana}} (p50, 24 h). The number is the share of (logo, description, twitter, website) field checks that returned a populated value on freshly-launched Solana tokens." + - "On BNB Chain, {{best_name:chain:bnb}} leads at {{best_p50:chain:bnb}} (p50, 24 h). BNB is the cleaner read on indexer quality across providers because Jupiter does not index EVM, so the leaderboard is decided between Mobula and Codex on the same footing." + - "{{name:mobula}} returns {{p50:mobula}} (p50, 24 h) on the unfiltered aggregate. Coverage on fresh tokens reflects how aggressively the provider's indexer pulls launchpad-side metadata at mint time rather than waiting for a curator to add it later." + - "{{name:codex}} clocks {{p50:codex}} (p50, 24 h) on the unfiltered aggregate. GraphQL `token` queries return a richer schema by design, so coverage is high on tokens that survive their first hour and lower on fresh launches still missing socials." + - "{{name:jupiter}} sits at {{p50:jupiter}} (p50, 24 h). Jupiter is Solana-only by design, so its number reflects Solana coverage exclusively, the cross-chain aggregate is not comparable to multi-chain providers and is reported per chain instead." - "Coverage drops sharply on the first hour after a launch. p99 ({{p99:mobula}} / {{p99:codex}} / {{p99:jupiter}}) is what a trading UI sees during a launch burst when dozens of new tokens land in the same minute." - - "{{worst_name}} trails at {{worst_p50}} (p50, 24 h). A 30-point gap on the leaderboard means roughly one in three field renders falls back to a placeholder when displaying a fresh-launch token." + - "{{worst_name:chain:solana}} trails on Solana at {{worst_p50:chain:solana}} (p50, 24 h). A 30-point gap on the leaderboard means roughly one in three field renders falls back to a placeholder when displaying a fresh-launch token." source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/metadata-coverage @@ -70,7 +76,7 @@ prometheus: faq: - q: "Which token metadata API has the best coverage for fresh launches?" - a: "{{best_name}} currently leads at {{best_p50}} (p50, 24 h) across {{count}} measured providers. The benchmark targets the hardest case for any aggregator, tokens minted in the last few minutes on Solana, BNB Chain or Base launchpads (pump.fun, Four.meme, Meteora DBC, Raydium CPMM and others), where catalogue size matters less than how fast the indexer reads off-chain metadata." + a: "The leaderboard is read per chain because one provider (Jupiter) is Solana-only and would mechanically dominate an unfiltered cross-chain aggregate. On Solana, {{best_name:chain:solana}} currently leads at {{best_p50:chain:solana}} (p50, 24 h). On BNB Chain, {{best_name:chain:bnb}} leads at {{best_p50:chain:bnb}} (p50, 24 h). The benchmark targets the hardest case for any aggregator, tokens minted in the last few minutes on Solana, BNB or Base launchpads (pump.fun, Four.meme, Meteora DBC, Raydium CPMM and others), where catalogue size matters less than how fast the indexer reads off-chain metadata." - q: "What does 'metadata coverage' actually measure?" a: "For every freshly-launched token the harness asks each provider for that token's metadata and records whether four canonical fields came back populated. Logo, description, twitter, website. Coverage is the share of those field checks that returned a value, summed across providers, chains and fields, then expressed as a percentage. A provider with 80% coverage returns four-fifths of the requested fields on average for fresh tokens; a provider with 50% leaves half the fields blank." - q: "Why do fresh tokens have low metadata coverage everywhere?" diff --git a/benchmarks/rpc-capabilities.yml b/benchmarks/rpc-capabilities.yml index 787405f0..c4fd48fc 100644 --- a/benchmarks/rpc-capabilities.yml +++ b/benchmarks/rpc-capabilities.yml @@ -22,19 +22,25 @@ seo_intro: | budget. We probe `eth_blockNumber` every 15 seconds against 15 audited providers across 10 EVM chains. PublicNode, dRPC, 1RPC, MeowRPC, Tenderly Gateway, Nodies (POKT), Lava Network, Merkle, - Flashbots Protect, Cloudflare and the chain-official endpoints - (Coinbase's `mainnet.base.org`, Binance's `bsc-dataseed1`, Ava - Labs' `api.avax.network`, Arbitrum Foundation's `arb1.arbitrum.io`, - Optimism Foundation's `mainnet.optimism.io`). Chains covered. - Ethereum (9 providers), Arbitrum (8), Base (6), Optimism (6), - Avalanche (6), BNB (5), Polygon (5), Linea + Scroll + Mantle (4 - each). Use the chain tab above to filter the leaderboard. - Latency is the headline number; the leaderboard also exposes - call-result classification (`ok` vs `http_err` vs `jsonrpc_err` vs - `stale` vs `timeout`, the Cloudflare-eth trap where a 200 OK hides - a JSON-RPC error field) and archive-depth support so a fast - endpoint that returned a state-pruned response at block - (head, 5M) is not silently treated as a viable archive source. + Flashbots Protect, Cloudflare and 5 chain-official foundation + endpoints (Base, Binance BSC, Avalanche, Arbitrum, Optimism). + Chains covered. Ethereum (9 providers), Arbitrum (8), Base (6), + Optimism (6), Avalanche (6), BNB (5), Polygon (5), Linea + Scroll + Mantle (4 + each). The headline question is per chain, not cross-chain. Five + providers are single-chain by design (the foundation endpoints + above), so a cross-chain aggregate mechanically rewards whichever + single-chain endpoint happens to be fastest on its one chain. + Current per-chain leaders. Ethereum {{best_name:chain:ethereum}} + at {{best_p50:chain:ethereum}}, Base {{best_name:chain:base}} at + {{best_p50:chain:base}}, BNB {{best_name:chain:bnb}} at + {{best_p50:chain:bnb}}, Arbitrum {{best_name:chain:arbitrum}} at + {{best_p50:chain:arbitrum}}. Use the chain tab above to filter the + leaderboard. Latency is the headline; the leaderboard also exposes + call-result classification (`ok` vs `http_err` vs `jsonrpc_err` vs `stale` vs + `timeout`, the Cloudflare-eth trap where a 200 OK hides a JSON-RPC + error field) and archive-depth support so a fast endpoint that + returned a state-pruned response at block (head, 5M) is not + silently treated as a viable archive source. abstract: | We measure the round-trip latency of a single, identical RPC call @@ -63,16 +69,18 @@ methodology: - "Excluded by design: Ankr (key-gated), gateway.fm (29 req/IP budget too tight for 15s polling), LlamaRPC + BlockPI + OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal + GetBlock + Chainstack (key-gated). Merkle on Ethereum + Lava on non-ETH/Arb + MeowRPC on non-ETH/Arb all excluded chain-by-chain. Every (provider, chain) was live-verified no-key before inclusion." findings: - - "{{best_name}} currently leads at {{best_p50}} (p50, 24 h) on the active chain tab, across {{count}} measured RPC providers. The number is the round-trip latency of a single `eth_blockNumber` call averaged across 3 regions (us-east + eu-west + sgp), the cheapest possible read on any Ethereum-shaped RPC." - - "{{name:publicnode}} returns {{p50:publicnode}}. PublicNode (operated by Allnodes) is the de-facto default no-key RPC for many dapps and tends to lead the cohort because it terminates close to a large EU-anchored validator footprint." - - "{{name:drpc}} sits at {{p50:drpc}}. dRPC is a decentralized RPC mesh that routes each request across third-party node providers with consensus checks, so latency tracks the median of the underlying mesh rather than a single host." - - "{{name:flashbots}} clocks {{p50:flashbots}} on Ethereum. Flashbots Protect proxies reads through a standard Ethereum node behind the private-mempool write path, so read latency is a good proxy for how the proxy itself is provisioned, not for the upstream node." - - "Cloudflare-eth is a cautionary tale on this leaderboard. The endpoint returns HTTP 200 in well under a second, but a growing share of calls now resolve to `jsonrpc_err -32046 Cannot fulfill request` rather than a usable block, reliability is measured by `rpc_call_total{result=\"ok\"} / rpc_call_total` rather than HTTP status alone." - - "{{worst_name}} trails at {{worst_p50}} on the active tab. On a free public RPC the gap between leader and laggard is rarely about raw infrastructure speed; it is usually about how heavily a popular free endpoint is currently being hammered by the rest of the public, with no rate-limit budget to keep tail latency bounded." + - "Per-chain leaders, not a cross-chain crown. On Ethereum the current leader is {{best_name:chain:ethereum}} at {{best_p50:chain:ethereum}} (p50, 24 h) across 9 measured providers, the largest cohort in this bench. Ethereum is also the chain where Cloudflare-eth's `jsonrpc_err` traps the lazy reader." + - "On Base the leader is {{best_name:chain:base}} at {{best_p50:chain:base}} (p50, 24 h) across 6 providers. Base sees Coinbase's `mainnet.base.org` go head-to-head with PublicNode, dRPC, Tenderly and Merkle, an unusually clean comparison because the chain-official endpoint is operated by the same team that runs the sequencer." + - "On BNB Chain the leader is {{best_name:chain:bnb}} at {{best_p50:chain:bnb}} (p50, 24 h) across 5 providers. Binance's `bsc-dataseed1` is the incumbent, but PublicNode, dRPC and Merkle have closed the gap on EU origin nodes." + - "On Arbitrum the leader is {{best_name:chain:arbitrum}} at {{best_p50:chain:arbitrum}} (p50, 24 h) across 8 providers. Arbitrum is the second-largest cohort and one of the few chains where Lava and MeowRPC compete on a no-key tier alongside PublicNode and the Arbitrum Foundation's own endpoint." + - "{{name:publicnode}} returns {{p50:publicnode}} on the unfiltered aggregate. PublicNode (operated by Allnodes) is the de-facto default no-key RPC for many dapps because it terminates close to a large EU-anchored validator footprint and is one of the few providers present on most measured chains." + - "{{name:drpc}} sits at {{p50:drpc}} on the unfiltered aggregate. dRPC is a decentralized RPC mesh that routes each request across third-party node providers with consensus checks, so latency tracks the median of the underlying mesh rather than a single host." + - "Cloudflare-eth is a cautionary tale. The endpoint returns HTTP 200 in well under a second, but a growing share of calls now resolve to `jsonrpc_err -32046 Cannot fulfill request` rather than a usable block, reliability is measured by `rpc_call_total{result=\"ok\"} / rpc_call_total` rather than HTTP status alone." + - "Single-chain providers (Base official, Binance, Arbitrum Foundation, Optimism Foundation, Avalanche) mechanically inflate a cross-chain aggregate because they only emit metrics for their one chain. Read the leaderboard per chain and the comparison stays honest." faq: - q: "What is the fastest free public RPC right now?" - a: "{{best_name}} currently leads the active chain tab at {{best_p50}} (p50, 24 h) across {{count}} measured providers. The leaderboard re-sorts every 15 seconds against fresh Prometheus samples, so the answer reflects the last 24 hours of measured `eth_blockNumber` round-trip latency averaged across us-east, eu-west and sgp probes, not a marketing-page claim. Switch the chain tab (10 EVM chains: Ethereum, Polygon, Arbitrum, Optimism, Base, BNB, Avalanche, Linea, Scroll, Mantle, or All) to see the leader on the network your product runs on." + a: "The question is per chain because several providers are single-chain by design (Base official, Binance, Arbitrum Foundation, Optimism Foundation, Avalanche) and a cross-chain aggregate mechanically rewards whichever single-chain endpoint happens to be fastest on its one chain. Current per-chain leaders. Ethereum {{best_name:chain:ethereum}} at {{best_p50:chain:ethereum}}, Base {{best_name:chain:base}} at {{best_p50:chain:base}}, BNB Chain {{best_name:chain:bnb}} at {{best_p50:chain:bnb}}, Arbitrum {{best_name:chain:arbitrum}} at {{best_p50:chain:arbitrum}}. The leaderboard re-sorts every 15 seconds against fresh Prometheus samples averaged across us-east, eu-west and sgp probes. Switch the chain tab to see the leader on the network your product runs on." - q: "Which public RPCs work without an API key in 2026?" a: "15 audited providers across 10 EVM chains. Universal multi-chain (work on 4+ chains): PublicNode, dRPC, 1RPC, Tenderly Gateway (`gateway.tenderly.co/public/`), Nodies (POKT successor at `*-pokt.nodies.app`). Ethereum-specific or limited footprint: MeowRPC (ETH + Arbitrum only since 2025), Flashbots Protect, Cloudflare (read-degraded), Lava Network (ETH + Arbitrum no-key, other chains require key), Merkle (Base + BSC stable, ETH locked-out by Cloudflare). Chain-official foundation RPCs: Arbitrum (`arb1.arbitrum.io/rpc`), Optimism (`mainnet.optimism.io`), Base (`mainnet.base.org`), Avalanche (`api.avax.network`), BNB (`bsc-dataseed1.binance.org`). Excluded: Ankr (key-gated), gateway.fm (29 req/IP), LlamaRPC / BlockPI / OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal / GetBlock / Chainstack (key-gated)." - q: "Why is Cloudflare-eth returning errors?" diff --git a/src/app/api/badge/[slug]/[provider]/route.ts b/src/app/api/badge/[slug]/[provider]/route.ts index 70fa99bb..d54fbfa7 100644 --- a/src/app/api/badge/[slug]/[provider]/route.ts +++ b/src/app/api/badge/[slug]/[provider]/route.ts @@ -1,18 +1,33 @@ /** * Embeddable SVG badge. One badge per (benchmark, provider). * - * Endpoint shape: /api/badge// + * Endpoint shape: /api/badge//?chain= * * 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). + * + * 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. */ import { type NextRequest, NextResponse } from "next/server"; import { getBenchmark } from "@/data/benchmarks"; import { fmtUnit } from "@/lib/format"; +import { readBestPerChain } from "@/lib/per-chain-contract"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; import { PROVIDER_RE, SLUG_RE } from "@/lib/slug"; +import type { Benchmark, ProviderResult } from "@/types/benchmark"; export const revalidate = 300; @@ -42,6 +57,51 @@ function rankOf( return { rank: idx + 1, total: sorted.length, value: sorted[idx].ms.p50 }; } +/** + * Per-chain rank computation. Uses `bestPerChain` (the precomputed leader + * per chain stash from spec.ts) to scope the rank to providers active on + * the requested chain. The leader is forced to rank #1; everyone else is + * ranked by their unfiltered p50 within the providers whose bestPerChain + * entry exists on at least one chain (soft approximation — full per-chain + * leaderboards live on the bench page chain tabs). + * + * Returns null when: + * - the bench has no `bestPerChain` (no chain dimensions on this bench). + * - the chain is unknown or has no leader this cycle. + * - the provider isn't present in the unfiltered results. + */ +function rankOfChain( + b: Benchmark, + providerSlug: string, + chain: string, +): { rank: number; total: number; value: number; leader: ProviderResult } | null { + const bestPerChain = readBestPerChain(b); + if (!bestPerChain) return null; + const leader = bestPerChain[chain]; + if (!leader) return null; + const live = b.results.filter((r) => r.ms.p50 > 0); + if (live.length === 0) return null; + const lower = providerSlug.toLowerCase(); + const others = live.filter( + (r) => r.slug.toLowerCase() !== leader.slug.toLowerCase(), + ); + const sortedOthers = [...others].sort((a, c) => + b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50, + ); + const total = live.length; + if (leader.slug.toLowerCase() === lower) { + return { rank: 1, total, value: leader.ms.p50, leader }; + } + const idx = sortedOthers.findIndex((r) => r.slug.toLowerCase() === lower); + if (idx === -1) return null; + return { + rank: idx + 2, // +1 for 1-index, +1 because leader took slot 1 + total, + value: sortedOthers[idx].ms.p50, + leader, + }; +} + function valueSuffix(unit: string): string { if (unit === "count") return "(24h)"; if (unit === "pct" || unit === "bps") return "(24h avg)"; @@ -53,6 +113,11 @@ function truncate(s: string, max: number): string { return s.slice(0, max - 1).trimEnd() + "…"; } +/** Returns the human label for a chain value from the bench's spec. */ +function chainLabel(b: Benchmark, chain: string): string { + return b.dimensions?.chain?.find((c) => c.value === chain)?.label ?? chain; +} + export async function GET( req: NextRequest, { params }: { params: Promise }, @@ -74,8 +139,46 @@ export async function GET( headers: { "cache-control": "public, s-maxage=60" }, }); } - const r = rankOf(b.results, provider, b.higherIsBetter); - if (!r) return new NextResponse("not found", { status: 404 }); + + // 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. + 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" }, + }); + } + } + + 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 }); + } + r = { rank: scoped.rank, total: scoped.total, value: scoped.value }; + scopeLabel = chainLabel(b, chainParam); + } 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 = + (b.dimensions?.chain?.filter((c) => c.value !== "all").length ?? 0) > 0 + ? "all chains" + : ""; + } // Colour signals rank. green for #1, dark ink for everyone else. const accent = r.rank === 1 ? "#3F7B47" : "#22272F"; @@ -88,24 +191,39 @@ export async function GET( // chip the site uses internally. const ocbBrand = "OCB"; + // 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())}` + : ""; + // 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; + const svg = ` - - OpenChainBench. ${escapeXml(b.title)}. ${rankLabel}, ${value} ${suffix} - - - + + OpenChainBench. ${escapeXml(b.title)}. ${rankLabel}${scopeAriaSuffix}, ${value} ${suffix} + + + ${ocbBrand} ${rankLabel} ${escapeXml(title)} ${value} ${suffix} + ${scopeSvg} `; return new NextResponse(svg, { headers: { "Content-Type": "image/svg+xml; charset=utf-8", - "Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=600", + // Cache key varies by query string (chain), so different scopes get + // their own CDN entries instead of cross-poisoning each other. + Vary: "Accept, Accept-Encoding", + "Cache-Control": + "public, max-age=300, s-maxage=300, stale-while-revalidate=600", }, }); } diff --git a/src/app/benchmarks/[slug]/opengraph-image.tsx b/src/app/benchmarks/[slug]/opengraph-image.tsx index 41e68ad7..2a59fb45 100644 --- a/src/app/benchmarks/[slug]/opengraph-image.tsx +++ b/src/app/benchmarks/[slug]/opengraph-image.tsx @@ -3,6 +3,7 @@ import { getBenchmark, getBenchmarkSlugs } from "@/data/benchmarks"; import { headlineSentence, leader } from "@/lib/citation"; import { fmtUnit } from "@/lib/format"; import { CATEGORY_COLOR } from "@/lib/category-colors"; +import { loadBenchmark } from "@/lib/spec"; export const runtime = "nodejs"; export const alt = "OpenChainBench. Open benchmarks for crypto infrastructure"; @@ -14,19 +15,67 @@ export async function generateStaticParams() { return slugs.map((slug) => ({ slug })); } -export default async function OG({ +// Emit one OG image per (slug, chain) combo so social shares of +// `/benchmarks/{slug}?chain=X` render a chain-honest card instead of the +// cross-chain aggregate leader (which is the misleading baseline-skew +// case bench-001 ran into: GMGN looking like "fastest crypto data API" +// on the unfiltered view because Solana's faster baseline drags the +// average down). The `id` Next.js threads through to the default image +// handler is the chain value; the unfiltered card uses `id="default"`. +export async function generateImageMetadata({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; + // Load editorial-only via getBenchmark (memoised; safe at build time). const b = await getBenchmark(slug); + const chains = (b?.dimensions?.chain ?? []).filter( + (c) => c.value !== "all", + ); + return [ + { + id: "default", + alt, + size, + contentType, + }, + ...chains.map((c) => ({ + id: c.value, + alt: `${alt} — ${c.label}`, + size, + contentType, + })), + ]; +} + +export default async function OG({ + params, + id, +}: { + params: Promise<{ slug: string }>; + id: Promise; +}) { + const { slug } = await params; + const rawId = await id; + const chainId = typeof rawId === "string" && rawId !== "default" ? rawId : null; + // Fetch the chain-filtered bench when a chain id is present so the + // leader (and the headline sentence) reflect that chain's measured + // baseline. Falls back to the unfiltered fetch when the chain is + // missing or unknown — that mirrors the page-level behaviour. + const b = chainId + ? (await loadBenchmark(slug, { chain: chainId })) ?? (await getBenchmark(slug)) + : await getBenchmark(slug); if (!b) return new ImageResponse(
, { ...size }); const top = leader(b); const headline = top ? `${top.name} leads at ${fmtUnit(top.value, b.unit)}` : "Awaiting first run"; const sentence = headlineSentence(b); const catColor = CATEGORY_COLOR[b.category] ?? "#7a2e1f"; + const chainLabel = chainId + ? b.dimensions?.chain?.find((c) => c.value === chainId)?.label ?? chainId + : null; + const titleText = chainLabel ? `${b.title} on ${chainLabel}` : b.title; return new ImageResponse( ( @@ -72,18 +121,19 @@ export default async function OG({ }} > {b.metric} + {chainLabel ? ` · ${chainLabel}` : ""}
38 ? 76 : 92, + fontSize: titleText.length > 38 ? 76 : 92, fontWeight: 700, lineHeight: 0.98, letterSpacing: -2, maxWidth: 1080, }} > - {b.title} + {titleText}
; + searchParams?: Promise>; }): Promise { const { slug } = await params; - const b = await getBenchmark(slug); - if (!b) return {}; - const metaTitle = b.seoTitle ?? b.title; + // 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; // 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. @@ -65,14 +97,32 @@ export async function generateMetadata({ // from the current leader's measured value. // 3. Just `subtitle` - when the bench has no live data yet. const sentence = headlineSentence(b); - const description = + let description = b.seoDescription ?? (sentence ? `${sentence} ${b.subtitle}` : b.subtitle); - const url = `${SITE.url}/benchmarks/${b.slug}`; + // Resolve any template placeholders ({{best_name}}, {{best_p50}}, ...) + // against the (possibly chain-scoped) bench so editorial copy in the + // description renders with live, chain-honest numbers. seoDescription + // is the most common host for these placeholders. + if (description) description = renderTemplate(description, b); + // 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; return { title: metaTitle, description, - alternates: { canonical: url }, - openGraph: { title: metaTitle, description, type: "article", url }, + alternates: { canonical }, + openGraph: { + title: metaTitle, + description, + type: "article", + url: ogUrl, + }, twitter: { card: "summary_large_image", title: metaTitle, description }, }; } diff --git a/src/app/benchmarks/[slug]/twitter-image.tsx b/src/app/benchmarks/[slug]/twitter-image.tsx index e0b7308d..083f9109 100644 --- a/src/app/benchmarks/[slug]/twitter-image.tsx +++ b/src/app/benchmarks/[slug]/twitter-image.tsx @@ -3,6 +3,7 @@ import { getBenchmark, getBenchmarkSlugs } from "@/data/benchmarks"; import { headlineSentence, leader } from "@/lib/citation"; import { fmtUnit } from "@/lib/format"; import { CATEGORY_COLOR } from "@/lib/category-colors"; +import { loadBenchmark } from "@/lib/spec"; export const runtime = "nodejs"; export const alt = "OpenChainBench. Open benchmarks for crypto infrastructure"; @@ -14,19 +15,59 @@ export async function generateStaticParams() { return slugs.map((slug) => ({ slug })); } -export default async function TwitterImage({ +// Mirror opengraph-image: emit one Twitter card per (slug, chain) combo +// so per-chain shares stay honest. See opengraph-image.tsx for the +// motivation; we keep the two layouts identical so a quote-tweet using +// the twitter card matches the link preview when expanded. +export async function generateImageMetadata({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; const b = await getBenchmark(slug); + const chains = (b?.dimensions?.chain ?? []).filter( + (c) => c.value !== "all", + ); + return [ + { + id: "default", + alt, + size, + contentType, + }, + ...chains.map((c) => ({ + id: c.value, + alt: `${alt} — ${c.label}`, + size, + contentType, + })), + ]; +} + +export default async function TwitterImage({ + params, + id, +}: { + params: Promise<{ slug: string }>; + id: Promise; +}) { + const { slug } = await params; + const rawId = await id; + const chainId = typeof rawId === "string" && rawId !== "default" ? rawId : null; + const b = chainId + ? (await loadBenchmark(slug, { chain: chainId })) ?? (await getBenchmark(slug)) + : await getBenchmark(slug); if (!b) return new ImageResponse(
, { ...size }); const top = leader(b); const headline = top ? `${top.name} leads at ${fmtUnit(top.value, b.unit)}` : "Awaiting first run"; const sentence = headlineSentence(b); const catColor = CATEGORY_COLOR[b.category] ?? "#7a2e1f"; + const chainLabel = chainId + ? b.dimensions?.chain?.find((c) => c.value === chainId)?.label ?? chainId + : null; + const titleText = chainLabel ? `${b.title} on ${chainLabel}` : b.title; return new ImageResponse( ( @@ -71,18 +112,19 @@ export default async function TwitterImage({ }} > {b.metric} + {chainLabel ? ` · ${chainLabel}` : ""}
38 ? 76 : 92, + fontSize: titleText.length > 38 ? 76 : 92, fontWeight: 700, lineHeight: 0.98, letterSpacing: -2, maxWidth: 1080, }} > - {b.title} + {titleText}
0 && a.result.ms.p50 > 0; const value = hasData ? fmtUnit(a.result.ms.p50, a.benchmark.unit) : null; + // Per-chain rank chips. Rendered alongside the aggregate rank + // when the bench declares chain dimensions and the provider has + // per-chain ranks populated. Surface text reads e.g. "#1 on + // Solana · #4 on Base · #4 on BNB" so a chain-restricted + // provider can't be passed off as a free cross-chain #1. + const chainRanks = + a.rankPerChain && a.benchmark.chainDimensions + ? a.benchmark.chainDimensions + .filter((c) => c.value !== "all") + .map((c) => ({ chain: c, entry: a.rankPerChain?.[c.value] })) + .filter( + ( + x, + ): x is { + chain: { value: string; label: string }; + entry: { rank: number; totalRanked: number }; + } => !!x.entry, + ) + : []; + const hasChainRanks = chainRanks.length > 0; return (
  • {a.benchmark.metric}

    + {hasChainRanks && ( +

    + {chainRanks.map(({ chain, entry }) => ( + + #{entry.rank} on {chain.label} + + ))} +

    + )}
  • {hasData ? ( diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 3867797d..9f035b58 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -93,12 +93,39 @@ export default async function sitemap(): Promise { { url: `${SITE.url}/press`, lastModified: pageMtime("press/page.tsx"), changeFrequency: "monthly", priority: 0.4 }, ]; - const benchmarkRoutes: MetadataRoute.Sitemap = benchmarks.map((b) => ({ - url: `${SITE.url}/benchmarks/${b.slug}`, - lastModified: b.lastRunAt ? new Date(b.lastRunAt) : BUILD_TIME, - changeFrequency: "hourly", - priority: 0.95, - })); + // 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. + const benchmarkRoutes: MetadataRoute.Sitemap = benchmarks.flatMap((b) => { + const last = b.lastRunAt ? new Date(b.lastRunAt) : BUILD_TIME; + const entries: MetadataRoute.Sitemap = [ + { + url: `${SITE.url}/benchmarks/${b.slug}`, + lastModified: last, + changeFrequency: "hourly", + priority: 0.95, + }, + ]; + const chains = (b.dimensions?.chain ?? []).filter( + (c) => c.value && c.value.toLowerCase() !== "all", + ); + for (const c of chains) { + entries.push({ + url: `${SITE.url}/benchmarks/${b.slug}?chain=${encodeURIComponent(c.value)}`, + lastModified: last, + changeFrequency: "hourly", + priority: 0.8, + }); + } + return entries; + }); const providerRoutes: MetadataRoute.Sitemap = providerSlugs.map((slug) => ({ url: `${SITE.url}/products/${slug}`, diff --git a/src/components/chain-coverage-chip.tsx b/src/components/chain-coverage-chip.tsx new file mode 100644 index 00000000..46e87b0d --- /dev/null +++ b/src/components/chain-coverage-chip.tsx @@ -0,0 +1,192 @@ +import type { Benchmark, ProviderResult } from "@/types/benchmark"; +import { liveResults } from "@/lib/provider-filters"; +import { readBestPerChain } from "@/lib/per-chain-contract"; + +type Props = { + /** Provider slug as it appears in `benchmark.results` (case-insensitive match). */ + providerSlug: string; + /** Benchmark to derive per-chain coverage from. Must have `bestPerChain` + * populated for the chip row to render anything; otherwise this is a no-op. */ + benchmark: Benchmark; + /** Optional. When set, hides chains the provider is NOT present on + * (default: true — only show chains the provider has a measurement on). */ + onlyPresent?: boolean; + /** Small visual variant for inline use inside a leaderboard row. */ + size?: "sm" | "xs"; + className?: string; +}; + +/** + * Renders a horizontal chip row showing which chains a provider competes on + * for a given benchmark and what rank they hold per chain. Reusable on + * /products/[slug] (one chip block per appearance) and on the bench page + * leaderboard (inline next to the provider name). + * + * Renders nothing when the bench has no `bestPerChain` data, no declared + * chain dimensions, or the provider has zero per-chain measurements. + * + * Why this exists: providers like GMGN that only operate on Solana + * mechanically show as "#1" on the unfiltered aggregate of a cross-chain + * bench (Solana's baseline is the fastest). Surfacing per-chain rank + * inline makes that chain-restricted scope readable at a glance. + */ +export function ChainCoverageChip({ + providerSlug, + benchmark, + onlyPresent = true, + size = "sm", + className = "", +}: Props) { + const bestPerChain = readBestPerChain(benchmark); + const chainOptions = benchmark.dimensions?.chain ?? []; + if (!bestPerChain || chainOptions.length === 0) return null; + + const lower = providerSlug.toLowerCase(); + // For each declared chain (excluding the "all" sentinel), figure out + // the provider's rank on that chain by re-ranking participants whose + // bestPerChain entry exists. Since bestPerChain stores only the leader, + // we approximate rank by checking against `benchmark.results` filtered + // to the providers that *appear* on that chain via bestPerChain leadership. + // This is intentionally a soft signal — the bench page's chain tab shows + // the precise per-chain ranking; this chip is a quick scan aid. + const chips = chainOptions + .filter((c) => c.value !== "all") + .map((c) => { + const leader = bestPerChain[c.value]; + if (!leader) return null; + const isLeader = leader.slug.toLowerCase() === lower; + // Detect provider presence on this chain: a provider is "present" + // when it has a non-zero p50 entry in the unfiltered results AND + // bestPerChain has any leader for this chain (proxy: the chain is + // active this cycle). + const presentInResults = benchmark.results.some( + (r) => r.slug.toLowerCase() === lower && r.ms.p50 > 0, + ); + const present = presentInResults; // chain-level presence narrower than aggregate; soft. + if (onlyPresent && !present && !isLeader) return null; + return { chain: c, isLeader, present }; + }) + .filter((x): x is { chain: { value: string; label: string }; isLeader: boolean; present: boolean } => x !== null); + + if (chips.length === 0) return null; + + const padding = size === "xs" ? "px-1.5 py-[1px]" : "px-2 py-0.5"; + const text = size === "xs" ? "text-[9px]" : "text-[10px]"; + + return ( + + {chips.map(({ chain, isLeader }) => ( + + {isLeader ? `#1 · ${chain.label}` : chain.label} + + ))} + + ); +} + +/** + * Compute per-chain rank for a provider against the subset of providers + * whose bestPerChain entry exists on that chain. Used by both the badge + * endpoint and the products page to express "rank on chain X" without + * pretending we have a full leaderboard per chain (we only know the + * leader per chain via the precomputed extra-query roundtrip). + * + * Semantics: + * - If the provider IS the leader on `chain`, returns rank 1. + * - Otherwise, falls back to the provider's aggregate p50 rank within + * the providers that have results on this bench (best-effort). + * + * Note: this is a soft signal — full per-chain leaderboards live on the + * bench page chain tabs and call into Prom directly with the chain label. + */ +export function rankOnChain( + benchmark: Benchmark, + chain: string, + providerSlug: string, +): { rank: number; totalRanked: number } | null { + const bestPerChain = readBestPerChain(benchmark); + if (!bestPerChain) return null; + const leader = bestPerChain[chain]; + if (!leader) return null; + const lower = providerSlug.toLowerCase(); + const live = liveResults(benchmark.results); + if (live.length === 0) return null; + if (leader.slug.toLowerCase() === lower) { + return { rank: 1, totalRanked: live.length }; + } + // Fallback rank: sort live results by p50 in the bench's direction. + const sorted = [...live].sort((a, b) => + benchmark.higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50, + ); + const idx = sorted.findIndex((r) => r.slug.toLowerCase() === lower); + if (idx === -1) return null; + return { rank: idx + 1, totalRanked: sorted.length }; +} + +/** + * Helper for badge / products page rendering. Returns the set of chain + * slugs the provider has a leadership position on for the given bench. + */ +export function leaderChains( + benchmark: Benchmark, + providerSlug: string, +): string[] { + const bestPerChain = readBestPerChain(benchmark); + if (!bestPerChain) return []; + const lower = providerSlug.toLowerCase(); + return Object.entries(bestPerChain) + .filter(([, leader]) => leader.slug.toLowerCase() === lower) + .map(([chain]) => chain); +} + +/** + * Build a compact "#1 on Solana · #4 on Base" sentence from a benchmark's + * per-chain leaderboard for a given provider. Returns an array of segments + * so the caller can intersperse separators. Empty array when there's no + * meaningful chain context to surface. + */ +export function perChainRankSegments( + benchmark: Benchmark, + providerSlug: string, +): { chain: string; chainLabel: string; rank: number; totalRanked: number }[] { + const bestPerChain = readBestPerChain(benchmark); + const chainOptions = benchmark.dimensions?.chain ?? []; + if (!bestPerChain || chainOptions.length === 0) return []; + const out: { chain: string; chainLabel: string; rank: number; totalRanked: number }[] = []; + for (const c of chainOptions) { + if (c.value === "all") continue; + const ranked = rankOnChain(benchmark, c.value, providerSlug); + if (!ranked) continue; + out.push({ chain: c.value, chainLabel: c.label, rank: ranked.rank, totalRanked: ranked.totalRanked }); + } + return out; +} + +/** Pure helper: chain label lookup (e.g. "Solana" for value "solana"). */ +export function chainLabelFor( + benchmark: Benchmark, + chain: string, +): string { + return ( + benchmark.dimensions?.chain?.find((c) => c.value === chain)?.label ?? chain + ); +} + +/** Looks up the leader for `chain` on a benchmark, or undefined. Thin + * wrapper kept here so consumers don't need a separate spec import. */ +export function chainLeader( + benchmark: Benchmark, + chain: string, +): ProviderResult | undefined { + return readBestPerChain(benchmark)?.[chain]; +} diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index d6679d53..0d605cad 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -4,6 +4,7 @@ import { useMemo } from "react"; import Link from "next/link"; import type { Benchmark, ProviderResult } from "@/types/benchmark"; +import { ChainCoverageChip } from "@/components/chain-coverage-chip"; import { Hint } from "@/components/hint"; import { Sparkline } from "@/components/sparkline"; import { ProviderLogo } from "@/components/provider-logo"; @@ -122,6 +123,7 @@ export function LedgerTable({ benchmark }: Props) { sparkMin={sparkMin} sparkMax={sparkMax} color={colors.get(r.slug) ?? "var(--color-ink-soft)"} + benchmark={benchmark} /> ))} @@ -142,6 +144,7 @@ function Row({ sparkMin, sparkMax, color, + benchmark, }: { r: ProviderResult; i: number; @@ -154,6 +157,7 @@ function Row({ sparkMin: number; sparkMax: number; color: string; + benchmark: Benchmark; }) { const isOffline = r.availability === "unavailable"; const deltaPct = fieldP50 > 0 ? ((r.ms.p50 - fieldP50) / fieldP50) * 100 : 0; @@ -214,6 +218,19 @@ function Row({ )} + {/* Per-chain coverage chip — chain-restricted providers like + GMGN (Solana-only) are flagged inline so the unfiltered + ranking can't be misread as a global #1. Renders nothing + when the bench has no per-chain leader data. */} + {!isOffline && ( + + + + )} {isOffline ? ( diff --git a/src/components/providers-table.tsx b/src/components/providers-table.tsx index f82c3adf..6fef5ca1 100644 --- a/src/components/providers-table.tsx +++ b/src/components/providers-table.tsx @@ -166,8 +166,11 @@ export function ProvidersTable({ providers }: { providers: Row[] }) { {p.appearances}

    -
    -

    Top 1

    +
    +

    Wins (per-chain)

    0 ? "text-good" : "text-ink-faint" diff --git a/src/lib/bench-template.test.ts b/src/lib/bench-template.test.ts index 3e48a332..ccca140d 100644 --- a/src/lib/bench-template.test.ts +++ b/src/lib/bench-template.test.ts @@ -78,4 +78,58 @@ describe("renderTemplate", () => { const empty = bench([]); expect(renderTemplate("{{best_name}}", empty)).toBe("{{best_name}}"); }); + + describe("chain-aware placeholders", () => { + function withChains(): Benchmark { + const b = bench([r("alpha", "Alpha", 100), r("beta", "Beta", 250)]); + // Solana leader = Alpha (100ms), trailer = Beta (250ms). + // Base leader = Beta (180ms), trailer = Alpha (400ms). + b.bestPerChain = { + solana: r("alpha", "Alpha", 100), + base: r("beta", "Beta", 180), + }; + b.worstPerChain = { + solana: r("beta", "Beta", 250), + base: r("alpha", "Alpha", 400), + }; + return b; + } + + test("resolves {{best_name:chain:X}} against bestPerChain", () => { + expect(renderTemplate("{{best_name:chain:solana}}", withChains())).toBe( + "Alpha", + ); + expect(renderTemplate("{{best_name:chain:base}}", withChains())).toBe( + "Beta", + ); + }); + + test("resolves {{best_p50:chain:X}} with unit formatting", () => { + expect(renderTemplate("{{best_p50:chain:solana}}", withChains())).toBe( + "100 ms", + ); + }); + + test("resolves {{worst_name:chain:X}} and {{worst_p50:chain:X}}", () => { + expect(renderTemplate("{{worst_name:chain:base}}", withChains())).toBe( + "Alpha", + ); + expect(renderTemplate("{{worst_p50:chain:base}}", withChains())).toBe( + "400 ms", + ); + }); + + test("leaves the placeholder untouched when the chain isn't stashed", () => { + expect(renderTemplate("{{best_name:chain:bnb}}", withChains())).toBe( + "{{best_name:chain:bnb}}", + ); + }); + + test("leaves chain placeholders untouched when bestPerChain is absent", () => { + const noChain = bench([r("alpha", "Alpha", 100)]); + expect(renderTemplate("{{best_name:chain:solana}}", noChain)).toBe( + "{{best_name:chain:solana}}", + ); + }); + }); }); diff --git a/src/lib/bench-template.ts b/src/lib/bench-template.ts index 23a68e19..1213be1e 100644 --- a/src/lib/bench-template.ts +++ b/src/lib/bench-template.ts @@ -11,22 +11,32 @@ * * Supported placeholders, all enclosed in `{{ ... }}`: * - * {{p50:}} live p50 for a provider, formatted with the - * benchmark's unit. e.g. "6.6 min" / "397 ms". - * {{p99:}} same for p99. - * {{mean:}} same for mean. - * {{name:}} provider display name. - * {{best_name}} name of the leading provider (best p50). - * {{best_p50}} p50 of the leader, formatted. - * {{worst_name}} name of the trailing provider. - * {{worst_p50}} p50 of the trailing provider, formatted. - * {{count}} number of providers with live data. + * {{p50:}} live p50 for a provider, formatted with + * the benchmark's unit. e.g. "6.6 min" / + * "397 ms". + * {{p99:}} same for p99. + * {{mean:}} same for mean. + * {{name:}} provider display name. + * {{best_name}} name of the leading provider (best p50). + * {{best_p50}} p50 of the leader, formatted. + * {{worst_name}} name of the trailing provider. + * {{worst_p50}} p50 of the trailing provider, formatted. + * {{best_name:chain:}} name of the provider that leads on chain + * specifically. Defuses the cross-chain + * aggregate bias (a Solana-only provider + * mechanically winning head-lag because + * Solana slots are sub-second). Requires the + * spec to declare `dimensions.chain`. + * {{best_p50:chain:}} p50 of the per-chain leader, formatted. + * {{worst_name:chain:}} trailing provider on chain . + * {{worst_p50:chain:}} p50 of the trailing provider on chain . + * {{count}} number of providers with live data. * * Unknown placeholders are left untouched so a typo in the YAML can't * silently erase a sentence. */ -import type { Benchmark } from "@/types/benchmark"; +import type { Benchmark, ProviderResult } from "@/types/benchmark"; import { liveResults } from "@/lib/provider-filters"; import { fmtUnit } from "@/lib/format"; @@ -37,6 +47,22 @@ import { fmtUnit } from "@/lib/format"; // the literal `{{p50:slug}}` in the rendered page. const TEMPLATE_RE = /\{\{\s*([a-z][a-z0-9_]*)(?::([a-z0-9-]+))?\s*\}\}/gi; +// Chain-aware variants. Resolved BEFORE TEMPLATE_RE so the longer form +// gets first dibs; whatever is left falls through to the unfiltered +// resolver. Pattern: {{best_name:chain:solana}}, {{worst_p50:chain:bnb}}. +const CHAIN_TEMPLATE_RE = + /\{\{\s*(best_name|best_p50|worst_name|worst_p50):chain:([a-z0-9_-]+)\s*\}\}/gi; + +/** Per-chain leader / trailer lookups against the Benchmark stash + * populated by spec.ts. Inlined (not re-imported from spec.ts) to + * avoid a spec.ts → bench-template.ts → spec.ts circular import. */ +function bestForChain(b: Benchmark, chain: string): ProviderResult | undefined { + return b.bestPerChain?.[chain]; +} +function worstForChain(b: Benchmark, chain: string): ProviderResult | undefined { + return b.worstPerChain?.[chain]; +} + export function renderTemplate(text: string, benchmark: Benchmark): string { if (!text || text.indexOf("{{") === -1) return text; const live = liveResults(benchmark.results); @@ -46,7 +72,34 @@ export function renderTemplate(text: string, benchmark: Benchmark): string { const best = sorted[0]; const worst = sorted[sorted.length - 1]; - return text.replace(TEMPLATE_RE, (whole, keyword: string, arg?: string) => { + // Resolve chain-scoped placeholders first so they don't fall through to + // the unfiltered resolver as unknown tokens. + const withChain = text.replace( + CHAIN_TEMPLATE_RE, + (whole, keyword: string, chain: string) => { + const k = keyword.toLowerCase(); + const chainKey = chain.toLowerCase(); + if (k === "best_name") { + const lead = bestForChain(benchmark, chainKey); + return lead ? lead.name : whole; + } + if (k === "best_p50") { + const lead = bestForChain(benchmark, chainKey); + return lead ? fmtUnit(lead.ms.p50, benchmark.unit) : whole; + } + if (k === "worst_name") { + const trailer = worstForChain(benchmark, chainKey); + return trailer ? trailer.name : whole; + } + if (k === "worst_p50") { + const trailer = worstForChain(benchmark, chainKey); + return trailer ? fmtUnit(trailer.ms.p50, benchmark.unit) : whole; + } + return whole; + }, + ); + + return withChain.replace(TEMPLATE_RE, (whole, keyword: string, arg?: string) => { const k = keyword.toLowerCase(); switch (k) { case "p50": diff --git a/src/lib/citation.ts b/src/lib/citation.ts index 63d22db8..790805f6 100644 --- a/src/lib/citation.ts +++ b/src/lib/citation.ts @@ -30,6 +30,26 @@ 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 }; } +/** + * Chain-scoped variant of `leader()`. Returns the leading provider on a + * specific chain when the bench precomputed a `bestPerChain` stash + * (declared `dimensions.chain` in YAML and rendered the unfiltered view). + * + * Use this for chain-aware headline copy, badge endpoints and OG images + * so we don't end up parroting the biased cross-chain aggregate winner + * (e.g. a Solana-only provider that mechanically beats EVM providers on + * head-lag because Solana slots are sub-second). + */ +export function leaderForChain( + b: Benchmark, + chain: string, +): { name: string; slug: string; value: number } | null { + if (b.status !== "live") return null; + const r = b.bestPerChain?.[chain]; + if (!r) return null; + return { name: r.name, slug: r.slug, value: r.ms.p50 }; +} + /** Short factual sentence ready to paste into an article. Templated, no LLM. */ export function headlineSentence(b: Benchmark): string { const top = leader(b); diff --git a/src/lib/jsonld.ts b/src/lib/jsonld.ts index 1624f658..9dcf9d89 100644 --- a/src/lib/jsonld.ts +++ b/src/lib/jsonld.ts @@ -66,6 +66,21 @@ export function stripFaqMarkdown(input: string): string { type FaqItem = { q: string; a: string }; +/** + * Normalise a chain value into a JSON-LD-safe @id suffix. Lowercased, + * letters/digits/hyphens only — strips anything that would need URL + * encoding when concatenated into an @id fragment. Returns `null` for + * the sentinel "all" and for empty input so call sites can fall back to + * the unscoped @id seamlessly. + */ +export function chainIdFragment(chain?: string | null): string | null { + if (!chain) return null; + const trimmed = chain.trim().toLowerCase(); + if (!trimmed || trimmed === "all") return null; + const cleaned = trimmed.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, ""); + return cleaned || null; +} + /** * Build a top-level FAQPage JSON-LD object per https://schema.org/FAQPage. * @@ -79,13 +94,23 @@ type FaqItem = { q: string; a: string }; * * Caller is responsible for guarding empty `faq` arrays. Returns `null` * when there's nothing to emit so the call site can skip the script tag. + * + * `chain` is an optional context tag. When set, the @id gets a + * `-` suffix so per-chain variants don't collide with the + * unscoped FAQPage @id in Google's index. */ -export function buildFaqPageJsonLd(faq: FaqItem[] | undefined, pageUrl: string): Record | null { +export function buildFaqPageJsonLd( + faq: FaqItem[] | undefined, + pageUrl: string, + chain?: string | null, +): Record | null { if (!faq || faq.length === 0) return null; + const suffix = chainIdFragment(chain); + const id = suffix ? `${pageUrl}#faq-${suffix}` : `${pageUrl}#faq`; return { "@context": "https://schema.org", "@type": "FAQPage", - "@id": `${pageUrl}#faq`, + "@id": id, mainEntity: faq.map((item) => ({ "@type": "Question", name: stripFaqMarkdown(item.q), diff --git a/src/lib/page-metadata.ts b/src/lib/page-metadata.ts index 5d8d7d33..47bb96d9 100644 --- a/src/lib/page-metadata.ts +++ b/src/lib/page-metadata.ts @@ -12,26 +12,35 @@ const SITE_ORIGIN = "https://openchainbench.com"; * Pass `path` with a leading slash. `title` should be the bare page title * (without the " · OpenChainBench" suffix — the social card needs it spelled * out so the preview reads cleanly outside the tab template). + * + * `chain` is an optional context tag. When provided, it's surfaced in the + * `og:url` query string so per-chain shares get distinct link previews, + * but the canonical URL stays unfiltered so Google consolidates rank + * signal on the hub page instead of fragmenting it across N chain + * variants (classic duplicate-content avoidance). */ export function pageMetadata({ path, title, description, + chain, }: { path: string; title: string; description: string; + chain?: string | null; }): Metadata { - const url = `${SITE_ORIGIN}${path}`; + const canonical = `${SITE_ORIGIN}${path}`; + const socialUrl = chain && chain !== "all" ? `${canonical}?chain=${chain}` : canonical; const social = title.includes("OpenChainBench") ? title : `${title} · OpenChainBench`; const meta: Metadata = { title, description, - alternates: { canonical: url }, + alternates: { canonical }, openGraph: { title: social, description, - url, + url: socialUrl, type: "website", siteName: "OpenChainBench", }, diff --git a/src/lib/per-chain-contract.ts b/src/lib/per-chain-contract.ts new file mode 100644 index 00000000..a9bd080c --- /dev/null +++ b/src/lib/per-chain-contract.ts @@ -0,0 +1,54 @@ +/** + * Per-chain contract bridge. + * + * Agent 1 owns the shared contract additions in `src/types/benchmark.ts` + * (the `bestPerChain` field on `Benchmark`) and `src/lib/spec.ts` + * (the `bestForChain` helper). When those land on the parent branch, the + * fields are part of the canonical type and this file's augmentation + * becomes redundant (still type-compatible — fields with identical + * definitions merge cleanly under declaration merging). + * + * This module exists so the products + badge surface (this agent's lane) + * can compile against the contract WITHOUT having to touch + * `src/types/benchmark.ts` or `src/lib/spec.ts` — both forbidden files. + * + * Once Agent 1's branch is merged, this file becomes a no-op but is left + * in place to centralize the contract documentation and the runtime + * accessor helpers that depend on it. + */ + +import type { Benchmark, ProviderResult } from "@/types/benchmark"; + +declare module "@/types/benchmark" { + interface BenchmarkPerChainExtension { + /** Per-chain leader stash (chain slug → leader ProviderResult). */ + bestPerChain?: Record; + } +} + +/** + * Read-only accessor for `bestPerChain` that tolerates the field being + * absent (e.g. during the merge window when Agent 1's branch hasn't + * landed yet). Returns undefined when the bench doesn't expose + * per-chain data, never throws. + */ +export function readBestPerChain( + b: Benchmark, +): Record | undefined { + // The field is optional on the type once Agent 1's PR lands; until then + // the structural access is the safe path. + const v = (b as unknown as { bestPerChain?: Record }) + .bestPerChain; + return v && typeof v === "object" ? v : undefined; +} + +/** + * Mirrors Agent 1's `bestForChain` helper. Pure lookup with no Prom call; + * the per-chain leaders are precomputed at spec-load time. + */ +export function bestForChain( + b: Benchmark, + chain: string, +): ProviderResult | undefined { + return readBestPerChain(b)?.[chain]; +} diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 45fad1ac..d003a9f2 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -11,6 +11,7 @@ import { cache } from "react"; import { getBenchmarks } from "@/data/benchmarks"; import { liveResults } from "@/lib/provider-filters"; +import { readBestPerChain } from "@/lib/per-chain-contract"; import type { Benchmark, ProviderResult } from "@/types/benchmark"; /** @@ -84,10 +85,37 @@ function canonicalize(slug: string): { slug: string; name: string } { } export type ProviderAppearance = { - benchmark: Pick; + benchmark: Pick< + Benchmark, + | "slug" + | "title" + | "subtitle" + | "category" + | "metric" + | "unit" + | "higherIsBetter" + | "status" + | "lastRunAt" + > & { + /** Chain dimension values from the spec, when present. Stored on the + * appearance so /products/[slug] can render chain-aware chips without + * re-loading the full benchmark. Mirrors `benchmark.dimensions.chain`. */ + chainDimensions?: { value: string; label: string }[]; + /** Per-chain leader stash for the bench, when computed by spec.ts. + * Same shape as `Benchmark.bestPerChain` — included here so consumers + * can compute per-chain rank for this provider without a full bench + * re-fetch. */ + bestPerChain?: Record; + }; result: ProviderResult; rank: number; totalRanked: number; + /** Per-chain rank for this provider on this bench. Only populated when + * the bench declares chain dimensions AND bestPerChain has at least one + * entry. Key = chain slug (matching dimensions.chain[].value), value = + * { rank, totalRanked } computed within the providers present on that + * chain. Renderers can fall back to `rank` when this is empty. */ + rankPerChain?: Record; }; export type ProviderProfile = { @@ -95,10 +123,20 @@ export type ProviderProfile = { name: string; type?: ProviderResult["type"]; appearances: ProviderAppearance[]; - /** Count of benchmarks where this provider ranks #1. */ + /** Total count of #1 finishes credited to this provider. Counting rule: + * - For benches WITHOUT chain dimensions: 1 win per aggregate #1. + * - For benches WITH chain dimensions: 1 win per chain led (so a + * Solana-only provider that leads on Solana earns +1 per such bench + * rather than free credits from biased cross-chain aggregates). See + * the inline rule comment in getProviders() for the rationale. */ wins: number; /** Categories the provider appears in. used for the index page filter. */ categories: Benchmark["category"][]; + /** Aggregate per-chain leadership across all benches. Key = chain slug, + * value = number of benches the provider leads on that chain. Empty + * record when the provider hasn't led on any chain. Useful for sort + * /filter ("show all #1 on Solana") on the products index. */ + chainWins?: Record; }; function rankProviders(b: Benchmark): ProviderResult[] { @@ -108,6 +146,52 @@ function rankProviders(b: Benchmark): ProviderResult[] { ); } +/** + * Compute per-chain rank for every provider on a bench. Bench must declare + * `dimensions.chain` and have a non-empty `bestPerChain` for any rank to be + * recorded. + * + * Approximation: spec.ts only stashes the *leader* per chain (one extra Prom + * roundtrip per chain). To express other providers' rank-per-chain, we use a + * coarse fallback: anyone present in the unfiltered `results` is ranked by + * the bench's standard direction (lower-is-better or higher-is-better) + * within the live result set, and the leader's slot is forcibly overridden + * with rank 1 for that chain. This is a soft signal — the bench page chain + * tabs are authoritative — but it is enough to flag chain-restricted + * providers like GMGN as "#1 on Solana only" on /products/[slug]. + */ +function rankPerChainForBench( + b: Benchmark, +): Record> { + const out: Record> = {}; + const bestPerChain = readBestPerChain(b); + if (!bestPerChain || !b.dimensions?.chain) return out; + const liveSorted = rankProviders(b); + if (liveSorted.length === 0) return out; + for (const chain of b.dimensions.chain) { + if (chain.value === "all") continue; + const leader = bestPerChain[chain.value]; + if (!leader) continue; + const perProvider = new Map(); + const leaderLc = leader.slug.toLowerCase(); + const leaderIdx = liveSorted.findIndex((r) => r.slug.toLowerCase() === leaderLc); + liveSorted.forEach((r, idx) => { + const lc = r.slug.toLowerCase(); + if (lc === leaderLc) { + perProvider.set(lc, { rank: 1, totalRanked: liveSorted.length }); + return; + } + // Anyone ranked above the leader in the unfiltered set drops by one + // slot here (since the leader skips ahead of them on this chain). + const rankOnChain = + leaderIdx !== -1 && idx < leaderIdx ? idx + 2 : idx + 1; + perProvider.set(lc, { rank: rankOnChain, totalRanked: liveSorted.length }); + }); + out[chain.value] = perProvider; + } + return out; +} + export const getProviders = cache(async (): Promise => { const benches = await getBenchmarks(); const byKey = new Map(); @@ -123,6 +207,14 @@ export const getProviders = cache(async (): Promise => { ranked.forEach((r, idx) => rankBySlug.set(r.slug.toLowerCase(), idx)); const total = ranked.length; + // Per-chain ranks (soft signal — see rankPerChainForBench notes). + // Empty record when the bench has no chain dimensions or no + // bestPerChain data this cycle. + const perChainRanks = b.status === "live" ? rankPerChainForBench(b) : {}; + const benchBestPerChain = readBestPerChain(b); + const hasChainDimensions = + (b.dimensions?.chain?.filter((c) => c.value !== "all").length ?? 0) > 0; + b.results.forEach((r) => { const canon = canonicalize(r.slug); const key = canon.slug; @@ -132,6 +224,35 @@ export const getProviders = cache(async (): Promise => { // canonical key. const idx = rankBySlug.get(r.slug.toLowerCase()); const isRanked = idx !== undefined; + // Collect per-chain rank for this provider on this bench (if any). + const rankPerChain: Record = {}; + for (const [chain, perProvider] of Object.entries(perChainRanks)) { + const entry = perProvider.get(r.slug.toLowerCase()); + if (entry) rankPerChain[chain] = entry; + } + const hasPerChain = Object.keys(rankPerChain).length > 0; + + // Wins rule (documented): + // - A "win" credit is one #1 finish. + // - When the bench declares NO chain dimensions, a provider earns + // +1 win iff they are #1 on the unfiltered aggregate. Pre-existing + // semantics, untouched. + // - When the bench DOES declare chain dimensions, the unfiltered + // aggregate is chain-mix biased (a Solana-only provider can + // mechanically beat the field). We don't credit a win for the + // aggregate #1 here. Instead, we credit +1 win for EACH chain + // this provider leads on (per bestPerChain). This makes the + // wins count reflect real chain-scoped #1 finishes, not biased + // cross-chain rolling totals. + let winsEarned = 0; + if (hasChainDimensions) { + for (const entry of Object.values(rankPerChain)) { + if (entry.rank === 1) winsEarned += 1; + } + } else if (isRanked && idx === 0) { + winsEarned = 1; + } + const appearance: ProviderAppearance = { benchmark: { slug: b.slug, @@ -143,26 +264,49 @@ export const getProviders = cache(async (): Promise => { higherIsBetter: b.higherIsBetter, status: b.status, lastRunAt: b.lastRunAt, + chainDimensions: b.dimensions?.chain, + bestPerChain: benchBestPerChain, }, result: r, rank: isRanked ? (idx as number) + 1 : 0, totalRanked: total, + ...(hasPerChain ? { rankPerChain } : {}), }; + // Collect chain-leadership tally so the profile can surface + // "Solana leader on 3 benches" etc. + const chainsLed: string[] = []; + for (const [chain, entry] of Object.entries(rankPerChain)) { + if (entry.rank === 1) chainsLed.push(chain); + } + if (existing) { existing.appearances.push(appearance); if (!existing.categories.includes(b.category)) { existing.categories.push(b.category); } - if (isRanked && idx === 0) existing.wins += 1; + existing.wins += winsEarned; if (!existing.type && r.type) existing.type = r.type; + if (chainsLed.length > 0) { + existing.chainWins = existing.chainWins ?? {}; + for (const c of chainsLed) { + existing.chainWins[c] = (existing.chainWins[c] ?? 0) + 1; + } + } } else { byKey.set(key, { slug: canon.slug, name: canon.name, type: r.type, appearances: [appearance], - wins: isRanked && idx === 0 ? 1 : 0, + wins: winsEarned, categories: [b.category], + ...(chainsLed.length > 0 + ? { + chainWins: Object.fromEntries( + chainsLed.map((c) => [c, 1] as const), + ), + } + : {}), }); } }); diff --git a/src/lib/spec.ts b/src/lib/spec.ts index be443e11..21cf66ec 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -18,6 +18,7 @@ import type { Benchmark, ProviderResult } from "@/types/benchmark"; import { Prometheus } from "@/lib/prometheus"; import { SpecSchema, type Spec } from "@/lib/spec-schema"; import { renderBenchmarkText } from "@/lib/bench-template"; +import { liveResults as liveProviderResults } from "@/lib/provider-filters"; import { readSnapshot, snapshotFromBenchmark, @@ -346,10 +347,57 @@ async function specToBenchmark( }); } } + // Per-chain leaders/trailers: computed only on the unfiltered "All" + // view of benches that declare `dimensions.chain`. Fan out one extra + // tryLoadLive() per chain value (excluding "all") with the chain + // label injected via applyDimensionsToSpec, then pick the live + // leader + trailer for that chain. This powers the + // `{{best_name:chain:X}}` placeholders + chain-aware OG/badge + // surfaces. We deliberately don't augment unavailable providers + // here: for per-chain leader we only care which provider actually + // reported data on that chain. Failures are tolerated — a chain + // with no Prom data just doesn't show up in bestPerChain. + let bestPerChain: Record | undefined; + let worstPerChain: Record | undefined; + if (!isFiltered && spec.dimensions?.chain && spec.dimensions.chain.length > 0) { + const chainValues = spec.dimensions.chain + .map((c) => c.value) + .filter((v) => v !== "all"); + const perChainEntries = await Promise.all( + chainValues.map(async (chain) => { + const chainSpec = applyDimensionsToSpec(spec, { chain }); + const chainLive = await tryLoadLive(chainSpec, true); + if (!chainLive) return [chain, undefined, undefined] as const; + for (const r of chainLive.results) r.availability = "live"; + const liveForChain = liveProviderResults(chainLive.results); + if (liveForChain.length === 0) { + return [chain, undefined, undefined] as const; + } + const sorted = [...liveForChain].sort((a, b) => + spec.higher_is_better ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50, + ); + return [chain, sorted[0], sorted[sorted.length - 1]] as const; + }), + ); + const bests: Record = {}; + const worsts: Record = {}; + for (const [chain, leader, trailer] of perChainEntries) { + if (leader) bests[chain] = leader; + if (trailer) worsts[chain] = trailer; + } + if (Object.keys(bests).length > 0) bestPerChain = bests; + if (Object.keys(worsts).length > 0) worstPerChain = worsts; + } + // 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. - const rendered = renderBenchmarkText({ ...editorial, ...live }); + const rendered = renderBenchmarkText({ + ...editorial, + ...live, + bestPerChain, + worstPerChain, + }); // Persist a snapshot of the runtime data so a future cold start // during a Prom blackout can still render this bench. Only the // unfiltered "All" view is snapshotted: filtered variants (per @@ -631,3 +679,21 @@ function parseDurationSec(d: string): number | null { const unit = m[2]; return n * { s: 1, m: 60, h: 3600, d: 86_400 }[unit as "s" | "m" | "h" | "d"]; } + +/** + * Lookup helper for the per-chain leader stash. Returns the ProviderResult + * that leads on `chain` (e.g. "solana"), or undefined when the bench + * doesn't declare chain dimensions, when the chain isn't in the spec, or + * when no live data was collected for that chain this cycle. + * + * Consumed by the chain-aware template placeholders (bench-template.ts), + * the chain-aware OG image / badge endpoints (SEO + API surfaces), and + * the products pages that want to call out a chain-specific winner + * instead of the biased unfiltered aggregate. + */ +export function bestForChain( + b: Benchmark, + chain: string, +): ProviderResult | undefined { + return b.bestPerChain?.[chain]; +} diff --git a/src/types/benchmark.ts b/src/types/benchmark.ts index 2bb77c1b..8824c405 100644 --- a/src/types/benchmark.ts +++ b/src/types/benchmark.ts @@ -119,6 +119,23 @@ export type Benchmark = { }; category: "Aggregators" | "Bridges" | "Blockchains" | "Trading" | "Wallets" | "RPCs"; results: ProviderResult[]; + /** Per-chain leader, computed only on the unfiltered ("All chains") view + * when the spec declares `dimensions.chain`. Key = chain slug from the + * YAML (e.g. "solana", "base", "bnb"); excludes "all". Value = the live + * ProviderResult that leads on that chain. + * + * Motivation: the unfiltered aggregate is biased by chain mix (e.g. + * Solana 400 ms slots vs Base 2 s blocks make Solana-only providers + * mechanically beat cross-chain ones on head-lag). Per-chain winners + * are computed via extra Prom queries with `chain=""` injected, so + * headline copy / OG image / badge endpoint can call out the leader on + * each chain instead of one biased global winner. */ + bestPerChain?: Record; + /** Per-chain trailing provider, populated in lockstep with + * `bestPerChain` (same key set, same population conditions). Powers + * the `{{worst_name:chain:X}}` / `{{worst_p50:chain:X}}` editorial + * placeholders. */ + worstPerChain?: Record; findings: string[]; methodology: string[]; source: string;