diff --git a/alternatives/alchemy.yml b/alternatives/alchemy.yml index 97fd309c..68991814 100644 --- a/alternatives/alchemy.yml +++ b/alternatives/alchemy.yml @@ -5,7 +5,7 @@ description: Multi-chain node + enhanced API provider benchmark: aggregator-head-lag intro: | - Alchemy serves RPC endpoints and enhanced data APIs across EVM chains and Solana. For real-time feeds, the metric that matters most is freshness. how long between a transaction landing on chain and the API reflecting it? Below is the live head-lag for each major onchain data provider, measured against a canonical-tip archive node and refreshed every minute. Lower is faster. + Alchemy is an EVM-first node and API provider whose surface is shaped by its enhanced endpoints (`alchemy_getAssetTransfers`, NFT API, webhooks, getTokenBalances, the simulation and debug suite, the account-abstraction bundler) rather than by a raw RPC tuned for edge latency. Pricing is metered in compute units, which charges more for an enhanced call than for a plain `eth_call`, so the ceiling shows up first on teams streaming high-cardinality events into a backend rather than on dapp front-ends issuing single reads. Solana coverage exists but sits on a stack built around Ethereum and its L2s. Teams that need a live view of trades on Base, BNB Chain or Solana usually leave Alchemy at the point where the enhanced API list stops mapping to what the product actually reads from chain. seo_title: Alchemy alternatives. live head-lag benchmark across data providers seo_description: Compare Alchemy alternatives on real-time data freshness. Live head-lag against a canonical archive node, refreshed every minute and published openly. diff --git a/alternatives/bitquery.yml b/alternatives/bitquery.yml index 32564fbb..e3280c40 100644 --- a/alternatives/bitquery.yml +++ b/alternatives/bitquery.yml @@ -5,7 +5,7 @@ description: GraphQL API for blockchain data, multi-chain benchmark: network-coverage intro: | - Bitquery is a GraphQL data API covering many chains. If you're sizing alternatives, the first axis is breadth. how many networks does each provider officially support? Below is the live count from each major onchain data provider, scraped from their public supported-networks endpoint and refreshed every six hours. + Bitquery is a GraphQL surface on top of indexed onchain data, with the schema as the actual product: queries declare exactly the fields needed across transfers, trades, DEX events and account state, and the indexer fans those out across chains. Pricing is point-based, with each field, filter and join consuming from a monthly point budget rather than from a flat request quota, which means a single dashboard with many panels can hit the ceiling well before request volume looks high. Chain coverage is broad but uneven in depth: some networks expose full DEX trade indexing, others only basic transfers. The team usually compared next is the one whose chain list either covers a long-tail network Bitquery does not, or covers the same network with a flatter pricing model that does not penalise wide GraphQL fragments on a per-field basis. seo_title: Bitquery alternatives. live network coverage benchmark seo_description: Compare Bitquery alternatives on the number of blockchains each major onchain data provider officially supports. Live data, refreshed every six hours. diff --git a/alternatives/coingecko.yml b/alternatives/coingecko.yml index afe7e614..6fc8c981 100644 --- a/alternatives/coingecko.yml +++ b/alternatives/coingecko.yml @@ -5,7 +5,7 @@ description: Crypto price + market data API, multi-chain benchmark: network-coverage intro: | - CoinGecko offers a broad price + market data REST API used across thousands of crypto apps. The first axis people compare alternatives on is breadth. how many blockchains does each provider officially support? Below is the live count from each major onchain data API, scraped directly from their public supported-networks endpoint and refreshed every six hours. + CoinGecko is a coin-centric price and market data REST API built around a curated asset list (the `/coins` universe) rather than around on-chain events. Tokens are added through a listing process, prices land on the API after that process completes, and the free tier is famously throttled (around 30 requests per minute on the demo key) which pushes any real product onto a paid plan within days of integration. The on-chain DEX side ships as a separate `/onchain` namespace inherited from GeckoTerminal, with its own quotas. Teams usually leave CoinGecko at one of two points: when the launch cadence of the tokens they care about outpaces the listing pipeline, or when the per-minute cap on the free tier forces a migration before the product is monetised enough to justify the enterprise tier. seo_title: CoinGecko alternatives. live network coverage benchmark seo_description: Compare CoinGecko alternatives on the number of blockchains each major onchain data API officially supports. Live data, refreshed every six hours. diff --git a/alternatives/helius.yml b/alternatives/helius.yml deleted file mode 100644 index e5d9963b..00000000 --- a/alternatives/helius.yml +++ /dev/null @@ -1,13 +0,0 @@ -slug: helius -target_product: Helius -target_url: https://helius.dev -description: Solana RPC provider with a transaction landing service (Sender) -benchmark: solana-tx-landing-latency - -intro: | - Helius runs a Solana RPC provider with a dedicated transaction landing service called Sender, available in both default mode (with Jito fan-out) and `swqos_only=true` mode (own-path only, no Jito leg). The only question that matters to a Solana trader picking a landing service is how many slots a signed mainnet transaction takes to reach the confirmed state on chain, because Solana confirmation is a slot-level event and a 1-slot difference is roughly 400 ms (enough for a MEV bot to front-run a competitor). This benchmark probes five landing services from a us-east Railway region, once per hour, by submitting an identical signed mainnet transaction to each in parallel. The payload is a compute-budget instruction pair (50k CU limit, 50k micro-lamport/CU price), a 1-lamport self-transfer, the per-service tip transfer at a pre-registered floor and an OCB-prefixed memo for forensic traceability. The headline metric is slot delta (`land_slot` minus `submit_slot`) captured from the `signatureSubscribe` WebSocket notification's `context.slot` field at commitment level confirmed. Wall-clock milliseconds is published alongside as a derived approximation but the canonical RTT-independent number is the slot delta. Helius is probed in `swqos_only=true` mode against `ewr-sender.helius-rpc.com/fast` to isolate the own-path from the Jito leg, with a 10,000-lamport tip floor. Other services in the V0-Lean cohort: Jito (control baseline), Astralane Iris (tip-refund), Nozomi by Temporal Labs (1M-lamport hard floor) and the Mobula multi-RPC fan-out aggregator. p50 and p99 slot delta are computed over a rolling 7-day window (168 samples per service per cell). - -seo_title: Helius alternatives. Live Solana tx landing slot delta benchmark -seo_description: Compare Helius Sender against Jito, Astralane, Nozomi and Mobula on slot delta from submit to confirmed. Signed mainnet probes every hour from us-east, 7-day window. - -status: live diff --git a/alternatives/jito.yml b/alternatives/jito.yml deleted file mode 100644 index 08e00369..00000000 --- a/alternatives/jito.yml +++ /dev/null @@ -1,13 +0,0 @@ -slug: jito -target_product: Jito -target_url: https://jito.network -description: Solana block engine with atomic bundles and tip auction -benchmark: solana-tx-landing-latency - -intro: | - Jito operates a Solana block engine with atomic bundles and a tip auction, live since 2022, and acts as the baseline that every premium landing service must beat: Helius default mode, Astralane and Nozomi all internally route some flow through Jito, so a same-slot result against the Jito control on a given cycle means the suspect service is essentially using Jito as its inclusion path. The only question that matters when picking a landing service is how many slots a signed mainnet transaction takes to reach the confirmed state on chain, because Solana confirmation is a slot-level event and a 1-slot difference is roughly 400 ms (enough for a MEV bot to front-run a competitor). This benchmark probes five services from a us-east Railway region, once per hour, by submitting an identical signed mainnet transaction in parallel. The payload is a compute-budget instruction pair, a 1-lamport self-transfer, the per-service tip transfer at a pre-registered floor and an OCB-prefixed memo. Jito is probed at `ny.mainnet.block-engine.jito.wtf/api/v1/transactions` with a 10,000-lamport tip floor. The headline metric is slot delta (`land_slot` minus `submit_slot`) read from the `signatureSubscribe` notification's `context.slot` at commitment confirmed; wall-clock ms is published alongside as a derived approximation. p50 and p99 slot delta are computed over a rolling 7-day window (168 samples per service per cell). The cohort includes Helius Sender in `swqos_only` mode (isolated own-path), Astralane Iris (tip-refund mechanism), Nozomi by Temporal Labs (1M-lamport hard floor) and the Mobula multi-RPC fan-out aggregator. Each probe carries the same blockhash and a comparable tip so the slot delta column reads as a direct routing comparison rather than a tip-auction wallclock race. - -seo_title: Jito alternatives. Live Solana tx landing slot delta benchmark -seo_description: Compare Jito against Helius Sender, Astralane, Nozomi and Mobula on slot delta from submit to confirmed. Signed mainnet probes every hour from us-east, 7-day window. - -status: live diff --git a/alternatives/lifi.yml b/alternatives/lifi.yml index 9e04eaa2..997f3475 100644 --- a/alternatives/lifi.yml +++ b/alternatives/lifi.yml @@ -5,7 +5,7 @@ description: Cross-chain bridge + DEX aggregator benchmark: bridge-quote-latency intro: | - Li.Fi is a cross-chain aggregator that quotes routes across many bridges and DEXes. If you're sizing alternatives, quote latency is one of the most user-felt axes. how long does the aggregator take to return a usable route for a given pair? Below is the live measurement for the four major bridge aggregators on identical routes (Solana ↔ Base ↔ Arbitrum), across notional sizes ($5/$50/$300). Numbers refresh every five minutes. + Li.Fi is a cross-chain aggregator that stitches together third-party bridges (Stargate, Across, Connext, Hop and others) and DEX aggregators behind a single `/quote` endpoint. The quote response carries a full execution plan with calldata, approval steps and gas estimates, which is convenient for a wallet integration but means the latency reflects fan-out across many underlying providers plus the time to pick a winner. Each upstream has its own SLA, and a slow leg in the cohort drags the quote even when the chosen route is fast. The trade-off is breadth for tail-latency: Li.Fi covers a long tail of bridges that intent-routed competitors do not, but pays for that breadth on the p95 of its `/quote` call. Teams move off when the latency of the fastest route they actually use exceeds what a narrower router can return for the same pair. seo_title: Li.Fi alternatives. Debridge, Mobula & Relay live quote latency benchmark seo_description: Compare Li.Fi alternatives on cross-chain quote latency. Debridge, Mobula and Relay measured on identical routes, refreshed every five minutes. diff --git a/alternatives/pump-portal.yml b/alternatives/pump-portal.yml index fa18f631..f30f60a5 100644 --- a/alternatives/pump-portal.yml +++ b/alternatives/pump-portal.yml @@ -5,7 +5,7 @@ description: Real-time pump.fun token data API on Solana benchmark: aggregator-head-lag intro: | - Pump Portal is a real-time API serving pump.fun-launched tokens on Solana. websocket trade events, new pool detection, swap data. Looking for an alternative? Below is how the three major onchain data providers compare on the metric that matters most for this kind of feed: time between an on-chain event and its delivery on a WebSocket. Numbers are live, refreshed every minute, measured against a canonical-tip archive node. Lower is faster. + Pump Portal is a single-purpose WebSocket on Solana, scoped to pump.fun and the bonding-curve launchpad ecosystem around it (new token creations, migration to Raydium, per-mint trade events). The surface is intentionally narrow: no support for other chains, no general DEX coverage outside the pump.fun graduates and no historical query layer beyond the live stream. The free tier exists, but high-volume readers route through a paid trading endpoint that adds a per-trade priority fee on top. The provider sits close to the launchpad it tracks rather than near a canonical Solana archive, so the bottleneck is usually the upstream indexer rather than the WebSocket itself. Teams that outgrow the pump.fun-only scope (cross-DEX routing, non-Solana chains, OHLCV history) tend to drop it the moment a second venue or chain enters the product. seo_title: Pump Portal alternatives. Codex, GeckoTerminal & Mobula live latency benchmark seo_description: Looking for an alternative to Pump Portal? Compare Codex, GeckoTerminal and Mobula on real-time blockchain data latency, measured continuously and published openly. diff --git a/alternatives/quicknode.yml b/alternatives/quicknode.yml index b9d3ce8b..17ce396e 100644 --- a/alternatives/quicknode.yml +++ b/alternatives/quicknode.yml @@ -5,7 +5,7 @@ description: Multi-chain RPC + data infrastructure benchmark: aggregator-head-lag intro: | - QuickNode runs RPC endpoints, streams and data APIs across EVM chains, Solana and more. For any pipeline that consumes the chain in real time, the practical question is how quickly the provider sees a fresh block. Below is the live head-lag for each major onchain data provider, measured against a canonical-tip archive node and refreshed every minute. Lower is faster. + QuickNode runs a multi-chain RPC business plus a Marketplace of paid add-ons (token API, NFT API, streams, Yellowstone for Solana) that bolt onto the base node subscription. The pricing model layers per-add-on fees on top of the per-method credit metering, so the bill scales with the breadth of features turned on rather than just request volume; teams running a single workload often discover that two add-ons cost more than the base plan. Chain coverage is broad across EVM and Solana, with regional endpoints in major cloud zones, but the streams product (filter-based push) lives behind a separate quota from the RPC. Real-time price and swap pipelines often end up offloading the head-of-chain stream to a dedicated provider so the QuickNode bill stays on the dapp-read side of the workload. seo_title: QuickNode alternatives. live head-lag benchmark across data providers seo_description: Compare QuickNode alternatives on real-time data freshness. Live head-lag against a canonical archive node, refreshed every minute and published openly. diff --git a/alternatives/relay.yml b/alternatives/relay.yml index 73ebf4b4..f5cf12b5 100644 --- a/alternatives/relay.yml +++ b/alternatives/relay.yml @@ -5,7 +5,7 @@ description: Cross-chain bridge with intent-based routing benchmark: bridge-quote-latency intro: | - Relay is a cross-chain bridge that routes via solver intents. If you're sizing alternatives, quote latency is one of the most felt UX axes. how long does the bridge take to return a usable quote for a given route? Below is the live measurement for the four major bridges on identical routes (Solana ↔ Base ↔ Arbitrum), with multiple notional sizes ($5/$50/$300). Numbers refresh every five minutes. + Relay is an intent-based bridge: the user signs an order, a solver fronts the destination-chain funds and the cross-chain settlement happens off the user's critical path. The architecture compresses the user-felt latency because the quote does not have to scan many upstream venues; the solver inventory itself is the route. That same architecture exposes a different failure mode, which is solver depth: routes the solvers do not maintain inventory on either fail or fall back to a slower path, so coverage on long-tail pairs is narrower than a fan-out aggregator's. Fees are quoted inclusive of the solver spread rather than as a separate gas-plus-bridge breakdown. Teams that switch away usually do so for a chain pair the Relay solvers do not actively support, where a route that exists at all beats a fast quote that does not. seo_title: Relay alternatives. Debridge, Li.Fi & Mobula live quote latency benchmark seo_description: Compare Relay alternatives on cross-chain bridge quote latency. Debridge, Li.Fi and Mobula measured on identical routes, refreshed every five minutes. diff --git a/alternatives/the-graph.yml b/alternatives/the-graph.yml index d40abf0f..c1fbeb56 100644 --- a/alternatives/the-graph.yml +++ b/alternatives/the-graph.yml @@ -5,7 +5,7 @@ description: Decentralised indexing protocol with subgraph queries benchmark: network-coverage intro: | - The Graph powers subgraph queries used by many dApps for indexed onchain data. If you are evaluating alternatives, the first dimension worth checking is the breadth of supported networks. how many chains does each major data provider officially cover? Below is the live count from each provider's public supported-networks endpoint, refreshed every six hours. + The Graph is a decentralised indexing protocol where each dataset (a subgraph) is written, deployed and queried independently rather than read off a pre-built schema. Queries are GQL against indexer nodes, billed in GRT, with a hosted gateway in front. The model means data shape is owned by whoever wrote the subgraph: a missing field, a stale index or a deprecated mapping is on the publisher, not on a central API team. Chain coverage tracks what indexers have chosen to support, which leans heavily EVM and lags on newer L2s and non-EVM networks until someone publishes a subgraph for them. The two reasons teams move off are the cost of running a private subgraph at production load, and the operational burden of debugging an indexer regression on a chain the public hosted service does not cover. seo_title: The Graph alternatives. live network coverage benchmark seo_description: Compare The Graph alternatives on the number of blockchains each major onchain data provider officially supports. Live data, refreshed every six hours. diff --git a/answers/which-solana-rpc-lands-the-most-transactions.yml b/answers/which-solana-rpc-lands-the-most-transactions.yml deleted file mode 100644 index 6921b2f1..00000000 --- a/answers/which-solana-rpc-lands-the-most-transactions.yml +++ /dev/null @@ -1,38 +0,0 @@ -slug: which-solana-rpc-lands-the-most-transactions -question: "Which Solana RPC provider lands the most transactions in 2026?" -short_answer: | - {{best_name}} currently leads Solana transaction landing latency at {{best_p50}} (p50, 24h), the lowest slot-delta between transaction submission and confirmation across the measured RPC field. - -benchmark: solana-tx-landing-latency - -intro: | - Solana trading bots, MEV searchers and on-chain settlement all live or die on the same metric: how reliably and quickly does the RPC endpoint actually land the transaction on a leader's block. Marketing pages publish landing-rate numbers; almost none publish methodology or a live, neutral comparison. This page answers the question that wallet integrations, agent infrastructure and trading desks ask before pasting a URL into production. Which RPC provider is actually landing transactions the fastest right now, measured in slot delta between submission and confirmation, with a probe that runs continuously from multiple regions against the same canonical leader schedule. - -methodology: | - The harness submits a self-signed compute-unit-cheap transaction every few seconds through each RPC provider's submission endpoint, then watches a canonical archive node for the resulting confirmation. The landing latency is the wall-clock slot delta between submission and confirmation, expressed in milliseconds at Solana's 400 ms slot interval. The p50 over 24h is the headline metric; p99 captures the worst 1 percent of cases, where a provider's regional infrastructure or leader proximity surfaces clearly. Probes run from US-East, EU-West and Singapore against the same canonical archive node so any geographic asymmetry shows up as a per-region split, not as a noise floor on the aggregate. - -limitations: - - "Slot delta is not the same as fee. A provider can land transactions fastest while charging a per-transaction priority fee through Jito or a similar bundler; cost-per-landed-transaction is a composite metric the leaderboard does not currently surface." - - "Self-signed test transactions do not exercise the full priority-fee mempool. A real production transaction with a high priority fee and CU budget lands faster than the probes shown here, and the relative ordering can shift when paying for inclusion." - - "Provider landing performance shifts with Solana validator leader schedule. A provider with relayers physically close to today's leader can outperform on this window and lose its lead next epoch when the schedule rotates." - - "This is not a stake-weighted measurement. The harness measures wall-clock landing time at the canonical archive node level, not the share of stake reached at each submission." - -faq: - - q: "What does landing latency actually measure?" - a: "Wall-clock milliseconds between the moment a probe submits a self-signed transaction to a Solana RPC and the moment a canonical archive node sees the same transaction in a confirmed block. Lower is faster. The number is the time from your code calling send to the network treating the transaction as included." - - q: "Why is this different from Solana block time?" - a: "Solana block time is the chain's slot interval, fixed at 400 milliseconds. Landing latency is the time your transaction takes to reach the leader plus the leader's time to include it plus the propagation back to a canonical observer. The chain produces a slot every 400 milliseconds whether or not your transaction lands in it; the question this page answers is which provider's path gets you into the next available slot most consistently." - - q: "Does Jito's bundler beat raw RPC landing?" - a: "On the measured probes, Jito bundling is treated as a provider option, not as a separate metric. When the harness submits through a Jito-aware provider with bundle inclusion enabled, the path includes the bundler. The leaderboard surfaces both Jito and non-Jito providers in the same field so the relative cost of bundling is visible." - - q: "What regions are the probes from?" - a: "US-East, EU-West and Singapore. Cross-region probes catch providers whose landing performance is asymmetric across geography (an RPC fast from EU but slow from APAC is common). The leaderboard reports the cross-region p50; the per-region breakdown is on the bench page." - - q: "Why not measure with my own real workload?" - a: "Real workloads are the ground truth, but they are not comparable across providers because they carry different priority fees, different program calls, and run from different infrastructure. The harness controls for those variables to publish a fair cross-provider comparison; for your specific workload, run the same harness yourself (it is open source) and compare." - -related: - - which-blockchain-has-cheapest-transaction-fees - - which-l1-has-the-fastest-finality - -seo_title: "Which Solana RPC provider lands the most transactions in 2026?" -seo_description: "{{best_name}} leads Solana transaction landing at {{best_p50}} slot delta (p50, 24h) measured live by OpenChainBench. Methodology, regional probes and limitations on this page." -status: live diff --git a/benchmarks/bridge-fee.yml b/benchmarks/bridge-fee.yml index 50a928e9..c50e2600 100644 --- a/benchmarks/bridge-fee.yml +++ b/benchmarks/bridge-fee.yml @@ -3,14 +3,26 @@ slug: bridge-fee number: "003" title: Cheapest cross-chain bridge for USDC at $300 notional -seo_title: "Cheapest cross-chain bridge 2026: deBridge, LI.FI, Mobula, Relay" -seo_description: "Cheapest cross-chain bridge for USDC at $300 notional. Total cost (fees, slippage, dest gas) across Solana, Base, Arbitrum. deBridge, LI.FI, Mobula, Relay." +seo_title: "Cheapest cross-chain bridge 2026: deBridge, LI.FI, Mobula, Relay, Near Intents" +seo_description: "Cheapest cross-chain bridge for USDC at $300 notional. Total cost (fees, slippage, dest gas) across Solana, Base, Arbitrum. deBridge, LI.FI, Mobula, Relay, Near Intents." subtitle: Total cost as a percent of notional, fees plus slippage plus destination gas combined, sampled at $300 USDC across Solana, Base and Arbitrum corridors. category: Bridges status: live metric: Effective fee unit: pct +# Per-destination breakdown. Same rationale as bridge-quote-latency: +# bridges quote different cost structures per corridor (gas on the +# destination dominates short hops, solver spread dominates long +# auction routes), so an aggregate cross-corridor cost percent +# obscures more than it reveals. +dimensions: + chain: + - { value: Base, label: Sol to Base } + - { value: Arbitrum, label: Base to Arb } + - { value: Solana, label: Arb to Sol } + - { value: HyperCore, label: Arb to HyperCore } + seo_intro: | This benchmark measures the cheapest cross-chain bridge live, refreshed every five minutes across the major USDC corridors. The headline figure @@ -106,7 +118,7 @@ providers: p90: quantile_over_time(0.90, bridge_cost_percent{bridge="mobula", amount_usd="300"}[24h]) p99: quantile_over_time(0.99, bridge_cost_percent{bridge="mobula", amount_usd="300"}[24h]) mean: avg_over_time(bridge_cost_percent{bridge="mobula", amount_usd="300"}[24h]) - success: avg_over_time(bridge_quote_success{bridge="mobula", amount_usd="300"}[24h]) + success: avg(avg_over_time(bridge_quote_success{bridge="mobula", amount_usd="300"}[24h])) sample_size: sum(count_over_time(bridge_cost_percent{bridge="mobula", amount_usd="300"}[24h])) series: avg_over_time(bridge_cost_percent{bridge="mobula", amount_usd="300"}[1h]) @@ -120,7 +132,7 @@ providers: p90: quantile_over_time(0.90, bridge_cost_percent{bridge="relay", amount_usd="300"}[24h]) p99: quantile_over_time(0.99, bridge_cost_percent{bridge="relay", amount_usd="300"}[24h]) mean: avg_over_time(bridge_cost_percent{bridge="relay", amount_usd="300"}[24h]) - success: avg_over_time(bridge_quote_success{bridge="relay", amount_usd="300"}[24h]) + success: avg(avg_over_time(bridge_quote_success{bridge="relay", amount_usd="300"}[24h])) sample_size: sum(count_over_time(bridge_cost_percent{bridge="relay", amount_usd="300"}[24h])) series: avg_over_time(bridge_cost_percent{bridge="relay", amount_usd="300"}[1h]) @@ -134,7 +146,7 @@ providers: p90: quantile_over_time(0.90, bridge_cost_percent{bridge="lifi", amount_usd="300"}[24h]) p99: quantile_over_time(0.99, bridge_cost_percent{bridge="lifi", amount_usd="300"}[24h]) mean: avg_over_time(bridge_cost_percent{bridge="lifi", amount_usd="300"}[24h]) - success: avg_over_time(bridge_quote_success{bridge="lifi", amount_usd="300"}[24h]) + success: avg(avg_over_time(bridge_quote_success{bridge="lifi", amount_usd="300"}[24h])) sample_size: sum(count_over_time(bridge_cost_percent{bridge="lifi", amount_usd="300"}[24h])) series: avg_over_time(bridge_cost_percent{bridge="lifi", amount_usd="300"}[1h]) @@ -148,6 +160,20 @@ providers: p90: quantile_over_time(0.90, bridge_cost_percent{bridge="debridge", amount_usd="300"}[24h]) p99: quantile_over_time(0.99, bridge_cost_percent{bridge="debridge", amount_usd="300"}[24h]) mean: avg_over_time(bridge_cost_percent{bridge="debridge", amount_usd="300"}[24h]) - success: avg_over_time(bridge_quote_success{bridge="debridge", amount_usd="300"}[24h]) + success: avg(avg_over_time(bridge_quote_success{bridge="debridge", amount_usd="300"}[24h])) sample_size: sum(count_over_time(bridge_cost_percent{bridge="debridge", amount_usd="300"}[24h])) series: avg_over_time(bridge_cost_percent{bridge="debridge", amount_usd="300"}[1h]) + + - slug: near-intents + name: Near Intents + tag: Intent layer (NEAR) + formula: "Median over 24h of total cost percent (solver spread + bridge fee) on a $300 USDC quote returned by Near Intents 1Click API, sampled every 5 minutes from eu-west." + type: intent + queries: + p50: quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", amount_usd="300"}[24h]) + p90: quantile_over_time(0.90, bridge_cost_percent{bridge="near-intents", amount_usd="300"}[24h]) + p99: quantile_over_time(0.99, bridge_cost_percent{bridge="near-intents", amount_usd="300"}[24h]) + mean: avg_over_time(bridge_cost_percent{bridge="near-intents", amount_usd="300"}[24h]) + success: avg(avg_over_time(bridge_quote_success{bridge="near-intents", amount_usd="300"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="near-intents", amount_usd="300"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="near-intents", amount_usd="300"}[1h]) diff --git a/benchmarks/bridge-quote-latency.yml b/benchmarks/bridge-quote-latency.yml index 3f1f4a55..7fea30c9 100644 --- a/benchmarks/bridge-quote-latency.yml +++ b/benchmarks/bridge-quote-latency.yml @@ -3,14 +3,28 @@ slug: bridge-quote-latency number: "002" title: Fastest cross-chain bridge quote API, live ms ranking -seo_title: "Fastest bridge quote API 2026: Mobula, deBridge, Relay, LI.FI" -seo_description: "{{best_name}} leads fastest bridge quote API at {{best_p50}} (p50, 24h). Mobula, deBridge, Relay, LI.FI on identical USDC routes, refreshed every 5 minutes." -subtitle: Time to receive a usable cross-chain quote, in milliseconds. Identical route and identical notional, measured every five minutes across Mobula, deBridge, Relay and LI.FI. +seo_title: "Fastest bridge quote API 2026: Mobula, deBridge, Relay, LI.FI, Near Intents" +seo_description: "{{best_name}} leads fastest bridge quote API at {{best_p50}} (p50, 24h). Mobula, deBridge, Relay, LI.FI, Near Intents on identical USDC routes, refreshed every 5 minutes." +subtitle: Time to receive a usable cross-chain quote, in milliseconds. Identical route and identical notional, measured every five minutes across Mobula, deBridge, Relay, LI.FI and Near Intents. category: Bridges status: live metric: Quote latency unit: ms +# Per-destination breakdown. The bridge-monitor harness emits the chain +# label set to route.ToChain (capitalized). Each bridge has wildly +# different per-corridor latency profiles (Near Intents in particular is +# bimodal: solver-cached corridors return in ~30ms while uncached ones +# wait the full 3s solver auction window) so aggregating across all +# destinations produces meaningless cross-corridor averages. The "All" +# tab keeps the aggregate view; the per-chain tabs reveal the truth. +dimensions: + chain: + - { value: Base, label: Sol to Base } + - { value: Arbitrum, label: Base to Arb } + - { value: Solana, label: Arb to Sol } + - { value: HyperCore, label: Arb to HyperCore } + seo_intro: | This benchmark measures how fast each cross-chain bridge quote API returns a usable response, the response-time half of the bridge story @@ -97,7 +111,7 @@ providers: p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mobula"}[24h]))) p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mobula"}[24h]))) mean: sum(rate(bridge_quote_latency_ms_sum{bridge="mobula"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="mobula"}[24h])) - success: avg_over_time(bridge_quote_success{bridge="mobula"}[24h]) + success: avg(avg_over_time(bridge_quote_success{bridge="mobula"}[24h])) sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="mobula"}[24h])) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mobula"}[1h]))) @@ -110,7 +124,7 @@ providers: p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="relay"}[24h]))) p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="relay"}[24h]))) mean: sum(rate(bridge_quote_latency_ms_sum{bridge="relay"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="relay"}[24h])) - success: avg_over_time(bridge_quote_success{bridge="relay"}[24h]) + success: avg(avg_over_time(bridge_quote_success{bridge="relay"}[24h])) sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="relay"}[24h])) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="relay"}[1h]))) @@ -123,7 +137,7 @@ providers: p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="lifi"}[24h]))) p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="lifi"}[24h]))) mean: sum(rate(bridge_quote_latency_ms_sum{bridge="lifi"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="lifi"}[24h])) - success: avg_over_time(bridge_quote_success{bridge="lifi"}[24h]) + success: avg(avg_over_time(bridge_quote_success{bridge="lifi"}[24h])) sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="lifi"}[24h])) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="lifi"}[1h]))) @@ -136,6 +150,19 @@ providers: p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="debridge"}[24h]))) p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="debridge"}[24h]))) mean: sum(rate(bridge_quote_latency_ms_sum{bridge="debridge"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="debridge"}[24h])) - success: avg_over_time(bridge_quote_success{bridge="debridge"}[24h]) + success: avg(avg_over_time(bridge_quote_success{bridge="debridge"}[24h])) sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="debridge"}[24h])) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="debridge"}[1h]))) + + - slug: near-intents + name: Near Intents + tag: Intent layer (NEAR) + formula: "Median wall-clock ms over 24h for Near Intents 1Click API to return a usable USDC quote via the solver auction bus, sampled every 5 minutes across the supported routes and 3 notionals from eu-west." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents"}[24h]))) + mean: sum(rate(bridge_quote_latency_ms_sum{bridge="near-intents"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="near-intents"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="near-intents"}[24h])) + sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="near-intents"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents"}[1h]))) diff --git a/benchmarks/perp-fees.yml b/benchmarks/perp-fees.yml index 3e4ccc57..775cbfbb 100644 --- a/benchmarks/perp-fees.yml +++ b/benchmarks/perp-fees.yml @@ -51,6 +51,20 @@ methodology: - "All-in formula: `all_in_bps = taker_fee_bps + spread_bps`. Both components emitted as separate metrics for transparency." - "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." +per_chain_explainer: + - slug: ETH + h2: "Cheapest perp DEX for ETH" + body: | + The cheapest perp DEX for ETH on this benchmark sits at {{best_p50:chain:ETH}} all-in (p50, 24h) for a $1000 ETH long 10x. ETH-PERP is the deepest pair on every venue measured (Hyperliquid, Lighter, dYdX v4, GMX v2, gains.trade), which means the rack-rate taker fee dominates the all-in number rather than the spread. Lighter quotes 0 bps taker, Hyperliquid 4.5 bps, dYdX 5 bps tier-0, GMX 4 or 6 bps on the impact branch, gains.trade reads the openFeeP slot live from the on-chain Gains v8 fees contract. Spread plus impact at $1000 notional is added on top via an orderbook walk. + - slug: BTC + h2: "Cheapest perp DEX for BTC" + body: | + The cheapest perp DEX for BTC on this benchmark sits at {{best_p50:chain:BTC}} all-in (p50, 24h) for a $1000 BTC long 10x. BTC-PERP carries the highest open interest across every venue in the cohort, so books are typically tighter than ETH on the same notional and the spread component compresses toward the rack rate. GMX v2 BTC uses the same positionFeeFactor schedule as ETH (4 bps positive impact, 6 bps negative); the bench reports the conservative negative branch. The leaderboard reorders intra-day with funding skew and orderbook depth. + - slug: SOL + h2: "Cheapest perp DEX for SOL" + body: | + The cheapest perp DEX for SOL on this benchmark sits at {{best_p50:chain:SOL}} all-in (p50, 24h) for a $1000 SOL long 10x. SOL-PERP books are thinner than ETH or BTC on every venue, so the spread plus impact component carries more weight in the all-in figure and a 5 bps taker venue can land above a 0 bps taker venue once the orderbook is walked. Lighter, Hyperliquid and dYdX v4 quote SOL-PERP natively; GMX v2 trades SOL as a synthetic on its Arbitrum deployment. + findings: - "{{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." diff --git a/benchmarks/solana-tx-landing-latency.yml b/benchmarks/solana-tx-landing-latency.yml deleted file mode 100644 index 8e1858b8..00000000 --- a/benchmarks/solana-tx-landing-latency.yml +++ /dev/null @@ -1,221 +0,0 @@ -# OpenChainBench. Bench № 027 - -slug: solana-tx-landing-latency -number: "027" -title: Fastest Solana RPC for tx landing, live slot delta benchmark -seo_title: "Fastest Solana RPC 2026: Helius, Jito, Mobula slot delta ranked" -seo_description: "{{best_name}} leads fastest Solana RPC for tx landing at {{best_p50}} (p50, 7d). Helius, Jito, Astralane, Mobula, Nozomi probed hourly with signed mainnet txs." -subtitle: How fast each landing service gets a signed mainnet tx confirmed. Slot delta = number of Solana slots between submit and confirmed (1 slot is roughly 400 ms). Active probing every hour from us-east. -category: Trading -status: live -metric: p50 slot delta to confirmed (7-day window) -unit: slots -higher_is_better: false - -disclaimer: | - Six caveats. (1) us-east only, sgp / eu-west arrive in V2. (2) One pre-registered tip per service. (3) Synthetic payload (1-lamport + memo); real swaps may land differently. (4) Helius / Astralane / Nozomi fan out to Jito internally; Jito control probe runs each cycle. (5) Confirmation = `confirmed`. (6) Slot delta is canonical; ms is derived (≈ slot_delta × 400 ms + RTT). Pair with /benchmarks/solana-tx-landing. - -seo_intro: | - This benchmark answers the only question that matters to a - Solana trader picking a landing service. how many slots does - your signed mainnet transaction take to reach the confirmed - state on chain. Every hour from a us-east probe, the harness - submits an identical signed tx through each of 5 services in - parallel, captures the submit slot before send and the land - slot from the signatureSubscribe WebSocket notification at - commitment=confirmed, and increments per-service Prometheus - histograms. Headline numbers shown are p50 and p99 slot delta - over a rolling 7-day window. Wall-clock milliseconds are - published alongside for intuition (one Solana slot is ~400 ms, - so a p50 of 1 slot is ~400 ms wall-clock plus submission RTT) - but slot delta is the canonical, sponsor-proof on-chain - measurement. - Why slot delta is the right metric. Solana confirmation is a - slot-level event. when a slot reaches supermajority vote, every - transaction in it becomes confirmed simultaneously. Wall-clock - ms conflates HTTP submission time, our RPC's polling lag, and - network RTT to the public WebSocket - all of which are - measurement artifacts unrelated to the landing service's actual - routing quality. Slot delta is what the chain itself records. - Coverage. 5 services probed in V0-Lean. Jito Block Engine (the - control / baseline because Helius, Astralane, Nozomi all - internally route some flow through it). Helius Sender in - `swqos_only=true` mode (isolates the Helius own-path from the - Jito leg). Astralane Iris (tip-refund mechanism). Nozomi by - Temporal Labs (premium tier, hard 1M lamport tip floor). - 0slot.trade (premium tier). NextBlock, bloXroute and - SolanaVibeStation arrive in the next tier (V1) once the first - sponsors land. Companion bench. /benchmarks/solana-tx-landing - measures market share via on-chain tip-wallet attribution - - who carries the flow today, regardless of speed. - -abstract: | - We probe 5 Solana transaction landing services from a single - Railway us-east region, once per hour, by submitting an - identical signed mainnet transaction to each. The payload is - the minimal valid Solana tx, compute-budget instructions - (50k CU limit, 50k micro-lamport/CU price), a 1-lamport - self-transfer, the per-service tip transfer to the service's - documented tip wallet, and an OCB-prefixed memo for forensic - traceability. All five services are submitted in parallel - goroutines within a single cycle so they sample the same chain - congestion window. The headline measurement is slot delta, - land_slot minus submit_slot, captured from the - signatureSubscribe WebSocket notification's context.slot field - at commitment=confirmed. Wall-clock ms is reported alongside - but is a derived approximation, slot_delta × ~400 ms plus - submission RTT and goroutine startup variance. A 60 s no- - confirmation deadline classifies the probe as - dropped{reason=timeout}; structured RPC errors classify as - invalid; transport failures as network_error; HTTP 419 / 429 / - "rate limit" errors classify as rate_limited (a separate label - so quota issues don't bias the bench against the throttled - service). Cost. ~$159/mo at SOL=$86, 86 % of which goes to the - four ≥1M-lamport-floor services (Nozomi, 0slot, bloXroute, - NextBlock, only two of these in V0-Lean). Sponsor SOL credits - covering a service's own probes are explicitly allowed per the - sponsor-proof framework. Limitations. (a) Single us-east - region, sgp / eu-west arrive in V2 once sponsors fund - geographic-edge story. (b) 1-hour cadence, 168 probes per - service per 7-day window, enough for stable p50 / p99 over the - publication window, not enough for intra-hour resolution - (V0.5 / V1 upgrade if needed). (c) Fan-out, Helius probed in - `swqos_only` mode only in V0-Lean to keep wire shape simple; - dual-mode arrives in v1.0.1 methodology PR. - -methodology: - - "Source endpoints (us-east Railway, base64 JSON-RPC sendTransaction unless noted). Jito `ny.mainnet.block-engine.jito.wtf/api/v1/transactions`. Helius Sender `ewr-sender.helius-rpc.com/fast?swqos_only=true` (skipPreflight + maxRetries=0). Nozomi `http://edge.nozomi.temporal.xyz/api/sendBatch?c=` (binary `[u16_BE_len][tx_bytes]`, HTTP per Temporal Labs). Astralane `ny.gateway.astralane.io/iris?api-key=` (3-elem params, mevProtect). 0slot `ny.0slot.trade?api-key=`." - - "Probe payload. 5 instructions in this exact order: SetComputeUnitLimit(50,000) + SetComputeUnitPrice(50,000 micro-lamports/CU) + SystemProgram.Transfer(payer→payer, 1 lamport) + SystemProgram.Transfer(payer→service tip wallet, floor lamports) + Memo(`ocb---`). cycle_id is an 8-byte random hex shared across the five parallel probes of one cycle, so the on-chain memos correlate." - - "Tip floors (pre-registered, methodology PR + 14-day window to change). Jito 10,000 lamports. Helius Sender 10,000. Astralane 500,000 net of refunds. Nozomi 1,000,000. 0slot 1,000,000." - - "Submission flow. One getLatestBlockhash(processed) shared across all five probes. One getSlot(processed) as submit_slot. For each service we subscribe to the signature via signatureSubscribe on the public WS BEFORE submission (otherwise a fast confirm could fire before we listen). Probes then fire in parallel goroutines, sign, POST. We block on the signatureNotification at commitment=confirmed; context.slot is land_slot; slot_delta = land_slot - submit_slot." - - "Why slot delta is canonical. Solana confirmation is slot-level. when a slot reaches supermajority, every tx in it becomes confirmed simultaneously. The WS pushes notifications for all subscribed sigs in that slot at the same instant. So sub-400 ms wallclock diffs between services in the same slot are artifacts (goroutine startup, RTT), not routing quality. slot_delta is what the chain records, what to cite in audits." - - "Wall-clock ms is a derived approximation. ms ≈ slot_delta × ~400 ms + HTTP submission RTT + variance. We publish it for intuition because traders think in seconds, not slots, but it should not be the sole metric in a sponsor pitch or audit. If a service argues 'your ms numbers are biased by your RTT', the answer is the slot delta column, which is RTT-independent." - - "Drop classification. timeout = no confirmation within 60 s. invalid = RPC error, on-chain Err, or BlockhashNotFound. network_error = transport-level (timeout, DNS, EOF, connection refused). rate_limited = HTTP 419 / 429 / 'rate limit' / 'too many requests'. landing_rate is published as success / (success + timeout), rate_limited and network_error are excluded so quota / transport issues don't bias the bench against a throttled service." - - "Jito control probe. Helius (default), Astralane, Nozomi route a portion of flow through Jito internally, conflating own-path vs Jito-caught-it. Jito is in the V0-Lean set so its control fires in the same cycle. Same slot_delta as Jito = no measurable own-path value. Suspect ahead by 1+ slot = real routing advantage." - - "Reproducibility. The full harness source is at github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing. Anyone with a funded Solana keypair (~1 SOL) can clone, set SOLANA_PROBE_KEYPAIR_BASE58, run the binary, and reproduce these metrics. The bench does not rely on any private or internal service for measurement, the only RPC dependency is the public `api.mainnet-beta.solana.com` HTTP + WebSocket endpoints." - - "Methodology v1.3 pre-registered at github.com/ChainBench/OpenChainBench/blob/main/docs/methodology/solana-tx-landing-active.md. Any change (tip floor, probe payload, cadence, region, metric definitions) ships as a public PR with a 14-day comment window. Major version bumps run a 30-day shadow period publishing old and new metrics in parallel." - -findings: - - "{{best_name}} leads the V0-Lean probe set at p50 = {{best_p50}} slot delta over the rolling 7-day window. Lower = fewer Solana slots between submission and confirmation. The gap between fastest and slowest is the operational signal, every service claims '99 %+ landing rate' in marketing copy, but the chain doesn't lie about which slot included your tx. A 1-slot difference is ~400 ms, enough for a MEV bot to front-run a competitor." - - "{{name:jito}} is the baseline / control. Helius (default mode), Astralane, and Nozomi all internally fan out to Jito, so the Jito p50 is the floor any premium service must beat. Same slot_delta as Jito on a given cycle = the service is essentially using Jito as its inclusion path. {{name:jito}} sits at p50 = {{p50:jito}} slot delta." - - "{{name:helius-sender}} in `swqos_only` mode isolates Helius's own routing path from the Jito leg. p50 = {{p50:helius-sender}} slot delta. A v1.0.1 methodology update will publish Helius default mode (with Jito fan-out) side-by-side for direct comparison." - - "{{name:nozomi}} premium pricing (1M lamport hard floor, ~10 × Jito's competitive level) only makes economic sense if the slot_delta advantage is meaningful. p50 = {{p50:nozomi}} slot delta. The gap vs Jito quantifies whether the tip premium buys real slot priority." - - "{{worst_name}} trails at p50 = {{worst_p50}} slot delta. The worst slot delta in the V0-Lean set is not necessarily a bad service, it may be a service whose strength is in dimensions this bench doesn't measure (anti-MEV protection, durable nonce, fee-refund mechanics). Latency is one variable, not the whole product." - -faq: - - q: "Why is slot delta the headline metric instead of wall-clock latency?" - a: "Solana confirmation is a slot-level event. when a slot reaches supermajority vote (~2/3 of stake), every transaction in that slot becomes confirmed simultaneously. The WebSocket pushes notifications for all subscribed signatures in that slot at the same instant. So if 3 services delivered txs that all landed in the same slot, our wallclock measurement records the same time for all 3, the only differentiation is whether the next service's tx landed in slot N or N+1. slot_delta captures that directly. Wall-clock ms is derived (slot_delta × ~400 ms + RTT + variance) and conflates routing quality with measurement artifacts like HTTP submission speed and our public RPC's network latency. We publish wall-clock ms because traders think in seconds, but slot_delta is what you should cite in an audit or methodology dispute. It's RTT-independent and reads directly from the chain." - - q: "What does '1 slot' actually mean in time?" - a: "Solana slots are ~400 ms in practice (~625 ms target with leader skips and forks averaging it down). A p50 slot_delta of 1 means your tx typically lands in the slot immediately following your submission, ~400 ms after sendTransaction return. p50 of 2 means typically one slot later, ~800 ms. The gap between p50 = 1 and p50 = 2 is the operational signal, a service that consistently lands 1 slot earlier than its competitors is ~400 ms ahead, which is the difference between catching an arbitrage and missing it." - - q: "Why an active bench when /benchmarks/solana-tx-landing already exists?" - a: "/benchmarks/solana-tx-landing is observational, it watches the chain and counts who carries the flow. It cannot answer 'how fast does my tx land if I send it now', because it doesn't send anything. This bench (active probing) answers that, at the cost of running 24 / 7 with real SOL ($159 / month at the V0-Lean cadence). The two benches answer different product questions. Read both." - - q: "Why only 5 services, not the 8 you measure observationally?" - a: "NextBlock, bloXroute Trader, and SolanaVibeStation all require paid plans or sales-call onboarding before they issue an API key. We're shipping V0-Lean today with the 5 services that have a clear self-serve or contact-based path. The other 3 will be added as the bench scales. The observational bench at /benchmarks/solana-tx-landing already covers all 8 because it doesn't need API keys." - - q: "Why us-east only?" - a: "V0-Lean. us-east is the de-facto Solana baseline (Jito, NextBlock, bloXroute, Helius all anchor their best-connected POPs there) and is where most Solana bots deploy by default. Adding eu-west and sgp triples the bench cost and answers a different question ('does the ranking change by geography?'), which is a planned V2 scope expansion." - - q: "What's the probe payload?" - a: "Five instructions in this exact order, locked by methodology §3. (1) SetComputeUnitLimit(50,000). (2) SetComputeUnitPrice(50,000 micro-lamports/CU), together a 2,500-lamport priority fee. (3) SystemProgram.Transfer of 1 lamport from the prober keypair to itself, the minimal valid state-touching tx. (4) SystemProgram.Transfer to the service's documented tip wallet at the pre-registered floor. (5) Memo program write with the cycle ID, service name, and probe mode. Total weight: ~600 bytes, well under the 1,232-byte tx limit." - - q: "How is fan-out handled?" - a: "Helius (default mode), Astralane, and Nozomi route a portion of flow through Jito internally. The Jito control probe, Jito is part of the V0-Lean probe set, fires in the same cycle as the suspect services with the same blockhash and a comparable tip. The slot_delta column tells you immediately whether a suspect service is adding value beyond a Jito wrapper. Same slot_delta as Jito = same inclusion slot = Jito caught it. Suspect ahead by 1+ slot = real own-path routing. Helius is additionally probed in `?swqos_only=true` mode to fully isolate its own routing path." - - q: "Can a service detect and prioritise our probes?" - a: "Yes, in principle. The memo prefix `ocb-` is deterministic and the keypair is constant per region. Anti-fingerprinting (memo randomisation, sub-account rotation, tip jitter within the floor band) ships in v1.0.2 methodology PR. We disclose this risk openly; the trade-off is that announcing the bench publicly to providers gives them a chance to fix real performance issues before we publish, which is a good outcome. We do NOT accept private deals to alter the probe surface for any specific service." - - q: "Why is sample_size on the dashboard ~168 per service?" - a: "V0-Lean cadence = 1 probe per service per hour from 1 region. 168 = 24 hours × 7 days. The 7-day publication window is the trade-off between statistical resolution (sample size grows with window) and freshness (shorter window reflects current chain conditions). At ~168 samples per cell, p50 is stable to within ±5 % and p99 to within ±15 %. Lower confidence intervals are unlocked at V0.5 cadence (1 / 10 min, ~$760 / mo) and above." - - q: "How is the confirmation observed?" - a: "Via `signatureSubscribe` on the public mainnet WebSocket (`wss://api.mainnet-beta.solana.com`). The subscription is registered BEFORE submission so a fast-confirming tx cannot complete before we are listening (otherwise we'd miss the notification and timeout spuriously). The RPC pushes the notification at the instant the commitment level is reached, so observation resolution is RTT-bounded (~30-50 ms us-east → mainnet-beta) and slot_delta is read directly from the notification's context.slot field. HTTP polling at 200 ms is an automatic fallback if the WebSocket fails to connect for a given cycle." - -source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing - -prometheus: - window: 7d - expected_freshness_seconds: 7200 - -# Real metrics emitted by the active prober in solana-tx-landing harness: -# solana_landing_probe_success_total{service, mode, region} counter -# solana_landing_probe_dropped_total{service, mode, region, reason} counter -# solana_landing_probe_latency_ms{service, mode, region} gauge (set every cycle) -# solana_landing_probe_latency_slots{service, mode, region} gauge (set every cycle) -# solana_landing_probe_latency_slots_histogram{service, mode, region} histogram (debug) -# solana_landing_probe_latency_ms_histogram{service, mode, region} histogram (debug) -# solana_landing_probe_keypair_balance_sol{region} gauge -# solana_landing_probe_cycle_total{region} counter -# solana_landing_probe_enabled{region} gauge -# -# Headline metric (canonical) = slot_p50 / slot_p99 read from the gauge. -# Wall-clock ms is published alongside via the standard p50/p90/p99 fields -# for reader intuition but is derived (slot_delta × ~400 ms + RTT + variance). -# Mode label is `swqos_only` for helius-sender, `default` for the rest. -# -# Why quantile_over_time(gauge) instead of histogram_quantile(histogram)? -# At V0-Lean cadence (1 probe / hour) we have ~168 samples per cell over 7d. -# Histogram buckets {100, 250, 500, 1000, 2000, 5000, 10000, 30000, 60000} ms -# have ~3 buckets in the 1-5s zone where probes actually land, so -# histogram_quantile collapses to bucket midpoints (1500, 3500 ms) and the -# series looks flat. quantile_over_time on the gauge takes the real sample -# at the 50th percentile, which is the accurate published number. - -providers: - - slug: jito - name: Jito - tag: Baseline + control probe; atomic bundles + tip auction since 2022 - formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed mainnet probes submitted to Jito's `ny.mainnet.block-engine.jito.wtf` from us-east." - queries: - p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) - p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) - p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) - mean: avg_over_time(solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) - success: sum(rate(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="jito",region="us-east",reason="timeout"}[7d]))) - sample_size: sum(increase(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) - series: solana_landing_probe_latency_slots{service="jito",region="us-east"} - - - slug: helius-sender - name: Helius - tag: Isolated Helius own-path (no Jito fan-out); anycast + 7 POPs - formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted to Helius Sender in `swqos_only=true` mode from us-east, isolating its own-path." - queries: - p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) - p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) - p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) - mean: avg_over_time(solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) - success: sum(rate(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="helius-sender",mode="swqos_only",region="us-east",reason="timeout"}[7d]))) - sample_size: sum(increase(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) - series: solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"} - - - slug: astralane - name: Astralane - tag: Tip-refund mechanism, sendBundle / sendIdeal modes, FRA + NY POPs - formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted with a 500k-lamport net tip to Astralane Iris's NY gateway from us-east." - queries: - p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) - p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) - p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) - mean: avg_over_time(solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) - success: sum(rate(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="astralane",region="us-east",reason="timeout"}[7d]))) - sample_size: sum(increase(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) - series: solana_landing_probe_latency_slots{service="astralane",region="us-east"} - - - slug: nozomi - name: Nozomi - tag: Temporal Labs, direct-to-leader, premium 1M-lamport hard floor - formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted with a 1M-lamport tip to Nozomi's `edge.nozomi.temporal.xyz` from us-east." - queries: - p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) - p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) - p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) - mean: avg_over_time(solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) - success: sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="nozomi",region="us-east",reason="timeout"}[7d]))) - sample_size: sum(increase(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) - series: solana_landing_probe_latency_slots{service="nozomi",region="us-east"} - - - slug: mobula - name: Mobula - tag: Multi-RPC fan-out aggregator (relays via Jito / Nozomi / zeroslot) - formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly probes submitted via Mobula's `api.mobula.io/api/2/swap/send` multi-RPC fan-out from us-east, using a Jito tip wallet." - queries: - p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) - p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) - p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) - mean: avg_over_time(solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) - success: sum(rate(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="mobula",region="us-east",reason="timeout"}[7d]))) - sample_size: sum(increase(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) - series: solana_landing_probe_latency_slots{service="mobula",region="us-east"} diff --git a/benchmarks/wallet-labels-coverage.yml b/benchmarks/wallet-labels-coverage.yml index 0b949be7..4412f301 100644 --- a/benchmarks/wallet-labels-coverage.yml +++ b/benchmarks/wallet-labels-coverage.yml @@ -81,6 +81,32 @@ methodology: - "Failures (timeouts, 5xx, auth errors) are counted as 'no label' and surfaced separately as `wallet_labels_fetch_errors_total`." - "Region: `eu-west` (single point)." +per_chain_explainer: + - slug: solana + h2: "Best wallet labeling API on Solana" + body: | + The Solana wallet labeling leaderboard is driven by chain-specialist curation rather than universal coverage. Helius wins by default on this chain because its label graph is built directly against native Solana programs (Jito stake pools, Jupiter routers, Pump.fun creators, Magic Eden marketplace IDs) rather than translated from an EVM-shaped schema. Mobula audits Solana as part of its universal coverage and clocks {{p50:mobula}} on the active Kind tab. Blockscout does not index Solana, so the per-chain leaderboard here is between Solana-native APIs. + - slug: ton + h2: "Best wallet labeling API on TON" + body: | + TON wallet labeling is essentially a two-API conversation. TonAPI ships a curated entity directory covering core TON contracts (jettons, Telegram bot wallets, DEX routers like STON.fi and DeDust). Mobula audits TON as part of its universal coverage. Most EVM-centric providers (Moralis, Blockscout) do not index TON at all, so the per-chain leaderboard here surfaces the genuine specialist gap. Per the bench's coverage definition, generic categorical fillers like `EOA` or `Wallet` do not count toward a hit; only entity-grade names do. + - slug: stellar + h2: "Best wallet labeling API on Stellar" + body: | + Stellar wallet labeling is dominated by StellarExpert, whose `/explorer/directory/{addr}` endpoint is the curated entity graph the rest of the Stellar ecosystem cites. The directory covers anchor issuers (Circle USDC issuer, AnchorUSD), SDF accounts, DEX market makers and SDF grant recipients. Mobula audits Stellar as part of its universal coverage. EVM-centric providers (Moralis, Blockscout) do not index Stellar, so the per-chain leaderboard reflects a specialist-heavy field rather than a universal one. + - slug: bitcoin + h2: "Best wallet labeling API on Bitcoin" + body: | + Bitcoin wallet labeling is the hardest case in the benchmark because the chain has no smart contract layer to derive names from, every entity has to come from a curated graph. WalletExplorer maintains the canonical public clustering of Bitcoin addresses (exchanges, mixers, pools, OFAC SDN), and most downstream Bitcoin labeling products cite it directly. Mobula audits Bitcoin as part of its universal coverage. The Kind = Contract tab is essentially empty on Bitcoin (no verified-source contracts), so the EOA tab is the only honest comparison. + - slug: ethereum + h2: "Best wallet labeling API on Ethereum" + body: | + Ethereum is the chain where the EOA vs Contract split matters most. On the Contract tab Blockscout saturates near 100% because every Uniswap V3 router, WETH9 and Aave V3 pool ships a verified-source constructor name that the explorer reads for free. On the EOA tab the comparison flips to curated entity graphs: Mobula, Moralis and OLI on Base EAS each maintain their own directory of Binance hot wallets, Safe multisigs, OFAC SDN addresses and public figures. Switch the Kind tab at the top of the page to read the side you actually need. + - slug: base + h2: "Best wallet labeling API on Base" + body: | + Base wallet labeling is shaped by Coinbase's OP Stack rollup pattern. Most EVM-centric providers (Mobula, Moralis, Blockscout, OLI via Base EAS) advertise Base coverage and audit cleanly on contracts (Uniswap V4, Aerodrome, the Coinbase Wallet Smart Wallet factory). The EOA tab is the harder job here because Base wallets are dominated by retail Smart Wallets created through the Coinbase Wallet factory, which collapses to a single contract type that providers must distinguish from one another via deployment-time call data. + findings: - "{{best_name}} currently leads coverage at {{best_p50}} (24 h) on the active tab, across {{count}} measured providers. The number is the share of curated anchor addresses for which the provider returns a non-generic entity name, audited every 30 minutes against ~180 publicly-known addresses split by kind (contract vs EOA)." - "The Kind toggle separates two distinct jobs. On the Contract tab any explorer that reads verified source code (Blockscout in particular) saturates near 100% because the contract's name is already in the bytecode metadata. On the EOA tab the score reflects how well the provider's curated entity graph covers plain wallets, the actually-hard job." diff --git a/next.config.ts b/next.config.ts index 6938a6f9..e8fcec31 100644 --- a/next.config.ts +++ b/next.config.ts @@ -47,6 +47,17 @@ const nextConfig: NextConfig = { // pin it so a future Next minor that flips defaults can't silently // split bench rankings between the two surface URLs. trailingSlash: false, + // Inject a build-time timestamp so the sitemap can emit a stable + // per deploy instead of `new Date()` at request time. The + // sitemap runs on force-dynamic (to bypass Next's 2 MB Data Cache + // limit), which means `new Date()` at module init evaluates anew on + // every Google crawl. Result: every URL in the sitemap got a freshly + // updated lastmod each visit, Google flagged the signal as unreliable + // and stopped using it to prioritise recrawls. This baking pins the + // value at build time so it changes only when a new deploy ships. + env: { + NEXT_PUBLIC_BUILD_TIME: new Date().toISOString(), + }, turbopack: { root: __dirname, }, diff --git a/public/logos/near-intents.svg b/public/logos/near-intents.svg new file mode 100644 index 00000000..0dab1130 --- /dev/null +++ b/public/logos/near-intents.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/api/badge/[slug]/[provider]/route.ts b/src/app/api/badge/[slug]/[provider]/route.ts index 5f29beed..08bd7f51 100644 --- a/src/app/api/badge/[slug]/[provider]/route.ts +++ b/src/app/api/badge/[slug]/[provider]/route.ts @@ -158,7 +158,7 @@ export async function GET( req: NextRequest, { params }: { params: Promise }, ) { - const rl = rateLimit(clientKey(req, "badge"), 120, 60); + const rl = rateLimit(clientKey(req, "badge"), 120, 60, req); if (!rl.ok) return tooManyRequests(rl.retryAfterSec); const { slug, provider } = await params; diff --git a/src/app/api/badge/[slug]/[provider]/snippet/route.ts b/src/app/api/badge/[slug]/[provider]/snippet/route.ts new file mode 100644 index 00000000..70625997 --- /dev/null +++ b/src/app/api/badge/[slug]/[provider]/snippet/route.ts @@ -0,0 +1,113 @@ +/** + * Copy-paste embed snippets for the per-(benchmark, provider) badge SVG. + * + * GET /api/badge///snippet?format=markdown|html|url|json + * + * Returns a ready-to-paste embed code so a provider can drop a live + * "Ranked #N on OpenChainBench" badge into their README, docs page, + * or marketing site without crafting the URL by hand. The badge SVG + * itself still lives at /api/badge//; this endpoint + * only wraps it. + * + * Why this exists separately from the SVG route: it is the one + * surface readers reach for when they want to BACKLINK us. Keeping + * snippet rendering out of the SVG path keeps the SVG cache hot + * (one cacheable shape per benchmark + provider) and avoids polluting + * the SVG content negotiation with a text/* branch. + * + * Optional query params forwarded to the badge URL so a provider can + * embed a scope-restricted badge (chain, region, kind). The site URL + * the badge links to also picks up the same scope where applicable, + * so a reader clicking through lands on the matching variant view. + */ + +import { type NextRequest, NextResponse } from "next/server"; +import { getBenchmark } from "@/data/benchmarks"; +import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; +import { PROVIDER_RE, SLUG_RE } from "@/lib/slug"; +import { SITE } from "@/data/site"; + +export const revalidate = 600; + +type Params = { slug: string; provider: string }; + +const FORMATS = ["markdown", "html", "url", "json"] as const; +type Format = (typeof FORMATS)[number]; + +function isFormat(v: string | null): v is Format { + return v != null && (FORMATS as readonly string[]).includes(v); +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise }, +) { + const r = rateLimit(clientKey(req, "badge-snippet"), 120, 60, req); + if (!r.ok) return tooManyRequests(r.retryAfterSec); + + const { slug, provider } = await params; + if (!SLUG_RE.test(slug) || !PROVIDER_RE.test(provider)) { + return NextResponse.json({ error: "invalid_slug" }, { status: 400 }); + } + + const benchmark = await getBenchmark(slug); + if (!benchmark) { + return NextResponse.json({ error: "bench_not_found" }, { status: 404 }); + } + const result = benchmark.results.find((p) => p.slug === provider); + if (!result) { + return NextResponse.json({ error: "provider_not_found" }, { status: 404 }); + } + + const sp = req.nextUrl.searchParams; + const format: Format = isFormat(sp.get("format")) ? (sp.get("format") as Format) : "markdown"; + const chain = sp.get("chain")?.trim() || ""; + const region = sp.get("region")?.trim() || ""; + const kind = sp.get("kind")?.trim() || ""; + + const scopeQs = new URLSearchParams(); + if (chain) scopeQs.set("chain", chain); + if (region) scopeQs.set("region", region); + if (kind) scopeQs.set("kind", kind); + const scopeSuffix = scopeQs.toString(); + + const badgeUrl = + `${SITE.url}/api/badge/${slug}/${provider}` + + (scopeSuffix ? `?${scopeSuffix}` : ""); + const benchUrl = + `${SITE.url}/benchmarks/${slug}` + + (scopeSuffix ? `?${scopeSuffix}` : ""); + + const alt = `OpenChainBench ${benchmark.title} ranking for ${result.name}`; + + const snippets = { + markdown: `[![${alt}](${badgeUrl})](${benchUrl})`, + html: `${alt}`, + url: badgeUrl, + } as const; + + if (format === "json") { + return NextResponse.json( + { + benchmark: { slug, title: benchmark.title, url: benchUrl }, + provider: { slug: provider, name: result.name }, + badge_url: badgeUrl, + snippets, + scope: { chain: chain || null, region: region || null, kind: kind || null }, + license: "CC-BY-4.0", + }, + { + headers: { + "cache-control": "public, s-maxage=300, stale-while-revalidate=600", + }, + }, + ); + } + + return new NextResponse(snippets[format], { + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "public, s-maxage=300, stale-while-revalidate=600", + }, + }); +} diff --git a/src/app/api/bench/[slug]/oracle-pairs/route.ts b/src/app/api/bench/[slug]/oracle-pairs/route.ts index f5fb1c38..0b4c6552 100644 --- a/src/app/api/bench/[slug]/oracle-pairs/route.ts +++ b/src/app/api/bench/[slug]/oracle-pairs/route.ts @@ -42,7 +42,7 @@ export async function GET( req: Request, { params }: { params: Promise }, ) { - const r = rateLimit(clientKey(req, "oracle-pairs"), 60, 60); + const r = rateLimit(clientKey(req, "oracle-pairs"), 60, 60, req); if (!r.ok) return tooManyRequests(r.retryAfterSec); const { slug } = await params; diff --git a/src/app/api/bench/[slug]/variant/route.ts b/src/app/api/bench/[slug]/variant/route.ts index 7a9e9a08..37bcabe8 100644 --- a/src/app/api/bench/[slug]/variant/route.ts +++ b/src/app/api/bench/[slug]/variant/route.ts @@ -24,7 +24,7 @@ export async function GET( req: NextRequest, { params }: { params: Promise }, ) { - const rl = rateLimit(clientKey(req, "variant"), 120, 60); + const rl = rateLimit(clientKey(req, "variant"), 120, 60, req); if (!rl.ok) return tooManyRequests(rl.retryAfterSec); const { slug } = await params; diff --git a/src/app/api/builder/[slug]/daily-series/route.ts b/src/app/api/builder/[slug]/daily-series/route.ts index 3cce38a0..cdabb1b4 100644 --- a/src/app/api/builder/[slug]/daily-series/route.ts +++ b/src/app/api/builder/[slug]/daily-series/route.ts @@ -26,7 +26,7 @@ export async function GET( req: Request, { params }: { params: Promise }, ) { - const r = rateLimit(clientKey(req, "hl-daily-series"), 60, 60); + const r = rateLimit(clientKey(req, "hl-daily-series"), 60, 60, req); if (!r.ok) return tooManyRequests(r.retryAfterSec); const { slug } = await params; diff --git a/src/app/api/builder/[slug]/top-users/route.ts b/src/app/api/builder/[slug]/top-users/route.ts index f1569394..38ed6429 100644 --- a/src/app/api/builder/[slug]/top-users/route.ts +++ b/src/app/api/builder/[slug]/top-users/route.ts @@ -23,7 +23,7 @@ export async function GET( req: Request, { params }: { params: Promise }, ) { - const r = rateLimit(clientKey(req, "hl-top-users"), 60, 60); + const r = rateLimit(clientKey(req, "hl-top-users"), 60, 60, req); if (!r.ok) return tooManyRequests(r.retryAfterSec); const { slug } = await params; diff --git a/src/app/api/chain/[slug]/live-prices/route.ts b/src/app/api/chain/[slug]/live-prices/route.ts index c5de8430..bbce1c7b 100644 --- a/src/app/api/chain/[slug]/live-prices/route.ts +++ b/src/app/api/chain/[slug]/live-prices/route.ts @@ -77,7 +77,7 @@ export async function GET( req: Request, { params }: { params: Promise<{ slug: string }> }, ) { - const r = rateLimit(clientKey(req, "chain-kpis-live"), 120, 60); + const r = rateLimit(clientKey(req, "chain-kpis-live"), 120, 60, req); if (!r.ok) return tooManyRequests(r.retryAfterSec); const { slug } = await params; diff --git a/src/app/api/citable/route.ts b/src/app/api/citable/route.ts index 251fde3f..972ceb22 100644 --- a/src/app/api/citable/route.ts +++ b/src/app/api/citable/route.ts @@ -1,37 +1,72 @@ import { NextResponse } from "next/server"; import { getBenchmarks } from "@/data/benchmarks"; import { SITE } from "@/data/site"; -import { fieldValue, leader, headlineSentence } from "@/lib/citation"; +import { AllBenchmarksDraftError } from "@/lib/spec"; +import { + fieldValue, + headlineSentence, + isInsufficient, + leader, +} from "@/lib/citation"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; export const runtime = "nodejs"; export const revalidate = 60; +/** Short 503 with a Retry-After hint, served when the aggregator has + * no live snapshot to surface (Prom blackout + cold KV). Beats serving + * an all-draft index that downstream LLM agents would treat as truth. */ +function unavailable(): NextResponse { + return NextResponse.json( + { error: "benchmarks_unavailable", retryAfterSec: 60 }, + { + status: 503, + headers: { + "cache-control": "no-store", + "retry-after": "60", + "access-control-allow-origin": "*", + }, + }, + ); +} + /** * Flat machine-readable index of every citable benchmark. Designed to be - * the **first** endpoint an AI agent or journalist crawls - gives them + * the **first** endpoint an AI agent or journalist crawls. Gives them * everything they need to decide whether to deep-link to a specific bench. * * License is intentionally surfaced per-row so downstream agents can * cite without needing to read the footer of every page. */ export async function GET(req: Request) { - const r = rateLimit(clientKey(req, "citable"), 60, 60); + const r = rateLimit(clientKey(req, "citable"), 60, 60, req); if (!r.ok) return tooManyRequests(r.retryAfterSec); - const benches = (await getBenchmarks()).filter((b) => b.editorialStatus === "live"); + let benches; + try { + benches = (await getBenchmarks()).filter( + (b) => b.editorialStatus === "live", + ); + } catch (err) { + if (err instanceof AllBenchmarksDraftError) return unavailable(); + throw err; + } const data = benches.map((b) => { - const top = leader(b); + const insufficient = isInsufficient(b); + const top = insufficient ? null : leader(b); + const status: "live" | "draft" | "insufficient" = insufficient + ? "insufficient" + : b.status; return { slug: b.slug, title: b.title, category: b.category, metric: b.metric, unit: b.unit, - status: b.status, - value: fieldValue(b), + status, + value: insufficient ? null : fieldValue(b), leader: top ? { name: top.name, slug: top.slug, value: top.value } : null, - sampleSize: b.sampleSize, + sampleSize: insufficient ? 0 : b.sampleSize, asOf: b.lastRunAt, headline: headlineSentence(b), url: `${SITE.url}/benchmarks/${b.slug}`, diff --git a/src/app/api/freshness/route.ts b/src/app/api/freshness/route.ts index ca2fded8..035351d5 100644 --- a/src/app/api/freshness/route.ts +++ b/src/app/api/freshness/route.ts @@ -71,7 +71,7 @@ const computeFreshness = unstable_cache( ); export async function GET(req: Request) { - const r = rateLimit(clientKey(req, "freshness"), 120, 60); + const r = rateLimit(clientKey(req, "freshness"), 120, 60, req); if (!r.ok) return tooManyRequests(r.retryAfterSec); // Resolve the spec list outside the cached function so its slug list diff --git a/src/app/api/llm-context/route.ts b/src/app/api/llm-context/route.ts index d4414fb1..b520e5f4 100644 --- a/src/app/api/llm-context/route.ts +++ b/src/app/api/llm-context/route.ts @@ -1,7 +1,13 @@ import { getBenchmarks } from "@/data/benchmarks"; import { SITE } from "@/data/site"; +import { AllBenchmarksDraftError } from "@/lib/spec"; import { fmtUnit } from "@/lib/format"; -import { fieldValue, headlineSentence, leader } from "@/lib/citation"; +import { + fieldValue, + headlineSentence, + isInsufficient, + leader, +} from "@/lib/citation"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -20,13 +26,31 @@ export const revalidate = 60; * per-region breakdown) but covers all 8 benches in one round-trip. */ export async function GET(req: Request) { - const r = rateLimit(clientKey(req, "llm-context"), 30, 60); + const r = rateLimit(clientKey(req, "llm-context"), 30, 60, req); if (!r.ok) { const tooMany = tooManyRequests(r.retryAfterSec); return new Response(await tooMany.text(), { status: tooMany.status, headers: tooMany.headers }); } - const benches = (await getBenchmarks()).filter((b) => b.editorialStatus === "live"); + let benches; + try { + benches = (await getBenchmarks()).filter( + (b) => b.editorialStatus === "live", + ); + } catch (err) { + if (err instanceof AllBenchmarksDraftError) { + return new Response("benchmarks_unavailable\n", { + status: 503, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + "retry-after": "60", + "access-control-allow-origin": "*", + }, + }); + } + throw err; + } const now = new Date().toISOString(); const lines: string[] = []; @@ -48,11 +72,15 @@ export async function GET(req: Request) { lines.push(`- Metric: ${b.metric} (${b.unit})`); lines.push(`- Page: ${SITE.url}/benchmarks/${b.slug}`); lines.push(`- JSON: ${SITE.url}/api/stat/${b.slug}`); - lines.push(`- Status: ${b.status}`); + const insufficient = isInsufficient(b); + const reportedStatus: "live" | "draft" | "insufficient" = insufficient + ? "insufficient" + : b.status; + lines.push(`- Status: ${reportedStatus}`); const v = fieldValue(b); const lead = leader(b); - if (v != null && lead) { + if (!insufficient && v != null && lead) { lines.push(`- Headline: ${headlineSentence(b)}`); lines.push(""); lines.push(`**Rankings (p50, 24h):**`); @@ -70,6 +98,12 @@ export async function GET(req: Request) { )}, success ${r.successRate.toFixed(1)}%, sample ${r.sampleSize ?? "n/a"})`, ); } + } else if (insufficient) { + // Surface the same insufficient sentence the other citable surfaces + // emit, so an LLM that pastes this Markdown into context never sees + // a fabricated winner for a bench whose harness lacks data. + lines.push(`- Headline: ${headlineSentence(b)}`); + lines.push(`- Insufficient samples to rank providers yet.`); } else { lines.push(`- ${b.status === "draft" ? "Draft (no live data yet)" : "Awaiting samples"}.`); } diff --git a/src/app/api/mcp/[transport]/route.ts b/src/app/api/mcp/[transport]/route.ts index 742010aa..809e4978 100644 --- a/src/app/api/mcp/[transport]/route.ts +++ b/src/app/api/mcp/[transport]/route.ts @@ -7,6 +7,7 @@ import { citationQuote, fieldValue, headlineSentence, + isInsufficient, leader, sparklineFor, } from "@/lib/citation"; @@ -208,15 +209,19 @@ const mcpHandler = createMcpHandler( async () => { const benches = (await getBenchmarks()).filter((b) => b.editorialStatus === "live"); const rows = benches.map((b) => { - const top = leader(b); + const insufficient = isInsufficient(b); + const top = insufficient ? null : leader(b); + const status: "live" | "draft" | "insufficient" = insufficient + ? "insufficient" + : b.status; return { slug: b.slug, title: b.title, category: b.category, metric: b.metric, unit: b.unit, - status: b.status, - value: fieldValue(b), + status, + value: insufficient ? null : fieldValue(b), leader: top, headline: headlineSentence(b), url: `${SITE.url}/benchmarks/${b.slug}`, @@ -279,25 +284,39 @@ const mcpHandler = createMcpHandler( isError: true, }; } - const top = leader(b); + const insufficient = isInsufficient(b); + const top = insufficient ? null : leader(b); + const status: "live" | "draft" | "insufficient" = insufficient + ? "insufficient" + : b.status; + const rankings = insufficient + ? b.results.map((r) => ({ + name: r.name, + slug: r.slug, + ms: { p50: null, p90: null, p99: null, mean: null }, + successRate: r.successRate, + })) + : b.results + .filter((r) => r.ms.p50 > 0) + .sort((a, c) => + b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50, + ) + .map((r) => ({ + name: r.name, + slug: r.slug, + ms: r.ms, + successRate: r.successRate, + })); const payload = { slug: b.slug, title: b.title, metric: b.metric, unit: b.unit, - status: b.status, - value: fieldValue(b), + status, + value: insufficient ? null : fieldValue(b), leader: top, - rankings: b.results - .filter((r) => r.ms.p50 > 0) - .sort((a, c) => (b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50)) - .map((r) => ({ - name: r.name, - slug: r.slug, - ms: r.ms, - successRate: r.successRate, - })), - sparkline: sparklineFor(b, top?.slug), + rankings, + sparkline: insufficient ? [] : sparklineFor(b, top?.slug), headline: headlineSentence(b), quote: citationQuote(b, SITE.url), pageUrl: `${SITE.url}/benchmarks/${b.slug}`, @@ -458,10 +477,15 @@ const mcpHandler = createMcpHandler( ], }; } - const top = leader(b); - const ranked = b.results - .filter((r) => r.ms.p50 > 0) - .sort((a, c) => (b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50)); + const insufficient = isInsufficient(b); + const top = insufficient ? null : leader(b); + const ranked = insufficient + ? [] + : b.results + .filter((r) => r.ms.p50 > 0) + .sort((a, c) => + b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50, + ); const md: string[] = []; md.push(`# ${b.title}`); @@ -501,21 +525,33 @@ const mcpHandler = createMcpHandler( // We attach both Markdown (default rendering) and JSON (structured // access) so clients can pick whichever matches their context. + const status: "live" | "draft" | "insufficient" = insufficient + ? "insufficient" + : b.status; const payload = { slug: b.slug, title: b.title, metric: b.metric, unit: b.unit, - value: fieldValue(b), + status, + value: insufficient ? null : fieldValue(b), leader: top, - rankings: ranked.map((r) => ({ - name: r.name, - slug: r.slug, - ms: r.ms, - successRate: r.successRate, - sampleSize: r.sampleSize, - })), - sparkline: sparklineFor(b, top?.slug), + rankings: insufficient + ? b.results.map((r) => ({ + name: r.name, + slug: r.slug, + ms: { p50: null, p90: null, p99: null, mean: null }, + successRate: r.successRate, + sampleSize: r.sampleSize ?? null, + })) + : ranked.map((r) => ({ + name: r.name, + slug: r.slug, + ms: r.ms, + successRate: r.successRate, + sampleSize: r.sampleSize, + })), + sparkline: insufficient ? [] : sparklineFor(b, top?.slug), headline: headlineSentence(b), quote: citationQuote(b, SITE.url), pageUrl: `${SITE.url}/benchmarks/${b.slug}`, @@ -549,7 +585,7 @@ const mcpHandler = createMcpHandler( * reject batches explicitly (see below). */ function rateLimited(req: Request): Response | null { const key = clientKey(req, "mcp"); - const r = rateLimit(key, 60, 60); + const r = rateLimit(key, 60, 60, req); if (!r.ok) return tooManyRequests(r.retryAfterSec); return null; } diff --git a/src/app/api/og/[slug]/route.tsx b/src/app/api/og/[slug]/route.tsx index a153fa32..d2449071 100644 --- a/src/app/api/og/[slug]/route.tsx +++ b/src/app/api/og/[slug]/route.tsx @@ -21,7 +21,7 @@ export async function GET( req: Request, { params }: { params: Promise<{ slug: string }> }, ) { - const r = rateLimit(clientKey(req, "og"), 60, 60); + const r = rateLimit(clientKey(req, "og"), 60, 60, req); if (!r.ok) return tooManyRequests(r.retryAfterSec); const { slug } = await params; diff --git a/src/app/api/series/[slug]/route.ts b/src/app/api/series/[slug]/route.ts index 638c064d..689d6346 100644 --- a/src/app/api/series/[slug]/route.ts +++ b/src/app/api/series/[slug]/route.ts @@ -37,7 +37,7 @@ export async function GET( req: Request, { params }: { params: Promise<{ slug: string }> }, ) { - const r = rateLimit(clientKey(req, "series"), 60, 60); + const r = rateLimit(clientKey(req, "series"), 60, 60, req); if (!r.ok) return tooManyRequests(r.retryAfterSec); const { slug } = await params; diff --git a/src/app/api/stat/[slug]/route.ts b/src/app/api/stat/[slug]/route.ts index 0101ab9a..47bcfe87 100644 --- a/src/app/api/stat/[slug]/route.ts +++ b/src/app/api/stat/[slug]/route.ts @@ -5,6 +5,7 @@ import { citationQuote, fieldValue, headlineSentence, + isInsufficient, leader, sparklineFor, } from "@/lib/citation"; @@ -19,12 +20,22 @@ export const revalidate = 60; * Single benchmark as a citable atomic unit. Designed to fit into one * agent tool call: ranked providers, sparkline, methodology link, * pre-formatted attribution string, and stable citation URL. + * + * Status field semantics: + * "live" - usable measurement, leader / value populated. + * "draft" - spec author has not published (editorialStatus draft). + * "insufficient" - editorially live but the harness has no usable + * sample yet (every provider p50 = 0, or runtime + * status flipped to draft mid-cycle). value, leader + * and rankings p50 are nulled so a consumer cannot + * accidentally cite a fabricated winner. The shape + * of the response is preserved. */ export async function GET( req: Request, { params }: { params: Promise<{ slug: string }> }, ) { - const r = rateLimit(clientKey(req, "stat"), 60, 60); + const r = rateLimit(clientKey(req, "stat"), 60, 60, req); if (!r.ok) return tooManyRequests(r.retryAfterSec); const { slug } = await params; @@ -42,7 +53,40 @@ export async function GET( ); } - const top = leader(b); + const insufficient = isInsufficient(b); + const top = insufficient ? null : leader(b); + const value = insufficient ? null : fieldValue(b); + // Status surfaced to consumers: "insufficient" wins over the raw + // runtime "live" flag when the predicate fires, so /api/stat stops + // claiming live data for a bench whose harness has nothing to show. + const status: "live" | "draft" | "insufficient" = insufficient + ? "insufficient" + : b.status; + + // Rankings: when insufficient we still return one entry per provider + // (shape preserved for any consumer that diff-tracks the provider set) + // but every p50 is nulled to drive home that no comparison is possible. + const rankings = insufficient + ? b.results.map((r) => ({ + name: r.name, + slug: r.slug, + ms: { p50: null, p90: null, p99: null, mean: null }, + successRate: r.successRate, + sampleSize: r.sampleSize ?? null, + })) + : b.results + .filter((r) => r.ms.p50 > 0) + .sort((a, c) => + b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50, + ) + .map((r) => ({ + name: r.name, + slug: r.slug, + ms: r.ms, + successRate: r.successRate, + sampleSize: r.sampleSize, + })); + const payload = { slug: b.slug, title: b.title, @@ -50,22 +94,13 @@ export async function GET( category: b.category, metric: b.metric, unit: b.unit, - status: b.status, + status, higherIsBetter: b.higherIsBetter, - value: fieldValue(b), + value, leader: top, - rankings: b.results - .filter((r) => r.ms.p50 > 0) - .sort((a, c) => (b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50)) - .map((r) => ({ - name: r.name, - slug: r.slug, - ms: r.ms, - successRate: r.successRate, - sampleSize: r.sampleSize, - })), - sparkline: sparklineFor(b, top?.slug), - sampleSize: b.sampleSize, + rankings, + sparkline: insufficient ? [] : sparklineFor(b, top?.slug), + sampleSize: insufficient ? 0 : b.sampleSize, asOf: b.lastRunAt, headline: headlineSentence(b), quote: citationQuote(b, SITE.url), diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index c165674d..56afc33d 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -3,21 +3,20 @@ 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 } from "@/data/benchmarks"; +import { getBenchmark, getBenchmarksSafe } from "@/data/benchmarks"; import { Pill } from "@/components/pill"; import { BenchmarkBody } from "@/components/benchmark-body"; import { BenchmarkBodySkeleton } from "@/components/benchmark-body-skeleton"; import { OraclePairMatrix } from "@/components/oracle-pair-matrix"; import { Breadcrumb } from "@/components/breadcrumb"; import { ChainHeadingsSummary } from "@/components/chain-headings-summary"; -import { OraclePairMatrix } from "@/components/oracle-pair-matrix"; import { CitationBar } from "@/components/citation-bar"; import { LiveIndicator } from "@/components/live-indicator"; import { ShareSection } from "@/components/share-section"; import { ExportVideoSection } from "@/components/export-video-section"; import { ReportSection } from "@/components/report-section"; import { CATEGORY_COLOR } from "@/lib/category-colors"; -import { headlineSentence } from "@/lib/citation"; +import { headlineSentence, isInsufficient } from "@/lib/citation"; import { capDescription } from "@/lib/seo-text"; import { getBenchCreatedAt } from "@/lib/seo/bench-dates"; import { SITE } from "@/data/site"; @@ -159,7 +158,7 @@ export default async function BenchmarkPage({ // /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 all = await getBenchmarksSafe(); // Seed ONLY the unfiltered key. Seeding the initially-selected // chain/region/kind combo with the aggregate made the client believe // it already had that variant, so it never fetched the real one: the @@ -173,6 +172,10 @@ export default async function BenchmarkPage({ const isDraft = benchmark.status === "draft"; const isAwaiting = isDraft && benchmark.editorialStatus === "live"; + // Insufficient: editorially live, runtime might say "live" too, but the + // shared predicate decided no provider has a usable p50. Drives the + // pill above the H1 and the headline degradation downstream. + const insufficient = isInsufficient(benchmark); // Cap the "more benchmarks" rail at 6 items so it doesn't turn into // an endless single-column scroll on mobile (with 18 benches the old // unlimited list rendered 17 full cards stacked). Prefer same-category @@ -322,7 +325,10 @@ export default async function BenchmarkPage({ {isAwaiting ? "awaiting samples" : "draft"} )} - {!isDraft && ( + {!isDraft && insufficient && ( + insufficient samples + )} + {!isDraft && !insufficient && ( diff --git a/src/app/benchmarks/[slug]/share-card/route.tsx b/src/app/benchmarks/[slug]/share-card/route.tsx index 02a21184..be8f7e7b 100644 --- a/src/app/benchmarks/[slug]/share-card/route.tsx +++ b/src/app/benchmarks/[slug]/share-card/route.tsx @@ -494,7 +494,7 @@ export async function GET( // benchmark loaders, each render is 50-200ms CPU. Without this an // attacker hitting random query-string variants would burn function // CPU even for unknown slugs. - const rl = rateLimit(clientKey(request, "share-card"), 60, 60); + const rl = rateLimit(clientKey(request, "share-card"), 60, 60, request); if (!rl.ok) return tooManyRequests(rl.retryAfterSec); const { slug } = await params; diff --git a/src/app/benchmarks/page.tsx b/src/app/benchmarks/page.tsx index 81817cc3..3126513c 100644 --- a/src/app/benchmarks/page.tsx +++ b/src/app/benchmarks/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { getBenchmarks } from "@/data/benchmarks"; +import { getBenchmarksSafe } from "@/data/benchmarks"; import { BenchmarkGrid } from "@/components/benchmark-grid"; import { safeJsonLd } from "@/lib/jsonld"; @@ -28,7 +28,7 @@ export const metadata: Metadata = { }; export default async function BenchmarksPage() { - const benchmarks = await getBenchmarks(); + const benchmarks = await getBenchmarksSafe(); // ItemList + BreadcrumbList JSON-LD so search engines and LLMs see the // page as a structured registry (the data is already in the DOM but diff --git a/src/app/compare/[slug]/loading.tsx b/src/app/compare/[slug]/loading.tsx deleted file mode 100644 index 2597da75..00000000 --- a/src/app/compare/[slug]/loading.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Loading UI rendered by Next.js during navigation to /compare/[slug]. - * Picked up automatically when the route's async render is in flight, - * which is the visible window where ad-hoc (non-curated) pairs pay the - * full cold start cost: every loadBenchmark for every shared bench - * fans out chain + region variant fetches. Without this file the user - * sees a frozen current page while the browser waits on the route - * payload; with it the visitor gets instant feedback that the compare - * page is building. - */ -export default function ComparePairLoading() { - return ( -
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-

- Loading live measurements -

-
- {Array.from({ length: 3 }).map((_, i) => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))} -
-

- First hit on a brand new pair can take a few seconds while the - per chain and per region variants fan out. Subsequent visits - and other users land on the cached render. -

-
-
- ); -} diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx index 8b8609f4..0e32f3da 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -122,16 +122,53 @@ function canonicalisationTarget(slug: string): string | null { return canonical === slug ? null : canonical; } +/** Lightweight precheck: does this pair have at least one shared bench + * after applying the whitelist + exclude rules? Pure set arithmetic on + * the already-loaded provider appearances. No Prom calls, no KV + * lookup, no fan out. + * + * Mirrors the candidate-slug computation inside `buildSharedBenches` + * so the two stay in lockstep. Called by `generateMetadata` so a pair + * whose providers both exist but share zero benches notFound()s + * before any HTML streams. */ +function hasSharedBenches( + pair: ComparePair, + aAppearances: Awaited>, + bAppearances: Awaited>, +): boolean { + if (!aAppearances || !bAppearances) return false; + const aSlugs = new Set(aAppearances.appearances.map((x) => x.benchmark.slug)); + const bSlugs = new Set(bAppearances.appearances.map((x) => x.benchmark.slug)); + const candidateSlugs = pair.benchmarks + ? pair.benchmarks.filter((s) => aSlugs.has(s) && bSlugs.has(s)) + : Array.from(aSlugs).filter((s) => bSlugs.has(s)); + const excluded = new Set(pair.excludeBenchmarks ?? []); + return candidateSlugs.some((s) => !excluded.has(s)); +} + export async function generateMetadata({ params, }: { params: Promise; }): Promise { const { slug } = await params; + // Run the same gating logic as the page render so non-canonical and + // invalid slugs short-circuit at the metadata phase. Combined with + // the loading.tsx removal in this hotfix, notFound() here cleanly + // produces a real 308 / 404 response from the route layer instead + // of a 200 wrapping a streamed loading skeleton. + const canonicalTarget = canonicalisationTarget(slug); + if (canonicalTarget) redirect(`/compare/${canonicalTarget}`); const pair = getComparePair(slug) ?? (await resolveAdHocPair(slug)); - if (!pair) return {}; + if (!pair) notFound(); const { a, b } = await loadPairProviders(pair); - if (!a || !b) return {}; + if (!a || !b) notFound(); + // Final SSR gate: an ad-hoc pair can have both providers resolved + // yet share zero benches (e.g. an RPC provider vs an oracle). + // Without this the page body's `shared.length === 0` check fires + // late and the response loses its chance to demote the status code. + // Cheap: only the appearance intersection, no Prom fan out. + if (!hasSharedBenches(pair, a, b)) notFound(); const url = `${SITE.url}/compare/${pair.slug}`; const title = `${a.name} vs ${b.name}: live OpenChainBench benchmark data`; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fdd7a364..4fb27720 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -161,12 +161,22 @@ export default function RootLayout({ own URL-bar overlay animates the layout viewport on top of the WebKit regression. CSS below downgrades the site header to non-sticky only when this class is present. Inline so the class - is on the html element before React hydrates — no flash. */} + is on the html element before React hydrates (no flash). */}