diff --git a/.gitignore b/.gitignore index f21b1015..c9d710d4 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,12 @@ yarn-error.log* # vercel .vercel +# Prebuild-generated sitemap freshness manifest. Written by +# scripts/emit-page-mtimes.ts on every build (git log %ct of editorial +# hub pages) so the sitemap can emit a real from git history +# without depending on Vercel's build-container file mtimes. +/data/page-mtimes.json + # Internal-only docs that must NOT ship with the open-source repo. # Patterns reserved as a tripwire: if a contributor adds a new doc with # one of these naming conventions, git won't track it without an diff --git a/alternatives/alchemy.yml b/alternatives/alchemy.yml index a2d7aa69..f5af808d 100644 --- a/alternatives/alchemy.yml +++ b/alternatives/alchemy.yml @@ -9,3 +9,6 @@ intro: | seo_title: "Alchemy alternatives: live benchmark" seo_description: Compare Alchemy alternatives on real-time data freshness. Live head-lag against a canonical archive node, refreshed every minute and published openly. + +positioning: | + Alchemy's moat is a vertically integrated node platform. It runs archive nodes across 100+ chains (approximately 120 listed on the chain-connect page) and layers a data stack on top of the same infrastructure (Node API, Prices API, Notify webhooks, Subgraphs). For head lag, the relevant surface is the WebSocket subscription endpoint (eth_subscribe, plus proprietary alchemy_pendingTransactions and alchemy_minedTransactions) and the Notify webhook system for Address Activity and Custom Webhooks. Alchemy pushes filtered logs rather than pre-decoded swap events, which means an integrator must maintain a Transfer or Swap topic filter and decode the ABI client-side. The Prices API is a REST product with cached quotes drawn from CEX plus DEX aggregation, not a live swap feed. Alchemy tends to win when a team already runs the Node API for reads and wants notifications on the same account boundary; teams that need a decoded, cross-chain DEX trade stream typically migrate to a purpose-built aggregator because the client-side decoding overhead adds pipeline hops. Chain coverage skews heavily to EVM plus Solana; long-tail EVM L2s and Bitcoin sit inside the standard Node API surface. diff --git a/alternatives/birdeye.yml b/alternatives/birdeye.yml index 1dead0c1..e76fc5e2 100644 --- a/alternatives/birdeye.yml +++ b/alternatives/birdeye.yml @@ -10,3 +10,6 @@ intro: | seo_title: "Birdeye alternatives: live benchmark" seo_description: "Birdeye alternatives on Solana token metadata coverage: logo, description, socials in seconds. Codex vs Mobula live." + +positioning: | + Birdeye's architectural weight sits on Solana. The public docs list 14 supported networks with Solana first and a longer tail of EVM and non-EVM chains (Ethereum, Arbitrum, BSC, Base, zkSync, Sui, Aptos, Monad, MegaETH, Fogo, Avalanche, Optimism, Polygon). The DEX Protocols page cites data drawn from more than 180 AMMs, with Solana-native venues like Raydium, Raydium CLMM, Orca and Whirlpool getting first-class treatment. The API surface that matters for metadata coverage is the DeFi and Token endpoints (/defi/price, /defi/token_overview, /defi/token_security), which return price, liquidity, supply, market cap and holder-side signals as normalized JSON. Because Solana pools are volatile and permissionless, a metadata provider either owns direct pool ingestion or inherits gaps; Birdeye publishes coverage caveats on Sui (no wallet APIs, no token security data, missing market cap and supply), which suggests per-chain pipelines rather than one uniform indexer. Teams pick Birdeye when the primary target is Solana memecoin and DEX token metadata under a single API. Teams tend to migrate when they need long-tail EVM L2 coverage or deep historical DEX reconstruction across chains where Birdeye ships less depth per chain. diff --git a/alternatives/bitquery.yml b/alternatives/bitquery.yml index 76380c11..9178d1e4 100644 --- a/alternatives/bitquery.yml +++ b/alternatives/bitquery.yml @@ -9,3 +9,6 @@ intro: | seo_title: "Bitquery alternatives: live benchmark" seo_description: Compare Bitquery alternatives on the number of blockchains each major onchain data provider officially supports. Live data, refreshed every six hours. + +positioning: | + Bitquery's moat is a unified GraphQL schema fanned out across four transports: GraphQL over HTTP, WebSocket subscriptions, Kafka with Protobuf encoding, and a Solana gRPC product (CoreCast) fed by validator-side Geyser plugins. Coverage is 40+ chains, uniform schema, decoded DEX trades with USD notional pre-computed server-side. For a chain-coverage benchmark that measures how many networks a provider officially supports for market data, Bitquery is structurally competitive because the same query works across every indexed chain without per-chain re-plumbing. The DEX Trades API is the load-bearing endpoint for market data: it exposes buys, sells, swaps, OHLCV at 1-second resolution, and cross-DEX aggregates on a single graph. Delivery latency is tiered by transport: gRPC under 300ms, Kafka under 500ms, WebSocket around a second, which lets integrators pick the durability vs speed trade-off explicitly. Teams win with Bitquery when they need historical plus real-time on the same schema, especially for analytics or accounting pipelines; teams that only want a decoded firehose of Solana Pump.fun trades sometimes pick a narrower vendor because Bitquery's breadth carries a higher entry price. diff --git a/alternatives/chainlink.yml b/alternatives/chainlink.yml index 401fbea0..a96d9e17 100644 --- a/alternatives/chainlink.yml +++ b/alternatives/chainlink.yml @@ -11,3 +11,6 @@ seo_title: "Chainlink alternatives: live benchmark" seo_description: "Chainlink vs Pyth, Binance and Coinbase on live oracle deviation. Max pairwise gap on BTC, ETH, SOL." status: live + +positioning: | + Chainlink Data Feeds publish aggregated prices on-chain through an AggregatorV3 contract that is fed by a Decentralized Oracle Network running Offchain Reporting, with new rounds triggered either by a configured deviation threshold or by a heartbeat interval, and read via a proxy contract that returns latestRoundData() in the same transaction as the consuming logic. Its architectural moat is on-chain durability and the largest deployed integration surface across EVM chains, exposed through AggregatorV3Interface with no off-chain refresh step required. That model does not maximise for oracle-deviation on fast-moving assets, because the on-chain price only advances when the deviation trigger fires or the heartbeat elapses, so the reported value can lag a moving spot market by roughly one trigger-width until the next round lands. Chainlink also ships Data Streams, a pull-based sub-second latency product accessed over REST, WebSocket and SDKs, aimed at perpetual futures, options and prediction market venues that need low-latency data with on-chain verification. Lending markets, stablecoin issuers and blue-chip settlement continue on Data Feeds for the on-chain read pattern. Perp DEXes and derivatives protocols that cannot tolerate deviation lag on volatile assets migrate the price-sensitive path to Data Streams or a pull oracle. diff --git a/alternatives/coingecko.yml b/alternatives/coingecko.yml index 45c32a58..3b056eb6 100644 --- a/alternatives/coingecko.yml +++ b/alternatives/coingecko.yml @@ -9,3 +9,6 @@ intro: | seo_title: "CoinGecko alternatives: live benchmark" seo_description: Compare CoinGecko alternatives on the number of blockchains each major onchain data API officially supports. Live data, refreshed every six hours. + +positioning: | + CoinGecko's moat is aggregation breadth across both centralized and decentralized venues under a single independent methodology. The core surface indexes 1,500+ CEXs and 18,000+ coins; the on-chain arm, delivered through GeckoTerminal and exposed under the /onchain endpoint family, adds 200+ blockchain networks, 1,500+ DEXs and tens of millions of tokens. For a chain-coverage benchmark on market data, this is the widest reported footprint of any listed provider in the cluster. Delivery is REST across every tier, with WebSocket and webhook channels gated to higher tiers. Data is not sourced from partner feeds or exchange-owned data providers but crawled and aggregated independently, which is the architectural reason CoinGecko's price on a long-tail token often differs from a venue-native quote. The /onchain/networks and /onchain/pools endpoints are the practical entry points for chain-scoped queries. CoinGecko wins for teams building broad market-data dashboards, tax and PnL products, or research tools that need one API to cover every chain a user might touch; teams focused on a single L2 or a specific DEX with millisecond freshness typically pair it with a narrower, WebSocket-native vendor. diff --git a/alternatives/drpc.yml b/alternatives/drpc.yml index cfc1b373..27f0a8b5 100644 --- a/alternatives/drpc.yml +++ b/alternatives/drpc.yml @@ -11,3 +11,6 @@ seo_title: "dRPC alternatives: live benchmark" seo_description: "dRPC alternatives on eth_blockNumber p50 latency. PublicNode, 1RPC, Tenderly, Nodies, Lava ranked live." status: live + +positioning: | + dRPC is a routing layer, not a node operator. Every request lands on Dproxy, which scores third-party providers on health, capability, location, and recent compute-unit distribution; the request is then handed to Dshackle, an open-source fault-tolerant load balancer that picks the specific upstream node based on the method called, the current chain head, and the block context required. The routing is method-aware: a sendRawTransaction fans out to every capable node, a balance read routes only to lag-free upstreams, and a historical read filters to nodes that have synced to the requested block. This is architecturally distinct from single-operator RPCs, where a request runs against one homogeneous fleet, and distinct from public free RPCs, which have no cross-provider selection at all. For the rpc-capabilities axis, the routing model directly shapes the observed method coverage: a method that any upstream supports counts as covered network-wide, and uptime aggregates across upstreams rather than depending on any one operator's incident window. Chain coverage spans 120+ chains across 217 networks (Chainlist product) spanning EVM L1s and L2s, Solana, Bitcoin, Cosmos, and TON. The typical use case is teams that want one endpoint per chain with automatic failover; migration happens when a team needs a proprietary method only one upstream exposes. diff --git a/alternatives/dune.yml b/alternatives/dune.yml index 42784527..615362ad 100644 --- a/alternatives/dune.yml +++ b/alternatives/dune.yml @@ -9,3 +9,6 @@ intro: | seo_title: "Dune alternatives: live benchmark" seo_description: "Dune alternatives on wallet labelling depth. Recently active address coverage across major EVM chains." + +positioning: | + Dune runs two distinct product surfaces with different pipelines. The core Dune Analytics product exposes SQL over decoded blockchain tables (Trino-backed, dbt-integrated), where community-maintained Spellbook models sit on top of raw, decoded, and traces tables. The Sim API is the real-time product, positioned in the docs as covering more than 60 EVM chains plus Solana and Eclipse with a REST surface for Balances, Activity, Transactions, Token Info, Token Holders, DeFi Positions, Collectibles and Webhooks. For a wallet-labels-coverage benchmark, the load-bearing distinction is that entity labels historically live in Dune Analytics as SQL tables and community spells rather than as a single REST endpoint on Sim; the Sim quickstart categorizes its endpoints around balances, activity, and token info, not wallet identity. Coverage of labels is therefore a function of what analysts have committed to the Spellbook plus what Sim exposes through its search and metadata endpoints. Teams pick Dune when the workflow is analyst-driven and labels are consumed via SQL joins in dashboards. Teams migrate when a product needs a single low-latency REST call that returns "is this address a CEX, MEV bot, bridge, or protocol contract" without writing a SQL query first. diff --git a/alternatives/dydx.yml b/alternatives/dydx.yml index 9a136733..83c736e8 100644 --- a/alternatives/dydx.yml +++ b/alternatives/dydx.yml @@ -11,3 +11,6 @@ seo_title: "dYdX alternatives: live benchmark" seo_description: Compare dYdX v4 against Hyperliquid, Lighter, GMX v2 and gains.trade on live taker fee plus spread crossed at $1000 notional, refreshed every five minutes. status: live + +positioning: | + dYdX v4 runs on an application-specific Cosmos SDK chain purpose-built for perpetuals, migrated off the Ethereum-based smart contracts in October 2023 with trading going live in November 2023. The moat sits at the pipeline level: validators maintain an off-chain in-memory orderbook for low-latency matching, then settle fills on-chain via consensus. Order placement and cancellation cost zero gas; fees apply only on execution and accrue to validators and stakers rather than a central operator. This split of matching from settlement is architecturally distinct from Ethereum L1 perp DEXs (fully on-chain, gas per action) and from single-sequencer L2 venues (matching lives on a proprietary node). For the perp-fees axis, the design has two direct consequences. Fees are set by governance parameter rather than by an operator, so tier changes are proposal-gated and observable on-chain. Rebates flow to on-chain makers under the same maker-taker schedule that a CEX would run, but with funds routed to the protocol staking layer instead of a company. The typical use case is high-volume market makers who want a maker rebate on a decentralized venue without paying per-order gas; teams migrate away when they need cross-margined equity products the Cosmos chain does not list. diff --git a/alternatives/etherscan.yml b/alternatives/etherscan.yml index a964714d..8bcc0bf5 100644 --- a/alternatives/etherscan.yml +++ b/alternatives/etherscan.yml @@ -11,3 +11,6 @@ seo_title: "Etherscan alternatives: live benchmark" seo_description: Compare Etherscan against PublicNode feeHistory and Owlracle on gwei gap between predicted and realized priority fee, ranked on p99 over 24h. status: live + +positioning: | + Etherscan's moat is not a data product; it is a decade of running the canonical block explorer for Ethereum and the derivative explorer family (Polygonscan, BscScan, Arbiscan, Optimistic Etherscan and others), consolidated under Etherscan API V2 as one endpoint across 50+ chains. Gas Tracker rides on that same explorer infrastructure. The gastracker module exposes two actions: gasoracle returns SafeGasPrice, ProposeGasPrice, FastGasPrice, suggestBaseFee, LastBlock and gasUsedRatio; gasestimate returns a confirmation-time estimate for a supplied gas price. The public Gas Tracker page shows a countdown of "Next update in 10s" and breaks recommendations into Base plus Priority components, consistent with post-EIP-1559 fee mechanics. For the gas-estimation benchmark, the load-bearing point is that Etherscan sees the same block stream and pending pool that feeds the block explorer, so its recommendation is a direct function of recent-block base fee trajectory and the priority-tip distribution. Teams pick the Etherscan gas oracle when the integration is Ethereum-first and already uses etherscan.io for verified contracts and receipts. Teams migrate when they need mempool-derived estimates on non-EVM chains or a per-transaction priority-fee curve rather than three fixed tiers. diff --git a/alternatives/gmx.yml b/alternatives/gmx.yml index c3187ae0..a95a06ac 100644 --- a/alternatives/gmx.yml +++ b/alternatives/gmx.yml @@ -11,3 +11,6 @@ seo_title: "GMX alternatives: live benchmark" seo_description: Compare GMX v2 against Hyperliquid, dYdX, Lighter and gains.trade on live taker fee plus spread crossed at $1000 notional, refreshed every five minutes. status: live + +positioning: | + GMX V2 uses isolated GM liquidity pools rather than an orderbook. Each market has its own pool that pays out trader profits and absorbs trader losses, priced against a low-latency oracle rather than crossed against maker quotes. The V2 architecture runs on Arbitrum, Avalanche and MegaETH, with keeper contracts executing opens and closes so that the trader submits an intent and the keeper fills it at the next oracle price. This oracle-driven pool model is architecturally distinct from CLOB perp venues: there is no maker side, so there is no maker rebate to earn, and the fee schedule replaces the taker-only price with a bundle of position fee, price impact fee, borrow fee, and funding fee. For the perp-fees axis, this matters because a headline taker-only comparison understates GMX cost on skewed markets, where the borrow fee accrues hourly against the dominant side, and understates savings on balanced markets, where the position fee drops when a trade reduces open-interest skew. The typical use case is directional traders on major pairs who prefer no order management; teams migrate to CLOB venues once they need maker rebates or tight passive quoting. diff --git a/alternatives/hyperliquid.yml b/alternatives/hyperliquid.yml index 5c8a0f0d..3c50120e 100644 --- a/alternatives/hyperliquid.yml +++ b/alternatives/hyperliquid.yml @@ -11,3 +11,6 @@ seo_title: "Hyperliquid alternatives: live benchmark" seo_description: Compare Hyperliquid against Lighter, dYdX, GMX v2 and gains.trade on live taker fee plus spread crossed at $1000 notional, refreshed every five minutes. status: live + +positioning: | + Hyperliquid runs a purpose-built L1 whose flagship application is the perp exchange itself. HyperCore holds the orderbook, margin, and matching engine as protocol state; HyperBFT, a HotStuff variant, orders blocks with median end-to-end latency of 0.2 seconds for co-located clients. Every resting order, fill, and liquidation is part of consensus and visible to every node, with strict price-time priority. This is architecturally different from dYdX v4, where matching happens in a validator in-memory book and only settlement lands on chain, and different from GMX, which has no orderbook at all. For the perp-fees axis, the design has two implications. The fee schedule is a 7-tier maker-taker table keyed to 14-day weighted volume, with maker rebates paid on every trade rather than settled periodically. HYPE staking layers a separate 5 to 40 percent discount as an overlay on top of the numbered volume tiers, and staking-linking lets a discount wallet apply to a distinct trading wallet, permanently. The typical use case is active perp traders and market makers who want CEX-shaped fees without a central operator; teams migrate away when they need EVM composability HyperCore itself does not offer, in which case HyperEVM sits on the same validator set. diff --git a/alternatives/jupiter.yml b/alternatives/jupiter.yml index 3288f0e9..e0db3829 100644 --- a/alternatives/jupiter.yml +++ b/alternatives/jupiter.yml @@ -11,3 +11,6 @@ seo_title: "Jupiter alternatives: live benchmark" seo_description: Compare Jupiter against Mobula, OpenOcean and Raydium on live Solana quote latency. 100 USDC to long-tail token, polled every 60 seconds from 3 regions. status: live + +positioning: | + Jupiter is Solana-native and structured around two paired products. Metis is the routing primitive; Ultra is the managed execution engine on top. The Metis Update post describes slippage-aware routing, dynamic intermediate tokens and just-in-time routing as the mechanics behind route selection, and the Ultra vs Metis comparison frames Metis as a routing library callers wrap with their own RPC and transaction pipeline, while Ultra bundles transaction construction, priority-fee optimization, confirmation polling and error handling behind a single /order endpoint that averages 400 to 600 milliseconds end-to-end with p95 under one second. For a Solana DEX quote-latency benchmark, the load-bearing detail is that Jupiter runs the routing infrastructure that the majority of Solana DeFi frontends and aggregators call into, so the quote surface is a first-party product rather than a resold feed. The Quote API accepts inputMint, outputMint, amount and slippageBps, and returns a route that can traverse multiple Solana AMMs including Raydium, Orca, Meteora and Phoenix. Teams pick Jupiter when the target chain is Solana and a single call needs to price a swap across the full venue set. Teams migrate when the workflow leaves Solana or requires custom market-making beyond aggregated liquidity. diff --git a/alternatives/kalshi.yml b/alternatives/kalshi.yml index ec8401bb..e2628595 100644 --- a/alternatives/kalshi.yml +++ b/alternatives/kalshi.yml @@ -11,3 +11,6 @@ seo_title: "Kalshi alternatives: live benchmark" seo_description: "Kalshi vs Polymarket, Limitless, Manifold, Myriad on warm book latency and ramp throttle (us-east, eu-west, sgp)." status: live + +positioning: | + Kalshi is a US event-contracts exchange with two API surfaces (Predictions and Perps) exposed over REST, WebSocket and FIX, authenticated by API key and governed by a documented token-budget rate-limit model with 7 named tiers (Basic, Advanced, Expert, Premier, Paragon, Prime, Prestige) that scale from a few hundred to several thousand tokens per second, with a burst allowance after idle time. Its architectural moat is regulatory: the venue operates under CFTC-recognised status for event contracts, which shapes the API surface (identified market participants, published limits, FIX access for institutional flow) and the data redistribution terms that any downstream consumer inherits. For a pm-rate-limits benchmark, that means Kalshi is the cohort's baseline for what a documented contract looks like: token budgets are formally tiered, order and book endpoints go to origin on every call, and the market-list endpoint is served from a CDN with a short max-age, so list latency measures the edge. WebSocket access requires authentication, which is standard for a regulated venue and keeps the public unauthenticated WebSocket surface small. Trading systems that require CFTC-supervised counterparty, FIX connectivity or auditable API terms integrate on Kalshi natively. Teams migrate to a Polygon-based CLOB when the product requires permissionless market creation, non-US retail flow, or on-chain settlement receipts that the regulated venue's off-chain matching does not surface. diff --git a/alternatives/lifi.yml b/alternatives/lifi.yml index 6e1eff7a..b58f4522 100644 --- a/alternatives/lifi.yml +++ b/alternatives/lifi.yml @@ -9,3 +9,6 @@ intro: | seo_title: "Li.Fi alternatives: 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. + +positioning: | + Li.Fi is a cross-chain routing and orchestration layer that normalises calls across many bridges, DEX aggregators and intent networks behind a unified /quote and /routes API surface, plus a TypeScript SDK and a drop-in widget. Its architectural moat is coverage: routes are searched across a wide set of underlying providers (Across, Hop, Mayan, Allbridge, Symbiosis, Squid, Synapse, CCTP, Wormhole among others), token standards and chain differences are normalised, and the response returns a full execution plan with calldata, approval steps and gas estimates ready for a wallet to sign. That breadth matters when a builder needs any-token-to-any-token support without hand-integrating each bridge, and it does not matter, or actively hurts, when the metric is bridge-quote-latency on a single well-known corridor. The /quote call fans out across underlying providers and picks a winner, so its wall-clock reflects the slowest leg searched, not the fastest route returned. Builders integrating a wallet swap modal, a portfolio rebalance tool or a treasury flow across long-tail chains stay on Li.Fi for the coverage. Teams that route a fixed hot corridor and need sub-second p95 typically front the Li.Fi SDK with a faster narrower router for the paths they actually use. diff --git a/alternatives/polymarket.yml b/alternatives/polymarket.yml index f9891835..cbbf5e7c 100644 --- a/alternatives/polymarket.yml +++ b/alternatives/polymarket.yml @@ -11,3 +11,6 @@ seo_title: "Polymarket alternatives: live benchmark" seo_description: Compare Polymarket against Kalshi, Limitless, Manifold and Myriad on warm book latency and daily ramp throttle behaviour, from us-east, eu-west and sgp. status: live + +positioning: | + Polymarket runs a hybrid Central Limit Order Book where orders match off-chain and settle atomically on Polygon through an audited exchange contract, with orders expressed as EIP-712 signed messages rather than exchange IOUs. Authentication is layered: L1 EIP-712 signatures derive API credentials from the user's private key, then L2 HMAC-SHA256 headers (POLY_ADDRESS, POLY_SIGNATURE, POLY_TIMESTAMP, POLY_API_KEY, POLY_PASSPHRASE) sign subsequent trading requests. Its architectural moat is non-custodial CLOB throughput on Polygon: the operator cannot execute unauthorised trades or set prices, and the matching engine sits primarily in eu-west-2 with co-location available after KYC. For a pm-rate-limits benchmark, the surface to size against is the CLOB /book and /midpoint endpoints plus the Gamma markets endpoint, all fronted by Cloudflare. The Cloudflare layer queues bursts instead of returning 429, so a polling loop sees rising latency before it sees an error, which changes what a throttle test can actually measure. Prediction-market builders, permissionless market creators and retail flow outside US jurisdiction integrate on Polymarket natively. Teams migrate to a regulated venue when they need CFTC-supervised counterparty, FIX connectivity or venue-side compliance guarantees the on-chain settlement model does not provide. diff --git a/alternatives/publicnode.yml b/alternatives/publicnode.yml index 687f0a32..881f72ee 100644 --- a/alternatives/publicnode.yml +++ b/alternatives/publicnode.yml @@ -11,3 +11,6 @@ seo_title: "PublicNode alternatives: live benchmark" seo_description: "PublicNode alternatives on eth_blockNumber p50 latency. dRPC, 1RPC, Tenderly, Nodies, Lava ranked live." status: live + +positioning: | + PublicNode is a free, registration-less RPC endpoint service operated by Allnodes, a node-hosting company. Every endpoint is served from Allnodes' own validator and full-node fleet across 78 blockchains, with global load balancing across regional clusters and no per-user API key. MEV-protected endpoints are enabled by default on Ethereum, Base, BSC, Solana, and Arbitrum, routing transactions through private order-flow relays rather than the public mempool. This is architecturally distinct from routing layers such as dRPC, which multiplex third-party providers, and distinct from developer-tool RPCs such as Tenderly, which layer proprietary simulation methods on top. PublicNode owns the underlying hardware but exposes only standard JSON-RPC, WebSocket, and where applicable gRPC and REST surfaces. For the rpc-capabilities axis, this means method coverage is whatever the upstream client (Geth, Erigon, Solana Agave, and so on) natively exposes, without extended tenderly_ or debug_ namespaces added on top. Uptime and latency depend on a single operator rather than a routing pool. The typical use case is dApps that need a fallback or public endpoint without a signup flow; teams migrate to paid providers when they need archive depth, guaranteed capacity, or historical trace methods PublicNode does not expose. diff --git a/alternatives/pump-portal.yml b/alternatives/pump-portal.yml index f907d4db..2ac116f9 100644 --- a/alternatives/pump-portal.yml +++ b/alternatives/pump-portal.yml @@ -9,3 +9,6 @@ intro: | seo_title: "PumpPortal alternatives: live benchmark" seo_description: "Pump Portal alternatives: Codex, GeckoTerminal, Mobula on real-time block ingestion + Solana mint feed." + +positioning: | + PumpPortal is a Solana-only, Pump.fun-native WebSocket relay. The Data API exposes a single connection at wss://pumpportal.fun/api/data with four subscription methods: subscribeNewToken, subscribeMigration, subscribeTokenTrade and subscribeAccountTrade. There is no REST fallback and no polling surface; the entire product is push. The moat is protocol specialization rather than infrastructure: PumpPortal decodes the Pump.fun bonding curve and PumpSwap AMM directly, so a launch, a trade or a migration to Raydium appears as a typed event through subscribeMigration without any client-side program-log parsing. For head lag on Solana specifically, this matters because the alternative is running a Yellowstone gRPC subscription plus a bonding-curve decoder in-house. Coverage does not extend beyond Solana Pump.fun and PumpSwap, so this product does not compete on breadth, only on latency to a specific event surface. It also ships a Lightning Transaction API for signed swap execution, which is a separate REST product. Teams win with PumpPortal when the use case is a Solana memecoin sniper or a Pump.fun-specific dashboard; teams migrate away the moment coverage needs to include Raydium CLMM, Meteora, Orca or any non-Solana venue. diff --git a/alternatives/pyth.yml b/alternatives/pyth.yml index 00fce2ef..c960f169 100644 --- a/alternatives/pyth.yml +++ b/alternatives/pyth.yml @@ -11,3 +11,6 @@ seo_title: "Pyth alternatives: live benchmark" seo_description: "Pyth vs Chainlink, Binance and Coinbase on live oracle deviation. Max pairwise gap on BTC, ETH, SOL." status: live + +positioning: | + Pyth Network is a pull oracle in which first-party publishers (exchanges, banks, trading firms and market makers) submit prices directly to an oracle program running on Pythnet, which aggregates them per slot into a single price and confidence interval, and then distributes signed updates cross-chain for permissionless refresh on the consuming chain. Its architectural moat is the first-party publisher set plus the sub-second aggregation cadence: the off-chain quote refreshes every 400 milliseconds, and the on-chain price advances whenever an integrator submits an update, rather than when a gas-paid node operator crosses a deviation trigger. For an oracle-deviation benchmark against canonical CEX prints, that model concentrates the disagreement in Pyth's favour on fast-moving assets, because the off-chain feed tracks continuous publisher submissions rather than a threshold-gated on-chain round. The API surface for off-chain consumption is Hermes, exposing REST and SSE endpoints for the latest price feeds and update payloads. Perp DEXes, options venues and prediction market settlement flows integrate Pyth on the volatile-asset path where a Chainlink-style trigger would introduce settlement lag. Teams stay on push oracles for read patterns that cannot afford the pull-refresh step or that need a fully synchronous read inside the settlement transaction without external submission. diff --git a/alternatives/quicknode.yml b/alternatives/quicknode.yml index a008bf40..b76a76ab 100644 --- a/alternatives/quicknode.yml +++ b/alternatives/quicknode.yml @@ -9,3 +9,6 @@ intro: | seo_title: "QuickNode alternatives: live benchmark" seo_description: Compare QuickNode alternatives on real-time data freshness. Live head-lag against a canonical archive node, refreshed every minute and published openly. + +positioning: | + QuickNode's moat is a Marketplace of specialized add-ons layered on a shared node fleet. For the head-lag axis the two products that matter are Streams and the Solana gRPC add-on (Yellowstone-compatible Geyser). Streams is a push pipeline: blocks, receipts and traces flow through a JavaScript or Go filter function and land in a webhook or storage destination, with exactly-once delivery in finality order. Streams supports 81 chains including Ethereum, Solana, Bitcoin, Arbitrum, Base, Polygon and Avalanche. The Solana gRPC add-on exposes a Geyser plugin stream directly, which is the same interface Solana validators emit internally; that path bypasses the JSON-RPC serialization tax and is why Yellowstone consumers land trades faster than REST or WS pollers on Solana. Swap-specific decoding is delegated to Marketplace add-ons (0x Swap API, Metis Jupiter, Pump Fun API, Aerodrome, Velodrome), each billed separately. QuickNode wins for teams that want raw block-level control plus a Solana Geyser feed on one bill; teams that want a single decoded cross-chain DEX event stream have to compose several Marketplace SKUs, which raises integration and latency budget. diff --git a/alternatives/relay.yml b/alternatives/relay.yml index 0eb9985d..1779acb0 100644 --- a/alternatives/relay.yml +++ b/alternatives/relay.yml @@ -9,3 +9,6 @@ intro: | seo_title: "Relay alternatives: live 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. + +positioning: | + Relay is a cross-chain payments network built on the Relay Protocol, an open system that connects users to a network of relayers who execute on-chain actions on their behalf, combined with DEX meta-aggregation across a documented set of 85 or more chains. Its architectural moat is the intent-plus-relayer model: the quote API is a thin price-discovery call against a pool of relayers who already hold inventory on the destination, so the request does not run a route-search graph across N underlying bridges. That thin surface is exactly what a bridge-quote-latency benchmark rewards, the API returns a single canonical price against pre-positioned liquidity rather than fanning out to third-party providers. The trade-off lives on the fill side, where a relayer must accept the intent, and on corridor coverage, which is bounded by which relayers hold inventory where. Relay is integrated as a swap layer for wallets, cross-chain gas-abstraction flows and app-chain onboarding where quote responsiveness dictates perceived UX. Teams migrate off when their corridor sits outside the relayer inventory footprint, or when they need multi-hop routing through long-tail bridges that a meta-aggregator with wider integration coverage handles natively. diff --git a/alternatives/tenderly.yml b/alternatives/tenderly.yml index 7c448ac7..6251bb40 100644 --- a/alternatives/tenderly.yml +++ b/alternatives/tenderly.yml @@ -11,3 +11,6 @@ seo_title: "Tenderly alternatives: live benchmark" seo_description: "Tenderly Gateway alternatives on eth_blockNumber p50 latency. PublicNode, dRPC, 1RPC, Nodies ranked live." status: live + +positioning: | + Tenderly built its RPC as an extension of a developer-tooling stack rather than as a standalone node fleet. The Node RPC product runs multi-region across EU and US, covers 80+ EVM networks, and exposes standard JSON-RPC and WebSocket. The architectural moat sits above the base RPC surface: the tenderly_ namespace ships simulateTransaction, simulateBundle, estimateGas, estimateGasBundle, traceTransaction, and decoding methods for inputs, errors, events, and ABI signatures. Node Extensions let a team write JavaScript or TypeScript that runs server-side under a custom JSON-RPC method name, so a project can compose a bespoke method (for example, simulate a mempool transaction) without deploying its own infrastructure. This is architecturally distinct from routing layers like dRPC and node fleets like PublicNode, neither of which owns a simulation engine or a scripting sidecar. For the rpc-capabilities axis, that translates into a coverage set biased toward pre-signing and post-mortem workflows: dry-running a transaction against forked state, tracing a decoded call graph, or profiling gas at bundle granularity. The typical use case is dApps whose UX depends on preview-before-sign or on gas quoting; teams migrate away when they need non-EVM chains or read-heavy public traffic that Node Extensions cannot cheaply serve. diff --git a/alternatives/the-graph.yml b/alternatives/the-graph.yml index e36e686b..bc815819 100644 --- a/alternatives/the-graph.yml +++ b/alternatives/the-graph.yml @@ -9,3 +9,6 @@ intro: | seo_title: "The Graph alternatives: live 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. + +positioning: | + The Graph's moat is a decentralized indexing network. Independent Indexers stake GRT and serve GraphQL queries against Subgraphs, which are custom indexing manifests deployed per contract or per protocol. Substreams sits underneath as a parallelized firehose for real-time and historical block data. The subgraph surface officially supports 60+ chains including Ethereum, Solana, Arbitrum, Optimism, Polygon and Base. The Token API, built by Pinax on top of Substreams, is a REST product covering 25 chains at last count (Arbitrum, Avalanche, Base, Bitcoin, Blast, BNB, Celo, Ethereum, Gnosis, HyperEVM, Linea, Litecoin, MegaETH, Optimism, Polygon, Robinhood Chain, Solana, Soneium, Unichain, Vaulta, WAX, X Layer, zkSync, Zora among others). For a chain-coverage benchmark on market data, The Graph is broad through the subgraph route but the coverage is contingent: each chain and each protocol needs a maintained subgraph that returns the requested market fields. Teams win when they own their data model; teams that want turnkey market data on every long-tail chain often reach for an aggregator instead. diff --git a/alternatives/zerion.yml b/alternatives/zerion.yml index 8db68e39..24b1f1a6 100644 --- a/alternatives/zerion.yml +++ b/alternatives/zerion.yml @@ -9,3 +9,6 @@ intro: | seo_title: "Zerion alternatives: live benchmark" seo_description: "Zerion alternatives on portfolio API chain coverage. Probe-verified chains vs self-declared catalogs, refreshed daily." + +positioning: | + Zerion started as a self-custody wallet frontend and the API is a direct product of that lineage. The developer docs organize the surface around Wallets (portfolio, positions, transactions, charts, PnL), Fungibles, NFTs, Chains, Gas, DApps, Subscriptions and Swaps/Bridges, exposing roughly 35 logical endpoints (more when subscription CRUD variants are counted separately). Chain coverage is described in the docs as EVM plus Solana plus Bitcoin, with a /v1/chains/ endpoint that returns per-chain capability flags for trading, sending and bridging. For a portfolio-chain-coverage benchmark, the load-bearing detail is that Zerion's product is wallet-shaped from the top down; positions are decoded across lending, staking and liquidity protocols and returned as normalized types, which means a chain is not supported in a meaningful sense until the DeFi decoding layer works there. That constraint tends to keep the coverage list to chains where protocol integrations are maintained. Teams pick Zerion when building a portfolio dashboard, wallet frontend or tax tool where positions must render human-readable across EVM, Solana and Bitcoin from a single call. Teams migrate when the requirement shifts to a long tail of niche EVM L2s that Zerion has not yet added protocol decoders for, or to raw-trace analytics workflows. diff --git a/answers/is-usdc-safer-than-usdt.yml b/answers/is-usdc-safer-than-usdt.yml index 37becd08..b3b0295f 100644 --- a/answers/is-usdc-safer-than-usdt.yml +++ b/answers/is-usdc-safer-than-usdt.yml @@ -36,3 +36,8 @@ related: seo_title: "Is USDC safer than USDT? Live peg data, not opinions" seo_description: "USDC vs USDT measured live: {{p99:usdc}} vs {{p99:usdt}} worst case peg deviation (p99, 24h) on USD quoted venues. What peg data can and cannot tell you about stablecoin safety." status: live + +expert_take: | + Peg tightness and reserve safety are two different questions, and readers regularly conflate them. Peg tightness is a live market signal produced by redemption arbitrage: when the market price drifts from the target, primary redeemers can close the gap in size, and the width of that gap over time measures how well that arbitrage loop is working. Reserve safety lives one layer down, in the banking partners holding the collateral, the attestation cadence and the legal jurisdiction. The two usually correlate; they can decouple within hours during a bank failure or a regulatory action, at which point the price feed reacts only after the news has already moved. Readers should read the p99 deviation and the time outside a 50 bps band as evidence of how the arbitrage loop performs under normal stress, and treat it as necessary but not sufficient for a treasury decision. A stablecoin that has held tight for a long stretch can still break; one that has broken once can still be the right pick if its reserve posture has changed since. Both dimensions belong in the answer. + +expert_take_reviewed: "2026-07-16" diff --git a/answers/what-is-the-fastest-free-ethereum-rpc.yml b/answers/what-is-the-fastest-free-ethereum-rpc.yml index 600abfa9..a944c2d7 100644 --- a/answers/what-is-the-fastest-free-ethereum-rpc.yml +++ b/answers/what-is-the-fastest-free-ethereum-rpc.yml @@ -36,3 +36,8 @@ related: seo_title: "What is the fastest free Ethereum RPC? Live no-key latency ranking" seo_description: "{{best_name}} leads free Ethereum RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 8 no-key endpoints probed every 60s from 3 regions, with success rates and archive depth audits." status: live + +expert_take: | + Free public RPCs share three architectural constraints that decide the ranking. First, they run on anycast edges, so the leader from one region regularly loses to another gateway from a different region; a global average hides that. Second, they answer from a shared node pool with no per-caller quota, which means a fast p50 during quiet hours can collapse under a memecoin mint. Third, and least discussed, a public gateway can return an HTTP 200 whose body is a JSON-RPC error, and any latency table that does not classify response bodies will silently reward a gateway that fails fast. Readers should look past the headline p50 at three things: the success rate column on the row they are picking, the p99 relative to the p50 (a small gap means the tail is real, a large gap means the provider is bursty), and whether archive depth is audited at all. A gateway that only serves the last day of state answers a different question than a free archive endpoint. + +expert_take_reviewed: "2026-07-16" diff --git a/answers/which-bridge-has-the-cheapest-usdc-fee.yml b/answers/which-bridge-has-the-cheapest-usdc-fee.yml index 89523132..c2af08d8 100644 --- a/answers/which-bridge-has-the-cheapest-usdc-fee.yml +++ b/answers/which-bridge-has-the-cheapest-usdc-fee.yml @@ -40,3 +40,8 @@ related: seo_title: "Which cross-chain bridge has the cheapest USDC fee in 2026?" seo_description: "OpenChainBench ranks six USDC routers (Mobula, Relay, LI.FI, deBridge, Across, Near Intents) on all in cost at $300 notional across Solana, Base and Arbitrum. Live 24h p50 leader on the bench page." status: live + +expert_take: | + Bridging cost lives inside three architectures with different fee shapes. Direct protocols charge a native protocol fee that is largely fixed, so it dominates the percentage at small ticket sizes and dilutes as notional grows. Aggregators route through underlying bridges they have integrated and add a routing markup on top; their best route is constrained to their integration set. Intent layers dispatch the transfer to a solver willing to settle it, so the price the user sees is a competitive spread rather than a sum of fees, and the leader on a corridor is whichever solver has the deepest inventory on that route at that moment. This is why a ranking swaps meaningfully by notional and by corridor; the same table looks different at $300, at $10000, and on a corridor where solver inventory is thin. Readers should read the all-in cost (fee plus slippage plus destination gas) rather than the rack rate any provider advertises, since aggregators and intent layers price implicitly through the spread and a rack-rate comparison rewards the wrong architecture. A common misconception is that aggregators are always cheaper; they are cheaper only on corridors their integration set dominates. + +expert_take_reviewed: "2026-07-16" diff --git a/answers/which-crypto-data-api-covers-the-most-blockchains.yml b/answers/which-crypto-data-api-covers-the-most-blockchains.yml index 051b9791..c9cfa616 100644 --- a/answers/which-crypto-data-api-covers-the-most-blockchains.yml +++ b/answers/which-crypto-data-api-covers-the-most-blockchains.yml @@ -42,3 +42,8 @@ related: seo_title: "Which crypto data API covers the most blockchains in 2026?" seo_description: "{{best_name}} leads at {{best_p50}} unique mainnets across GeckoTerminal, Codex, Mobula, CoinPaprika, CoinStats and Dune, audited every 6 hours by OpenChainBench." status: live + +expert_take: | + Chain coverage is a single-axis metric on a two-axis problem. Breadth (how many chain ids the provider lists) and depth (how completely each chain is indexed: pools, swaps, holders, metadata, historical range) are independently priced inside every data platform, and one can grow at the expense of the other. A provider that ships adapters for every new EVM chain within a week can look excellent on a network count while carrying only price ticks on the long tail; a provider with a shorter list may cover every listed chain end to end. Readers should also treat testnet inclusion with suspicion, since testnet registries are inconsistent across vendors and inflate the count without adding integratable surface. The right way to use this ranking is as one input alongside head lag on the chains that matter to the integration, and metadata completeness for the tokens the product will surface. A common misread is treating a coverage leader as automatically the right pick; the shortlist should be built from the intersection of breadth, depth and freshness. + +expert_take_reviewed: "2026-07-16" diff --git a/answers/which-crypto-oracle-is-the-most-accurate.yml b/answers/which-crypto-oracle-is-the-most-accurate.yml index e21d3560..7936ab11 100644 --- a/answers/which-crypto-oracle-is-the-most-accurate.yml +++ b/answers/which-crypto-oracle-is-the-most-accurate.yml @@ -40,3 +40,8 @@ related: seo_title: "Which crypto price oracle is the most accurate in 2026?" seo_description: "OpenChainBench measures cross oracle agreement (p99 max pairwise deviation in bps, 24h) between Chainlink, Pyth, Binance and Coinbase on 10 USD pairs. Live per pair numbers on the bench page." status: live + +expert_take: | + Oracle accuracy is a per-pair question dressed as a per-provider question. Continuous CEX tickers publish every tick and track the market by construction. Push oracles anchored on chain post a new round only when the off-chain aggregate has drifted past a deviation trigger or a heartbeat has elapsed, which is not a bug but a gas cost tradeoff; between updates the on-chain value legitimately lags. Pull oracles ship a signed off-chain quote that the caller inserts into their own transaction, moving the freshness question from the publisher to the integrator. Cross-oracle deviation is therefore not a scalar; it is a distribution across pairs, and the same oracle can lead on BTC where the trigger fires often and trail on a volatile alt where the same trigger produces multi-minute lag. Readers integrating an oracle into a settlement contract should read the per-pair p99 gap on the exact asset they liquidate, then read the round-age gauge next to it to attribute a large gap to the correct cause (lagging push oracle vs a CEX outlier). Ranking oracles as a single leaderboard obscures the mechanic that matters. + +expert_take_reviewed: "2026-07-16" diff --git a/answers/which-crypto-price-api-is-the-fastest.yml b/answers/which-crypto-price-api-is-the-fastest.yml index ff7b4e03..4f0680e4 100644 --- a/answers/which-crypto-price-api-is-the-fastest.yml +++ b/answers/which-crypto-price-api-is-the-fastest.yml @@ -40,3 +40,8 @@ related: seo_title: "Which crypto price API has the lowest head lag in 2026?" seo_description: "{{best_name}} leads crypto data head lag at {{best_p50}} (p50, 24h) across Mobula, Codex and GeckoTerminal on Base, BNB Chain and Solana. Measured live by OpenChainBench from 3 regions." status: live + +expert_take: | + Head lag is set by two floors and one pipeline. The first floor is the chain's own block cadence, which is a protocol constant; no data provider can publish a swap before the block it lives in is produced. The second floor is the transport contract with the provider. A persistent WebSocket pushes events as soon as the indexer has them, a GraphQL subscription behaves similarly, and a REST poll adds the client's poll interval to every read by construction. Comparing a REST feed to a WebSocket feed on raw p50 is comparing two architectures, not two pipelines. On top of those floors sits the provider's own indexer speed, which is what the benchmark actually isolates when the transport and the chain are held constant. Readers building a trading UI should weigh p99 as heavily as p50; a feed that is quick most of the time but stalls for 20 seconds once a minute makes a chart stutter in a way p50 alone does not surface. Success ratio catches feeds that disappear during volatility. + +expert_take_reviewed: "2026-07-16" diff --git a/answers/which-gas-oracle-is-the-most-accurate.yml b/answers/which-gas-oracle-is-the-most-accurate.yml index 3de2013e..018731e0 100644 --- a/answers/which-gas-oracle-is-the-most-accurate.yml +++ b/answers/which-gas-oracle-is-the-most-accurate.yml @@ -42,3 +42,8 @@ related: seo_title: "Which Ethereum gas oracle is the most accurate in 2026?" seo_description: "{{best_name}} leads at {{best_p50}} gwei (p99 absolute gap, 24h) across PublicNode feeHistory, Owlracle and Etherscan on Ethereum and Polygon, measured per block by OpenChainBench." status: live + +expert_take: | + Gas oracles split into two archetypes that answer different questions, and the leaderboard should be read with that split in mind. Percentile trackers read the protocol-native reward histogram and publish a number that hugs the realized priority fee by construction; the gap to reality is small most of the time, and their failure mode is under-predicting during a spike. Inclusion-confidence oracles were designed for a pre-EIP-1559 world and over-predict on purpose to guarantee the transaction lands; their gap to the realized number is wider, but users almost never miss a block. Comparing them on absolute error alone punishes the oracle that is doing its job correctly. Readers should read the p99 gap and the covered rate together: a low p99 with a low covered rate is a percentile tracker that will occasionally underbid, and a wider p99 with a high covered rate is an inclusion-confidence oracle that trades a small overpay for reliability. The right choice depends on the product; a wallet default that must never miss a block prefers coverage, a batching relayer that can re-broadcast prefers a tight percentile fit. A single-number leaderboard hides this. + +expert_take_reviewed: "2026-07-16" diff --git a/answers/which-perp-dex-has-the-lowest-fees.yml b/answers/which-perp-dex-has-the-lowest-fees.yml index 695b00b9..be12c1e7 100644 --- a/answers/which-perp-dex-has-the-lowest-fees.yml +++ b/answers/which-perp-dex-has-the-lowest-fees.yml @@ -42,3 +42,8 @@ related: seo_title: "Which perpetual DEX has the lowest all in fees on ETH in 2026?" seo_description: "OpenChainBench ranks eight perp DEXes (Lighter, Hyperliquid, dYdX, GMX, gains.trade, Paradex, Extended, Polymarket) on the same $1000 ETH 10x long all in cost. Live 24h leader on the bench page." status: live + +expert_take: | + Rack rate taker fee is the number every comparison table quotes, and it is also the number a trader stops being able to read the moment a market order goes in. The other half of the bill lives in the orderbook: the half spread and the price impact walked at the trader's notional. On a deep book, that component is small and the all-in cost tracks the rack rate closely. On a thin book, the same rack rate can clock several multiples worse once notional is walked through visible liquidity. A zero-fee taker on a shallow market is not free; the cost has moved from the fee schedule into the spread. Readers should also separate open cost from funding, since funding is a signed rate paid every hour the position is held and belongs on a different mental line item than the fee paid at open. A common misread is comparing one venue's rack rate to another venue's all-in cost; the fair comparison walks the same notional through both books and sums fees, spread and impact into one basis-point number. + +expert_take_reviewed: "2026-07-16" diff --git a/answers/which-prediction-market-data-api-is-the-freshest.yml b/answers/which-prediction-market-data-api-is-the-freshest.yml index 57af902b..b985e0d4 100644 --- a/answers/which-prediction-market-data-api-is-the-freshest.yml +++ b/answers/which-prediction-market-data-api-is-the-freshest.yml @@ -1,15 +1,15 @@ slug: which-prediction-market-data-api-is-the-freshest question: "Which prediction market data API publishes the freshest Polymarket data?" short_answer: | - {{best_name}} currently relays Polymarket trades the fastest at {{best_p50}} (p50, 24h) versus Polymarket's own CLOB WebSocket gateway, measured by cross correlating trade events on Polymarket, Mobula and Codex on the same rotating basket of top 20 markets by 24 hour volume. + {{best_name}} currently relays Polymarket trades the fastest at {{best_p50}} (p50, 24h) versus Polymarket's own CLOB WebSocket gateway, measured by cross correlating trade events on Polymarket and Codex on the same rotating basket of top 20 markets by 24 hour volume. benchmark: pm-data-freshness intro: | - Prediction markets produce the most time sensitive event stream in crypto. An election market settles in seconds, a sports book moves on every play, and the price tick a trading UI shows is only as fresh as the API behind it. Builders integrating Polymarket through a data provider rather than hitting the CLOB directly need to know how many milliseconds that provider adds between Polymarket publishing a trade and the provider relaying the same trade to its WebSocket subscribers. This page answers exactly that. The OpenChainBench pm-data-freshness harness holds three WebSocket subscribers in parallel: Polymarket's own CLOB gateway (the canonical T0), Mobula's PM WebSocket, and Codex GraphQL subscriptions, all subscribed to the same basket of top 20 Polymarket markets by 24 h volume, refreshed every 5 minutes. Each trade is matched across providers by a tuple of conditionId, price rounded to 3 decimals, trade size and a 5 second time bucket; the per provider lag versus Polymarket's gateway publish time is recorded as a Prometheus histogram and the leaderboard ranks by p50 in milliseconds, lower is fresher. + Prediction markets produce the most time sensitive event stream in crypto. An election market settles in seconds, a sports book moves on every play, and the price tick a trading UI shows is only as fresh as the API behind it. Builders integrating Polymarket through a data provider rather than hitting the CLOB directly need to know how many milliseconds that provider adds between Polymarket publishing a trade and the provider relaying the same trade to its WebSocket subscribers. This page answers exactly that. The OpenChainBench pm-data-freshness harness holds two WebSocket subscribers in parallel: Polymarket's own CLOB gateway (the canonical T0) and Codex GraphQL subscriptions, both subscribed to the same basket of top 20 Polymarket markets by 24 h volume, refreshed every 5 minutes. Each trade is matched across providers by a tuple of conditionId, price rounded to 3 decimals, trade size and a 5 second time bucket; the per provider lag versus Polymarket's gateway publish time is recorded as a Prometheus histogram and the leaderboard ranks by p50 in milliseconds, lower is fresher. methodology: | - Three WebSocket clients run in parallel inside the harness on a Railway europe west 4 instance. Polymarket's own gateway (`wss://ws-subscriptions-clob.polymarket.com/ws/market`, public, no auth, sub 50 ms publish latency from EU West) is the canonical T0 by construction because nothing downstream can be faster than the source. Mobula's PM WebSocket (`wss://pm-api-prod-eu.mobula.io`) requires an API key in the subscribe payload and a browser User Agent on the upgrade request (Cloudflare on the gateway silently filters the default Go HTTP UA). Codex GraphQL (`wss://graph.codex.io/graphql` with the `graphql-transport-ws` subprotocol) runs an `onPredictionTradesCreated` firehose subscription filtered client side to the Polymarket protocol marketIds in the active basket. The cross correlation key is `(conditionId, priceUSD times 1000 rounded, sizeUSD times 1 million rounded, floor(trade_time / 5s))`; the 5 second bucket absorbs minor clock skew between gateways without merging unrelated trades. Histogram buckets run 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000 ms. Providers that fail to relay a matched trade within 90 seconds are not counted toward p50, only toward their receive total, so a provider cannot look fresh on the leaderboard by silently dropping events; the success rate column surfaces that. + Two WebSocket clients run in parallel inside the harness on a Railway europe west 4 instance. Polymarket's own gateway (`wss://ws-subscriptions-clob.polymarket.com/ws/market`, public, no auth, sub 50 ms publish latency from EU West) is the canonical T0 by construction because nothing downstream can be faster than the source. Codex GraphQL (`wss://graph.codex.io/graphql` with the `graphql-transport-ws` subprotocol) runs an `onPredictionTradesCreated` firehose subscription filtered client side to the Polymarket protocol marketIds in the active basket. The cross correlation key is `(conditionId, priceUSD times 1000 rounded, sizeUSD times 1 million rounded, floor(trade_time / 5s))`; the 5 second bucket absorbs minor clock skew between gateways without merging unrelated trades. Histogram buckets run 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000 ms. Providers that fail to relay a matched trade within 90 seconds are not counted toward p50, only toward their receive total, so a provider cannot look fresh on the leaderboard by silently dropping events; the success rate column surfaces that. limitations: - "Polymarket's own gateway is the canonical T0 by construction. Its row on the leaderboard sits near zero because the harness compares everything else against it, not because Polymarket has solved network latency; the displayed sub millisecond value is a floor (vector(0.5)) so the row stays visible on the linear chart axis next to the multi second Codex value." @@ -20,11 +20,9 @@ limitations: faq: - q: "Which Polymarket data API has the lowest latency right now?" - a: "{{best_name}} currently leads at {{best_p50}} (p50 over the last 24 hours), measured as wall clock time from Polymarket's own CLOB WebSocket publish to provider relay on the same trade. The leaderboard re sorts every minute on fresh Prometheus samples, so the answer reflects the actual measured lag on the active market basket, not a marketing claim. Mobula's edge cached relay typically clocks tens of milliseconds; Codex's on chain Polygon indexer carries a structural floor near 2 seconds because it ingests block confirmations." + a: "{{best_name}} currently leads at {{best_p50}} (p50 over the last 24 hours), measured as wall clock time from Polymarket's own CLOB WebSocket publish to provider relay on the same trade. The leaderboard re sorts every minute on fresh Prometheus samples, so the answer reflects the actual measured lag on the active market basket, not a marketing claim. Codex's on chain Polygon indexer carries a structural floor near 2 seconds because it ingests block confirmations." - q: "What does freshness delta mean for a prediction market API?" a: "The bench connects to Polymarket's own CLOB WebSocket and to each provider's WebSocket simultaneously, subscribes to the same markets, and for every trade event records how many milliseconds the provider takes to relay the event after Polymarket itself publishes it. Lower is better. Polymarket's own gateway publish time is the canonical T0 because by construction nothing downstream can be faster than the source. The cross correlation key (conditionId, price, size, 5 s bucket) survives minor clock skew between gateways without merging unrelated trades." - - q: "Is Mobula's PM WebSocket faster than Codex?" - a: "Yes, by a structural margin, because the two providers do fundamentally different things under the hood. Mobula's PM WebSocket is an edge cached relay of Polymarket's own gateway, so its p50 delta is roughly the network round trip between the two gateways plus a few milliseconds of bookkeeping. Codex ingests the on chain Polygon confirmation of each trade, which adds Polygon block time (around 2 seconds) before any trade can be relayed. For a live UI, Mobula's path wins on freshness; for on chain reconciliation or settlement workflows, Codex's chain indexed path is what you actually want. The leaderboard surfaces both numbers honestly." - q: "Why not include Polymarket REST polling on this bench?" a: "Freshness is a WebSocket question. REST polling at 1 second cadence would have a floor freshness near 500 ms (poll interval divided by 2) plus round trip time, dominated by how often you poll. The Polymarket gateway WebSocket exists for exactly this reason, to avoid that floor. Adding REST as a row would make the leaderboard noisy without changing the conclusion: WebSocket beats polling by definition for real time data." - q: "Are these numbers comparable to Kalshi or Limitless?" @@ -40,5 +38,5 @@ related: - which-crypto-price-api-is-the-fastest seo_title: "Which prediction market data API is the freshest in 2026?" -seo_description: "{{best_name}} leads at {{best_p50}} (p50, 24h) versus Polymarket's CLOB gateway, with Mobula and Codex measured live by OpenChainBench on the top 20 Polymarket markets by volume." +seo_description: "{{best_name}} leads at {{best_p50}} (p50, 24h) versus Polymarket's CLOB gateway, with Codex measured live by OpenChainBench on the top 20 Polymarket markets by volume." status: live diff --git a/benchmarks/aggregator-head-lag.yml b/benchmarks/aggregator-head-lag.yml index e72b0c81..d9fade57 100644 --- a/benchmarks/aggregator-head-lag.yml +++ b/benchmarks/aggregator-head-lag.yml @@ -83,6 +83,14 @@ source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/aggrega prometheus: window: 24h + # Live-feed sanity gate. Sums event-arrival changes across every + # aggregator series in the last 15min. If this is 0, ALL our probes + # are silent (harness crash / Prom scrape failing / config drift) and + # the per-provider "Feed down" badges would be misleading, so the UI + # suppresses them. If > 0, at least one provider is actively receiving + # events from us, which means a provider showing 0 changes on its own + # is genuinely silent from Codex/Mobula/Gecko upstream. + probe_ok: sum(changes(head_lag_seconds[15m])) faq: - q: "Which crypto data API has the lowest latency right now?" @@ -153,17 +161,32 @@ providers: mean: avg_over_time(head_lag_seconds{aggregator="mobula"}[24h]) * 1000 success: clamp_max(count_over_time(head_lag_seconds{aggregator="mobula"}[24h]) / 5760, 1) sample_size: sum(count_over_time(head_lag_seconds{aggregator="mobula"}[24h])) - series: avg_over_time(head_lag_seconds{aggregator="mobula"}[1h]) * 1000 + # Hybrid `unless` guard: drop the sample when the gauge saw no + # fresh event in the last 15 min AND we have enough scrapes in + # that window to trust the changes() reading (>5 samples). The + # count_over_time gate skips false-drops on backfilled data + # that Prom compacted to ~1 sample per hour — on that vintage + # changes([15m]) reads 0 by construction, without the gate the + # 30D view lost its first ~8 days to a fake wall-to-wall gap. + # Modern data at 15s cadence carries ~60 samples per 15 min so + # the guard still fires the moment a WS goes silent. + series: avg_over_time(head_lag_seconds{aggregator="mobula"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="mobula"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="mobula"}[15m]) > 5) + # Total gauge-value changes on this aggregator across chains and + # regions in the last 15min. Non-zero = fresh events arrived; 0 = + # every subscription has been silent for 15+ minutes (the gauge is + # frozen at its last value). Threshold is > 0 rather than a rate + # so quiet chains at night don't false-flag the aggregate view. + live_activity: sum(changes(head_lag_seconds{aggregator="mobula"}[15m])) regions: - region: us-east p50: quantile_over_time(0.50, head_lag_seconds{aggregator="mobula", region="us-east"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="mobula", region="us-east"}[1h]) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="mobula", region="us-east"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="mobula", region="us-east"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="mobula", region="us-east"}[15m]) > 5) - region: eu-west p50: quantile_over_time(0.50, head_lag_seconds{aggregator="mobula", region="eu-west"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="mobula", region="eu-west"}[1h]) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="mobula", region="eu-west"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="mobula", region="eu-west"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="mobula", region="eu-west"}[15m]) > 5) - region: ap-southeast p50: quantile_over_time(0.50, head_lag_seconds{aggregator="mobula", region="sgp"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="mobula", region="sgp"}[1h]) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="mobula", region="sgp"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="mobula", region="sgp"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="mobula", region="sgp"}[15m]) > 5) - slug: codex name: Codex @@ -176,17 +199,18 @@ providers: mean: avg_over_time(head_lag_seconds{aggregator="codex"}[24h]) * 1000 success: clamp_max(count_over_time(head_lag_seconds{aggregator="codex"}[24h]) / 5760, 1) sample_size: sum(count_over_time(head_lag_seconds{aggregator="codex"}[24h])) - series: avg_over_time(head_lag_seconds{aggregator="codex"}[1h]) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="codex"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="codex"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="codex"}[15m]) > 5) + live_activity: sum(changes(head_lag_seconds{aggregator="codex"}[15m])) regions: - region: us-east p50: quantile_over_time(0.50, head_lag_seconds{aggregator="codex", region="us-east"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="codex", region="us-east"}[1h]) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="codex", region="us-east"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="codex", region="us-east"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="codex", region="us-east"}[15m]) > 5) - region: eu-west p50: quantile_over_time(0.50, head_lag_seconds{aggregator="codex", region="eu-west"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="codex", region="eu-west"}[1h]) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="codex", region="eu-west"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="codex", region="eu-west"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="codex", region="eu-west"}[15m]) > 5) - region: ap-southeast p50: quantile_over_time(0.50, head_lag_seconds{aggregator="codex", region="sgp"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="codex", region="sgp"}[1h]) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="codex", region="sgp"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="codex", region="sgp"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="codex", region="sgp"}[15m]) > 5) - slug: geckoterminal name: GeckoTerminal @@ -199,15 +223,16 @@ providers: mean: avg_over_time(head_lag_seconds{aggregator="geckoterminal"}[24h]) * 1000 success: clamp_max(count_over_time(head_lag_seconds{aggregator="geckoterminal"}[24h]) / 5760, 1) sample_size: sum(count_over_time(head_lag_seconds{aggregator="geckoterminal"}[24h])) - series: avg_over_time(head_lag_seconds{aggregator="geckoterminal"}[1h]) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="geckoterminal"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="geckoterminal"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="geckoterminal"}[15m]) > 5) + live_activity: sum(changes(head_lag_seconds{aggregator="geckoterminal"}[15m])) regions: - region: us-east p50: quantile_over_time(0.50, head_lag_seconds{aggregator="geckoterminal", region="us-east"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="geckoterminal", region="us-east"}[1h]) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="geckoterminal", region="us-east"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="geckoterminal", region="us-east"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="geckoterminal", region="us-east"}[15m]) > 5) - region: eu-west p50: quantile_over_time(0.50, head_lag_seconds{aggregator="geckoterminal", region="eu-west"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="geckoterminal", region="eu-west"}[1h]) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="geckoterminal", region="eu-west"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="geckoterminal", region="eu-west"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="geckoterminal", region="eu-west"}[15m]) > 5) - region: ap-southeast p50: quantile_over_time(0.50, head_lag_seconds{aggregator="geckoterminal", region="sgp"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="geckoterminal", region="sgp"}[1h]) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="geckoterminal", region="sgp"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="geckoterminal", region="sgp"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="geckoterminal", region="sgp"}[15m]) > 5) diff --git a/benchmarks/evm-block-builders.yml b/benchmarks/evm-block-builders.yml new file mode 100644 index 00000000..26651f6b --- /dev/null +++ b/benchmarks/evm-block-builders.yml @@ -0,0 +1,232 @@ +# OpenChainBench. Bench № 085 + +slug: evm-block-builders +number: "085" +title: Ethereum block builder market share +seo_title: "Ethereum block builders benchmark 2026, live builder market share" +seo_description: "Live benchmark of who builds Ethereum blocks. Builder market share attributed via block extraData, cross-checked against MEV-Boost relay bidtraces." +subtitle: Share of Ethereum mainnet blocks attributed to each block builder via the extraData self-label, cross-checked against relay bidtraces. Observational, no transactions sent. +category: Trading +status: live +metric: Block share (24h) +unit: pct +higher_is_better: true + +disclaimer: | + Attribution relies on the extraData tag each builder voluntarily stamps into its blocks. A builder can change or strip its tag at any time; unrecognized tags land in the Other row and every raw string is logged so the table grows. High share is a centralization datapoint, not a quality ranking. + +seo_intro: | + Who actually builds Ethereum? Since MEV-Boost, validators outsource + block construction to a handful of specialized builders, and the + builders sign their work: each stamps a self-label into the block's + extraData field ("Titan (titanbuilder.xyz)", "beaverbuild.org", and + so on). This benchmark decodes the extraData of every mainnet block, + attributes it to a curated table of known builder tags, and publishes + the live market share. The result is the concentration story in one + chart: 95 percent plus of Ethereum blocks are built by a handful of + private builders, and the top two regularly build most of the chain. + Blocks carrying an execution client's default tag (geth, reth, + nethermind, besu version strings) are counted as vanilla, meaning the + proposer built locally instead of outsourcing; that row is the live + measure of how much of Ethereum still self-builds. As an independent + cross-check the harness also polls the public bidtrace data APIs of + seven MEV-Boost relays (Flashbots, ultrasound, Agnostic, bloXroute + max-profit and regulated, Titan, Aestus) and counts delivered + payloads per relay, a signal that does not depend on any builder's + self-chosen label. + +abstract: | + We attribute every Ethereum mainnet block to its builder by decoding + the block's extraData field, the 32 bytes a builder uses to sign its + work in human-readable ASCII. The harness polls the head of the chain + through a keyless public RPC once per 12 second slot, backfills any + blocks missed between polls by number, decodes extraData to printable + ASCII and matches it against a curated substring table of known + builder tags (Titan, Quasar, Eureka, BuilderNet, beaverbuild, rsync, + Flashbots, Builder+ and more). Client-default tags (geth, reth, + nethermind, besu, erigon version strings) and empty extraData count + as vanilla, locally built blocks. Anything unrecognized counts as + Other, increments an unattributed counter, and logs the raw string so + the table can grow, the same philosophy as the tip-wallet table in + bench 016. The headline metric is each builder's percentage of all + blocks observed in the last 24 hours. Two companion measurements ship + in the same harness. First, an independent cross-check: the public + bidtrace APIs of seven MEV-Boost relays are polled every five minutes + and delivered payloads are counted per relay, deduped per relay by + slot high-water-mark; the same slot appearing on several relays is + normal because builders multi-home their bids. Second, an L2 + soft-confirmation study: the harness holds the Arbitrum sequencer + feed and the Base flashblocks stream open and measures how far those + preconfirmation channels run ahead of the public RPC head, emitted as + histograms for a future bench page. At inception a 50 block live + sample attributed 98 percent cleanly: Titan 19, Quasar 16, Eureka 6, + BuilderNet 2, beaverbuild 1, Builder+ 1, one vanilla block. + +methodology: + - "Source: keyless public Ethereum RPC (ethereum-rpc.publicnode.com by default, env-overridable). eth_getBlockByNumber(latest) every 12s, matching the mainnet slot time. Gaps between polls are backfilled by block number (capped at 10) so the counters see every block, not just the ones that happen to be head at poll time." + - "Attribution: extraData hex is decoded to printable ASCII and matched, lowercase, against a curated substring table (titan, quasar, eureka, buildernet, beaverbuild, rsync, builder0x69, flashbots, bloxroute, builder+/btcs, penguinbuild and more). First match wins; most specific substrings are ordered first." + - "Vanilla detection: empty extraData or execution-client default tags (geth, reth, nethermind, besu, erigon, go1.x, platform strings) count as builder=vanilla, meaning the proposer built the block locally instead of outsourcing to a builder. This row is the live self-built share of Ethereum." + - "Unattributed handling: any non-empty tag not in the table counts as builder=other, increments ebb_unattributed_total, and logs the raw extraData string. The table grows from operator logs instead of silently under-counting, the bench 016 pattern." + - "Self-labeling caveat: extraData is voluntary. A builder can rebrand, blank its tag, or impersonate another. The relay cross-check below is the independent signal; a large divergence between extraData share and relay-side delivered payloads would surface tag games." + - "Relay cross-check: seven MEV-Boost relay data APIs (Flashbots, ultrasound, Agnostic, bloXroute max-profit, bloXroute regulated, Titan, Aestus) are polled keyless every 5 min via GET /relay/v1/data/bidtraces/proposer_payload_delivered?limit=50. Delivered payloads are counted per relay (ebb_relay_payloads_total), deduped per relay by slot high-water-mark. The same slot on multiple relays is normal: builders multi-home bids, so relay counters measure relay share, not a partition of blocks." + - "Single vantage: one harness in one region observes the chain through one public RPC. Attribution is not latency-sensitive (extraData is consensus data, identical from every vantage), so single-vantage only affects liveness, which the health gauges expose." + - "Companion L2 metrics: the same harness measures Arbitrum sequencer-feed soft-confirmation lag (ebb_arb_softconf_lag_milliseconds, feed arrival to public RPC visibility, offset derived at runtime) and Base flashblocks cadence plus flashblock-to-RPC lag (ebb_base_flashblock_interval_milliseconds, ebb_base_softconf_lag_milliseconds). These are per-chain, not per-builder, so this spec surfaces builder share only; the L2 lag series feed a future bench page." + +findings: + - "{{best_name}} currently builds {{best_p50}} of Ethereum blocks observed over the last 24h, across {{count}} attribution rows. The top two builders regularly account for the majority of the chain, the concentration this bench exists to make visible." + - "{{name:beaverbuild}} sits at {{p50:beaverbuild}}. beaverbuild dominated 2023-2024 alongside rsync; its slide against Titan and the newer Quasar and Eureka operations is the clearest sign the builder market still churns even as it concentrates." + - "{{name:buildernet}} carries {{p50:buildernet}}. BuilderNet is the Flashbots-initiated attempt to mutualize block building into a multi-operator network precisely because of the concentration this page measures; its share is the decentralization counter-signal to watch." + - "{{name:vanilla}} blocks, built locally by the proposer without a builder, sit at {{p50:vanilla}}. This is the live measure of how much of Ethereum still self-builds rather than outsourcing to the MEV supply chain." + - "{{worst_name}} trails at {{worst_p50}}. The long tail matters less than the head here: the bench's story is that a validator set of a million keys funnels block construction through a single-digit number of firms." + +faq: + - q: "How is a block attributed to a builder?" + a: "Builders stamp a self-label into the 32-byte extraData field of every block they construct, for example 'Titan (titanbuilder.xyz)' or 'beaverbuild.org'. The harness decodes extraData to ASCII and matches it against a curated substring table. Empty or client-default extraData (geth, reth, nethermind version strings) means the proposer built the block locally and counts as vanilla. Unrecognized tags count as Other and are logged raw so the table grows. At inception, 98 percent of a live 50-block sample attributed cleanly." + - q: "Can a builder game this by changing its extraData?" + a: "Yes, extraData is voluntary self-labeling, and that is disclosed as the bench's main caveat. A builder could blank its tag (blocks would fall into Other, and the unattributed counter plus raw-string logging would surface the shift within hours) or imitate another builder's tag (detectable by divergence against relay bidtraces, which report the builder's signing pubkey rather than its label). In practice builders keep their tags stable because the label is marketing: searchers pick builders partly on public share stats." + - q: "Why do the p50, p90 and p99 columns show the same number?" + a: "This bench has no latency dimension, so the percentile slots are repurposed, same convention as bench 016. Every aggregate column carries the builder's 24h block share; the sample column carries the raw block count and the success column reports the harness's own RPC poll health, not anything about the builder. The ranking signal is the share itself." + - q: "What is the relay cross-check and why does it not sum to 100 percent?" + a: "Seven MEV-Boost relays expose a public data API listing every payload they delivered to a proposer. The harness counts delivered payloads per relay every five minutes. Because builders submit the same bid through several relays and every relay that carried the winning payload reports it, one block can legitimately appear on multiple relays. Relay counts therefore measure relay market share and act as an attribution sanity check independent of extraData; they are not a partition of blocks and do not sum to the block count." + - q: "Why does builder concentration matter?" + a: "Under MEV-Boost, validators outsource block construction, so whoever builds most blocks decides transaction ordering and inclusion for most of Ethereum. Concentration in two or three private firms creates censorship surface (a dominant builder filtering transactions affects most blocks), a single point of failure for liveness of the fee market, and information asymmetry for anyone trading on-chain. Projects like BuilderNet exist specifically to mutualize this layer; this bench provides the neutral number for whether concentration is getting better or worse." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/evm-block-builders + +prometheus: + window: 24h + expected_freshness_seconds: 120 + freshness_metric: ebb_eth_last_block + +# Real metrics emitted by the evm-block-builders harness: +# ebb_blocks_total{builder} counter (attributed blocks) +# ebb_unattributed_total counter +# ebb_eth_poll_health gauge (0|1) +# ebb_eth_last_block gauge (freshness) +# ebb_relay_payloads_total{relay} counter (cross-check) +# ebb_arb_softconf_lag_milliseconds histogram (future bench) +# ebb_base_flashblock_interval_milliseconds histogram (future bench) +# ebb_base_softconf_lag_milliseconds histogram (future bench) +# +# Each "provider" is one builder attribution row. The headline is the +# builder's share of all blocks observed in 24h (pct). p50/p90/p99/mean +# carry the same share scalar (016 convention); sample_size carries the +# raw block count; success carries harness poll health. + +providers: + - slug: titan + name: Titan + tag: titanbuilder.xyz, dominant private builder + formula: "Blocks whose extraData contains the Titan tag, as a percentage of all Ethereum blocks observed in the last 24h." + queries: + p50: 100 * sum(increase(ebb_blocks_total{builder="titan"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p90: 100 * sum(increase(ebb_blocks_total{builder="titan"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p99: 100 * sum(increase(ebb_blocks_total{builder="titan"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + mean: 100 * sum(increase(ebb_blocks_total{builder="titan"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + success: avg_over_time(ebb_eth_poll_health[24h]) + sample_size: sum(increase(ebb_blocks_total{builder="titan"}[24h])) + series: 100 * sum(rate(ebb_blocks_total{builder="titan"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h]))) + + - slug: quasar + name: Quasar + tag: Fast-rising private builder + formula: "Blocks whose extraData contains the Quasar tag, as a percentage of all Ethereum blocks observed in the last 24h." + queries: + p50: 100 * sum(increase(ebb_blocks_total{builder="quasar"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p90: 100 * sum(increase(ebb_blocks_total{builder="quasar"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p99: 100 * sum(increase(ebb_blocks_total{builder="quasar"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + mean: 100 * sum(increase(ebb_blocks_total{builder="quasar"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + success: avg_over_time(ebb_eth_poll_health[24h]) + sample_size: sum(increase(ebb_blocks_total{builder="quasar"}[24h])) + series: 100 * sum(rate(ebb_blocks_total{builder="quasar"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h]))) + + - slug: eureka + name: Eureka + tag: Private builder, newer entrant + formula: "Blocks whose extraData contains the Eureka tag, as a percentage of all Ethereum blocks observed in the last 24h." + queries: + p50: 100 * sum(increase(ebb_blocks_total{builder="eureka"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p90: 100 * sum(increase(ebb_blocks_total{builder="eureka"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p99: 100 * sum(increase(ebb_blocks_total{builder="eureka"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + mean: 100 * sum(increase(ebb_blocks_total{builder="eureka"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + success: avg_over_time(ebb_eth_poll_health[24h]) + sample_size: sum(increase(ebb_blocks_total{builder="eureka"}[24h])) + series: 100 * sum(rate(ebb_blocks_total{builder="eureka"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h]))) + + - slug: buildernet + name: BuilderNet + tag: Flashbots-initiated multi-operator building network + formula: "Blocks whose extraData contains the BuilderNet tag, as a percentage of all Ethereum blocks observed in the last 24h." + queries: + p50: 100 * sum(increase(ebb_blocks_total{builder="buildernet"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p90: 100 * sum(increase(ebb_blocks_total{builder="buildernet"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p99: 100 * sum(increase(ebb_blocks_total{builder="buildernet"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + mean: 100 * sum(increase(ebb_blocks_total{builder="buildernet"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + success: avg_over_time(ebb_eth_poll_health[24h]) + sample_size: sum(increase(ebb_blocks_total{builder="buildernet"}[24h])) + series: 100 * sum(rate(ebb_blocks_total{builder="buildernet"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h]))) + + - slug: beaverbuild + name: beaverbuild + tag: beaverbuild.org, former market leader + formula: "Blocks whose extraData contains the beaverbuild tag, as a percentage of all Ethereum blocks observed in the last 24h." + queries: + p50: 100 * sum(increase(ebb_blocks_total{builder="beaverbuild"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p90: 100 * sum(increase(ebb_blocks_total{builder="beaverbuild"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p99: 100 * sum(increase(ebb_blocks_total{builder="beaverbuild"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + mean: 100 * sum(increase(ebb_blocks_total{builder="beaverbuild"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + success: avg_over_time(ebb_eth_poll_health[24h]) + sample_size: sum(increase(ebb_blocks_total{builder="beaverbuild"}[24h])) + series: 100 * sum(rate(ebb_blocks_total{builder="beaverbuild"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h]))) + + - slug: bobthebuilder + name: Bob The Builder + tag: bobthebuilder.xyz, pseudonymous builder + formula: "Blocks whose extraData contains the bobthebuilder tag, as a percentage of all Ethereum blocks observed in the last 24h." + queries: + p50: 100 * sum(increase(ebb_blocks_total{builder="bobthebuilder"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p90: 100 * sum(increase(ebb_blocks_total{builder="bobthebuilder"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p99: 100 * sum(increase(ebb_blocks_total{builder="bobthebuilder"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + mean: 100 * sum(increase(ebb_blocks_total{builder="bobthebuilder"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + success: avg_over_time(ebb_eth_poll_health[24h]) + sample_size: sum(increase(ebb_blocks_total{builder="bobthebuilder"}[24h])) + series: 100 * sum(rate(ebb_blocks_total{builder="bobthebuilder"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h]))) + + - slug: btcs + name: Builder+ + tag: BTCS Builder+ + formula: "Blocks whose extraData contains the Builder+ or BTCS tag, as a percentage of all Ethereum blocks observed in the last 24h." + queries: + p50: 100 * sum(increase(ebb_blocks_total{builder="btcs"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p90: 100 * sum(increase(ebb_blocks_total{builder="btcs"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p99: 100 * sum(increase(ebb_blocks_total{builder="btcs"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + mean: 100 * sum(increase(ebb_blocks_total{builder="btcs"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + success: avg_over_time(ebb_eth_poll_health[24h]) + sample_size: sum(increase(ebb_blocks_total{builder="btcs"}[24h])) + series: 100 * sum(rate(ebb_blocks_total{builder="btcs"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h]))) + + - slug: vanilla + name: Vanilla + tag: Locally built by the proposer, no builder + formula: "Blocks with empty or execution-client default extraData (geth, reth, nethermind, besu, erigon), meaning the proposer built locally, as a percentage of all blocks observed in 24h." + queries: + p50: 100 * sum(increase(ebb_blocks_total{builder="vanilla"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p90: 100 * sum(increase(ebb_blocks_total{builder="vanilla"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p99: 100 * sum(increase(ebb_blocks_total{builder="vanilla"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + mean: 100 * sum(increase(ebb_blocks_total{builder="vanilla"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + success: avg_over_time(ebb_eth_poll_health[24h]) + sample_size: sum(increase(ebb_blocks_total{builder="vanilla"}[24h])) + series: 100 * sum(rate(ebb_blocks_total{builder="vanilla"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h]))) + + - slug: other + name: Other + tag: Unrecognized extraData tags, logged raw for table growth + formula: "Blocks whose extraData carries a non-empty tag not in the curated table, as a percentage of all blocks observed in 24h. Every raw string is logged so the attribution table grows." + queries: + p50: 100 * sum(increase(ebb_blocks_total{builder="other"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p90: 100 * sum(increase(ebb_blocks_total{builder="other"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + p99: 100 * sum(increase(ebb_blocks_total{builder="other"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + mean: 100 * sum(increase(ebb_blocks_total{builder="other"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h]))) + success: avg_over_time(ebb_eth_poll_health[24h]) + sample_size: sum(increase(ebb_blocks_total{builder="other"}[24h])) + series: 100 * sum(rate(ebb_blocks_total{builder="other"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h]))) diff --git a/benchmarks/hyperliquid-rpc.yml b/benchmarks/hyperliquid-rpc.yml new file mode 100644 index 00000000..11874309 --- /dev/null +++ b/benchmarks/hyperliquid-rpc.yml @@ -0,0 +1,201 @@ +# OpenChainBench. Bench № 092 + +slug: hyperliquid-rpc +number: "092" +title: Fastest free Hyperliquid RPC, live no-key HyperEVM endpoint latency +seo_title: "Fastest free Hyperliquid RPC 2026" +seo_description: "{{best_name}} leads free Hyperliquid RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 5 no-key HyperEVM providers measured every 60s from 3 regions." +subtitle: HTTP round-trip latency for `eth_getBlockByNumber("latest", false)` against every free, no-key public HyperEVM endpoint, audited every 60 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Hyperliquid's HyperEVM (chain id 999) is the EVM execution layer bolted + onto the HyperCore perps engine. Standard Ethereum JSON-RPC wire, so + the probe is `eth_getBlockByNumber("latest", false)` , the same call + the Ethereum, Base, Arbitrum and 20+ other chain benches use. Provider + cohort at launch: Hyperliquid Labs's official `rpc.hyperliquid.xyz/evm`, + dRPC, Stakely, Purroof Group and Hypurrscan. Alchemy demo path + (rate-limited dead), Grove/Pocket public LB (needs an app id at the + public LB path), thirdweb (returns "Invalid chain" on HyperEVM) and + the AllThatNode / Blast API guesses (DNS not resolving to a public + HyperEVM endpoint) were audited alongside and excluded. Each will be + revisited when a stable no-key path reappears. + +abstract: | + Per-chain member of the RPC latency cluster, extended to HyperEVM. + We measure the round-trip latency of a single, identical JSON-RPC + call (`eth_getBlockByNumber("latest", false)`) against every no-key + public HyperEVM endpoint that sustains continuous probing, 5 providers + at launch, every 60 seconds, from us-east, eu-west and Singapore. The + harness classifies every response (ok / http_err / jsonrpc_err / + stale / timeout) with the standard EVM staleness gap (block gap = + 25 blocks ≈ 50 seconds at HyperEVM's ~2 s block time). The cross-chain + view lives on the parent `rpc-capabilities` benchmark; this page is + the Hyperliquid-scoped answer with per-region breakdowns as a + first-class dimension. + +methodology: + - "Cadence: every 60 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\", false]}`. Plain HTTP POST, identical for every endpoint, no API key in any request. Non-cacheable by design: the rotating id defeats edge caches that would answer a bare head query without touching a node." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result with a parsable hex block number), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body , this is how the audit caught thirdweb's `Invalid chain` failure), `stale` (more than 25 blocks behind the cross-provider tip, roughly 50 seconds at HyperEVM's ~2 s block time), `timeout`. Latency without reliability is a misleading ranking signal." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to `chain=\"hyperliquid\"`. Provider coverage at launch: 5 no-key endpoints (Hyperliquid Labs official, dRPC, Stakely, Purroof Group, Hypurrscan)." + - "Excluded by the launch audit: Alchemy demo path (rate-limited dead), thirdweb (`Invalid chain` on HyperEVM), Grove/Pocket public LB (requires an app id at the LB path), AllThatNode + Blast API + Chainstack + Gelato + Imperator HyperEVM paths (DNS-fail or 401 without a key), PublicNode (no HyperEVM subdomain yet). HyperEVM is a young chain (mainnet Feb 2025) so this list will shift quickly." + +findings: + - "{{best_name}} currently leads free Hyperliquid RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h) across 5 measured providers." + - "{{name:hyperliquid-official}} is operated by Hyperliquid Labs itself; the bench measures the same public endpoint that HyperCore front-ends and Hyperliquid dashboards route through by default, so the number here reflects what a wallet or dApp integrator sees end-to-end." + - "{{name:drpc}} routes HyperEVM through its multi-provider load balancer with edge-close routing; latency here is a good proxy for what an integrator using dRPC's paid tier can expect after removing the free-tier caching noise." + - "{{name:hypurrscan}} runs a HyperEVM-native explorer alongside its RPC gateway; the reliability column matters as much as p50 since community-run infra can churn during network upgrades or heavy trading periods." + +faq: + - q: "What is the fastest free Hyperliquid RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (eth_getBlockByNumber p50 over the last 24h), measured against 5 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which HyperEVM RPCs work without an API key?" + a: "Five endpoints sustain continuous keyless probing at launch: Hyperliquid Labs's `rpc.hyperliquid.xyz/evm`, dRPC (`hyperliquid.drpc.org`), Stakely (`hyperliquid-json-rpc.stakely.io`), Purroof Group (`rpc.purroofgroup.com`) and Hypurrscan (`rpc.hypurrscan.io`). Every listed endpoint was live-verified with an `eth_getBlockByNumber` POST returning a parsable hex head before inclusion. Excluded by the audit: Alchemy's demo key path is rate-limited dead, thirdweb returns `Invalid chain` on HyperEVM, Grove/Pocket public LB requires an app id at the LB path, and AllThatNode / Blast API / Chainstack / Gelato / Imperator HyperEVM paths either DNS-fail or 401 without a key. HyperEVM is a young chain so this list will shift as providers stand up dedicated endpoints." + - q: "Does the fastest Hyperliquid RPC change by region?" + a: "Often. HyperEVM provider infra is heavily weighted toward Europe and the US; a gateway that wins from Amsterdam can lose from Singapore by multiples, especially for community-run providers with a single data centre. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is Hyperliquid RPC latency measured here, technically?" + a: "One identical JSON-RPC POST (`eth_getBlockByNumber(\"latest\", false)`) every 60 seconds against each provider from each of 3 regions, using the same plain HTTP client the rest of the EVM cluster uses. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest." + - q: "Why is the probe `eth_getBlockByNumber` and not `eth_blockNumber`?" + a: "Two reasons. First, `eth_getBlockByNumber(\"latest\", false)` forces the node to actually assemble the block header (timestamp, hash, gas metrics) which a polite CDN cannot synthesise from a stale integer , `eth_blockNumber` on its own can be answered from an edge cache without touching a node, defeating the point of a latency probe. Second, the returned header lets the harness populate the `latest_block_hash` and `timestamp` fields the cross-provider tip machinery uses to detect stale endpoints. Both together mean the number on this page is what your app actually pays when it fetches a fresh head." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="hyperliquid"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: hyperliquid-official + name: Hyperliquid Labs + tag: Hyperliquid Labs's official public HyperEVM RPC + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Hyperliquid Labs's official public HyperEVM RPC." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="hyperliquid-official", chain="hyperliquid"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="hyperliquid-official", chain="hyperliquid"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="hyperliquid-official", chain="hyperliquid"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="hyperliquid-official", chain="hyperliquid"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="hyperliquid-official", chain="hyperliquid"}) / sum(ocb:rpc_call:rate_24h{provider="hyperliquid-official", chain="hyperliquid"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="hyperliquid-official", chain="hyperliquid"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="hyperliquid-official", chain="hyperliquid"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="hyperliquid-official", chain="hyperliquid", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="hyperliquid-official", chain="hyperliquid", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="hyperliquid-official", chain="hyperliquid", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="hyperliquid-official", chain="hyperliquid", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="hyperliquid-official", chain="hyperliquid", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="hyperliquid-official", chain="hyperliquid", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Multi-provider RPC load balancer, edge-close routing + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key HyperEVM endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="hyperliquid"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="hyperliquid"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="hyperliquid"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="hyperliquid"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="hyperliquid"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="hyperliquid"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="hyperliquid"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="hyperliquid"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="hyperliquid", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="hyperliquid", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="hyperliquid", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="hyperliquid", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="hyperliquid", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="hyperliquid", region="sgp"}[1h]) + + - slug: stakely + name: Stakely + tag: EU-based staking + infra provider, keyless HyperEVM path + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Stakely's no-key HyperEVM endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="stakely", chain="hyperliquid"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="stakely", chain="hyperliquid"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="stakely", chain="hyperliquid"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="stakely", chain="hyperliquid"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="stakely", chain="hyperliquid"}) / sum(ocb:rpc_call:rate_24h{provider="stakely", chain="hyperliquid"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="stakely", chain="hyperliquid"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="stakely", chain="hyperliquid"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="stakely", chain="hyperliquid", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="stakely", chain="hyperliquid", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="stakely", chain="hyperliquid", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="stakely", chain="hyperliquid", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="stakely", chain="hyperliquid", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="stakely", chain="hyperliquid", region="sgp"}[1h]) + + - slug: purroofgroup + name: Purroof Group + tag: Community Hyperliquid validator + public RPC + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Purroof Group's no-key HyperEVM endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="purroofgroup", chain="hyperliquid"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="purroofgroup", chain="hyperliquid"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="purroofgroup", chain="hyperliquid"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="purroofgroup", chain="hyperliquid"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="purroofgroup", chain="hyperliquid"}) / sum(ocb:rpc_call:rate_24h{provider="purroofgroup", chain="hyperliquid"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="purroofgroup", chain="hyperliquid"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="purroofgroup", chain="hyperliquid"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="purroofgroup", chain="hyperliquid", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="purroofgroup", chain="hyperliquid", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="purroofgroup", chain="hyperliquid", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="purroofgroup", chain="hyperliquid", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="purroofgroup", chain="hyperliquid", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="purroofgroup", chain="hyperliquid", region="sgp"}[1h]) + + - slug: hypurrscan + name: Hypurrscan + tag: HyperEVM explorer + no-key public RPC gateway + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Hypurrscan's no-key HyperEVM endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="hypurrscan", chain="hyperliquid"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="hypurrscan", chain="hyperliquid"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="hypurrscan", chain="hyperliquid"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="hypurrscan", chain="hyperliquid"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="hypurrscan", chain="hyperliquid"}) / sum(ocb:rpc_call:rate_24h{provider="hypurrscan", chain="hyperliquid"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="hypurrscan", chain="hyperliquid"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="hypurrscan", chain="hyperliquid"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="hypurrscan", chain="hyperliquid", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="hypurrscan", chain="hyperliquid", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="hypurrscan", chain="hyperliquid", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="hypurrscan", chain="hyperliquid", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="hypurrscan", chain="hyperliquid", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="hypurrscan", chain="hyperliquid", region="sgp"}[1h]) diff --git a/benchmarks/l2-block-time.yml b/benchmarks/l2-block-time.yml index 17199113..e65d3f81 100644 --- a/benchmarks/l2-block-time.yml +++ b/benchmarks/l2-block-time.yml @@ -2,12 +2,16 @@ slug: l2-block-time number: "009" -title: Fastest L2 block time, live across Arbitrum, Base, Optimism and 6 more +title: Fastest L2 block time, live across Robinhood, Arbitrum, Base and 7 more seo_title: "Fastest L2 block time 2026" -seo_description: "Fastest L2 sequencer block time live: Arbitrum, Blast, Optimism, Base, Linea and more ranked by newHeads interval." +seo_description: "Fastest L2 sequencer block time live: Robinhood Chain, Arbitrum, Optimism, Base, Linea and more ranked by real block interval." subtitle: Wall-clock interval in milliseconds between two consecutive newHeads events on each L2 sequencer, refreshed continuously. per_chain_explainer: + - slug: robinhood + h2: "Robinhood Chain block time" + body: | + Robinhood Chain block time is {{p50:robinhood}} (p50, 24h), the most aggressive cadence in the cohort: the Arbitrum Orbit stack lets Robinhood's sequencer target ~100 ms blocks for tokenized-stock settlement. The chain launched mainnet on July 1, 2026, pays gas in ETH and settles to Ethereum blobs. Measurement note: Robinhood exposes no public WebSocket, so block time here is derived from block-number deltas over 2-second HTTP poll windows, an accurate average that does not capture per-block jitter. - slug: arbitrum h2: "Arbitrum One block time" body: | @@ -66,6 +70,7 @@ abstract: | outlier sample is capped at 5 min so a reconnect can't pollute p50. methodology: + - "Robinhood Chain exception: the sequencer exposes no public WebSocket, so its block time is derived from eth_blockNumber deltas polled every 2 seconds over HTTP. A window that advanced N blocks contributes N samples of (elapsed / N) ms, which preserves per-block weighting and yields an accurate average; per-block jitter is not captured on this chain and this note is the disclosure." - "Cadence: continuous WebSocket subscription per L2, one persistent connection, no polling." - "Subscribe payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_subscribe\",\"params\":[\"newHeads\"]}`. No API key, public endpoints (publicnode.com mostly, era.zksync.io/ws for zkSync)." - "Sample: block-time(N) = T_recv(N) − T_recv(N-1), measured in milliseconds with `time.Now()` precision on the harness clock. Block-number duplicates from the same connection are filtered." @@ -101,6 +106,18 @@ faq: a: "Polygon zkEVM and Mode don't expose a public WebSocket endpoint without an API key, so we defer them to v2 when an API-keyed contributor endpoint lands. Starknet uses the Cairo stack with a different RPC shape, it would live in a dedicated bench rather than be shoehorned into the EVM cohort here. Berachain, Sonic and other adjacent chains are L1s, not L2s, so they live in the l1-finality bench." providers: + - slug: robinhood + name: Robinhood Chain + tag: Arbitrum Orbit, ~100ms blocks, HTTP-polled (no public WSS) + formula: "Median milliseconds per block on Robinhood Chain, derived from eth_blockNumber deltas over 2s HTTP poll windows (no public WebSocket), p50 over 24h." + queries: + p50: histogram_quantile(0.50, sum(rate(l2_block_time_milliseconds_histogram_bucket{chain="robinhood"}[24h])) by (le)) + p90: histogram_quantile(0.90, sum(rate(l2_block_time_milliseconds_histogram_bucket{chain="robinhood"}[24h])) by (le)) + p99: histogram_quantile(0.99, sum(rate(l2_block_time_milliseconds_histogram_bucket{chain="robinhood"}[24h])) by (le)) + mean: sum(rate(l2_block_time_milliseconds_histogram_sum{chain="robinhood"}[24h])) / sum(rate(l2_block_time_milliseconds_histogram_count{chain="robinhood"}[24h])) + success: avg_over_time(l2_block_time_health{chain="robinhood"}[24h]) + sample_size: sum(increase(l2_block_time_samples_total{chain="robinhood"}[24h])) + series: avg_over_time(l2_block_time_milliseconds{chain="robinhood"}[1h]) - slug: arbitrum name: Arbitrum One tag: Nitro fast-finality, sub-second sequencer diff --git a/benchmarks/network-fees.yml b/benchmarks/network-fees.yml index 36e65e3a..cedc60ba 100644 --- a/benchmarks/network-fees.yml +++ b/benchmarks/network-fees.yml @@ -2,10 +2,10 @@ slug: network-fees number: "031" -title: Cheapest blockchain transaction fee, live across 14 L1 and L2 chains +title: Cheapest blockchain transaction fee, live across 15 L1 and L2 chains seo_title: "Cheapest crypto transaction fee 2026" -seo_description: "Cheapest blockchain transaction fee in USD live across 14 chains. Solana, Base, Arbitrum, BNB Chain ranked." -subtitle: "Live USD cost of one native token transaction on 14 Layer 1 and Layer 2 chains, refreshed every 30 seconds." +seo_description: "Cheapest blockchain transaction fee in USD live across 15 chains. Solana, Base, Robinhood Chain, Arbitrum, BNB ranked." +subtitle: "Live USD cost of one native token transaction on 15 Layer 1 and Layer 2 chains, refreshed every 30 seconds." seo_intro: | This page answers one question. How much does it cost in dollars to send one transaction on each major blockchain right now. We track 14 chains in parallel and refresh the number every 30 seconds. The eight Layer 1 chains are Ethereum, Solana, BNB Chain, TRON, Cardano, Sui, Litecoin and Monero. The six Layer 2 rollups are Arbitrum, Base, zkSync Era, Linea, Mantle and Taiko. For every chain we query its own fee market directly (eth_feeHistory for the EVM family, getRecentPrioritizationFees on Solana, koios epoch params on Cardano, get_fee_estimate on Monero, the mempool oracle on Litecoin, getChainParameters on TRON, suix_getReferenceGasPrice on Sui), convert the result to the smallest native unit, then multiply by the live USD price of the chain's native token from Mobula. The output is the actual dollar amount a wallet user pays today. No gwei to lamport conversion, no marketing claim. Compare Ethereum gas now versus Solana fee in USD, see whether Arbitrum is still cheaper than Base today, find out which Layer 1 has the lowest transaction cost this minute. @@ -71,6 +71,10 @@ per_chain_explainer: h2: "Arbitrum transaction fee" body: | Arbitrum One native transfer fee is {{p50:arbitrum}} (p50, 24h). Arbitrum Nitro runs an EIP-1559 fee market against the sequencer at sub-second cadence; native ETH transfers are 21000 gas and sit at fractions of a cent because the sequencer's priority fee is near-zero by design. The L1 data-posting component (Ethereum blobs since Dencun) is not yet included in this figure, only L2 execution cost. Computed via `eth_feeHistory` on the Arbitrum sequencer RPC times live ETH price. + - slug: robinhood + h2: "Robinhood Chain transaction fee" + body: | + Robinhood Chain native transfer fee is {{p50:robinhood}} (p50, 24h). Robinhood's Arbitrum Orbit rollup posts a very low base fee (~0.05 gwei) and zero priority rewards, the typical Orbit pattern where the centralized sequencer needs no tip market; ETH is the gas asset and the L1 blob-posting cost is excluded from this figure like every other L2 on this page. Computed via `eth_feeHistory` on the official sequencer RPC times live ETH price. - slug: base h2: "Base transaction fee" body: | @@ -159,6 +163,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="ethereum",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="ethereum",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="ethereum"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="ethereum",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="ethereum",tier="std"} - slug: bnb @@ -172,6 +177,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="bnb",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="bnb",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="bnb"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="bnb",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="bnb",tier="std"} - slug: solana @@ -185,6 +191,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="solana",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="solana",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="solana"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="solana",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="solana",tier="std"} - slug: tron @@ -198,6 +205,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h]) success: avg_over_time(tx_fee_health{chain="tron"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="tron",tier="single"}[7d]) series: tx_fee_native_transfer_usd{chain="tron",tier="single"} - slug: cardano @@ -211,6 +219,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h]) success: avg_over_time(tx_fee_health{chain="cardano"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="cardano",tier="single"}[7d]) series: tx_fee_native_transfer_usd{chain="cardano",tier="single"} - slug: sui @@ -224,6 +233,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="sui"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="sui",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="sui",tier="std"} - slug: litecoin @@ -237,6 +247,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="litecoin",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="litecoin",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="litecoin"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="litecoin",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="litecoin",tier="std"} - slug: monero @@ -250,6 +261,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="monero",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="monero",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="monero"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="monero",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="monero",tier="std"} - slug: arbitrum @@ -263,7 +275,21 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="arbitrum",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="arbitrum",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="arbitrum"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="arbitrum",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="arbitrum",tier="std"} + - slug: robinhood + name: Robinhood Chain + layer: l2 + tag: "Arbitrum Orbit rollup Robinhood, 21000 gas" + formula: "Median USD cost of a native ETH transfer on Robinhood Chain over 24h. L2 execution cost only, L1 data posting fee excluded for now." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="robinhood",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="robinhood",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="robinhood",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="robinhood",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="robinhood"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="robinhood",tier="std"}[7d]) + series: tx_fee_native_transfer_usd{chain="robinhood",tier="std"} - slug: base name: Base layer: l2 @@ -275,6 +301,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="base",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="base",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="base"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="base",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="base",tier="std"} - slug: zksync name: zkSync Era @@ -287,6 +314,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="zksync",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="zksync",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="zksync"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="zksync",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="zksync",tier="std"} - slug: linea name: Linea @@ -299,6 +327,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="linea",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="linea",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="linea"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="linea",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="linea",tier="std"} - slug: mantle name: Mantle @@ -311,6 +340,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="mantle",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="mantle",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="mantle"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="mantle",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="mantle",tier="std"} - slug: taiko name: Taiko @@ -323,4 +353,5 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="taiko",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="taiko",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="taiko"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="taiko",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="taiko",tier="std"} diff --git a/benchmarks/oracle-deviation.yml b/benchmarks/oracle-deviation.yml index 79e38f0c..6f3ed8dd 100644 --- a/benchmarks/oracle-deviation.yml +++ b/benchmarks/oracle-deviation.yml @@ -135,7 +135,7 @@ providers: p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="BTC/USD"}[24h]) * 100 mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="BTC/USD"}[24h]) * 100 success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="BTC/USD"}[24h])) / (4 * (1/30)), 1), 0) - sample_size: count(count by (source) (ocb_oracle_price{pair="BTC/USD"})) + sample_size: sum(count_over_time(ocb_oracle_price{pair="BTC/USD"}[24h])) series: ocb_oracle_max_deviation_pct{pair="BTC/USD"} * 100 - slug: eth-usd @@ -148,7 +148,7 @@ providers: p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="ETH/USD"}[24h]) * 100 mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="ETH/USD"}[24h]) * 100 success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="ETH/USD"}[24h])) / (4 * (1/30)), 1), 0) - sample_size: count(count by (source) (ocb_oracle_price{pair="ETH/USD"})) + sample_size: sum(count_over_time(ocb_oracle_price{pair="ETH/USD"}[24h])) series: ocb_oracle_max_deviation_pct{pair="ETH/USD"} * 100 - slug: sol-usd @@ -161,7 +161,7 @@ providers: p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="SOL/USD"}[24h]) * 100 mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="SOL/USD"}[24h]) * 100 success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="SOL/USD"}[24h])) / (4 * (1/30)), 1), 0) - sample_size: count(count by (source) (ocb_oracle_price{pair="SOL/USD"})) + sample_size: sum(count_over_time(ocb_oracle_price{pair="SOL/USD"}[24h])) series: ocb_oracle_max_deviation_pct{pair="SOL/USD"} * 100 - slug: bnb-usd @@ -174,7 +174,7 @@ providers: p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="BNB/USD"}[24h]) * 100 mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="BNB/USD"}[24h]) * 100 success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="BNB/USD"}[24h])) / (4 * (1/30)), 1), 0) - sample_size: count(count by (source) (ocb_oracle_price{pair="BNB/USD"})) + sample_size: sum(count_over_time(ocb_oracle_price{pair="BNB/USD"}[24h])) series: ocb_oracle_max_deviation_pct{pair="BNB/USD"} * 100 - slug: avax-usd @@ -187,7 +187,7 @@ providers: p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="AVAX/USD"}[24h]) * 100 mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="AVAX/USD"}[24h]) * 100 success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="AVAX/USD"}[24h])) / (4 * (1/30)), 1), 0) - sample_size: count(count by (source) (ocb_oracle_price{pair="AVAX/USD"})) + sample_size: sum(count_over_time(ocb_oracle_price{pair="AVAX/USD"}[24h])) series: ocb_oracle_max_deviation_pct{pair="AVAX/USD"} * 100 - slug: link-usd @@ -200,7 +200,7 @@ providers: p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="LINK/USD"}[24h]) * 100 mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="LINK/USD"}[24h]) * 100 success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="LINK/USD"}[24h])) / (4 * (1/30)), 1), 0) - sample_size: count(count by (source) (ocb_oracle_price{pair="LINK/USD"})) + sample_size: sum(count_over_time(ocb_oracle_price{pair="LINK/USD"}[24h])) series: ocb_oracle_max_deviation_pct{pair="LINK/USD"} * 100 - slug: matic-usd @@ -213,7 +213,7 @@ providers: p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="MATIC/USD"}[24h]) * 100 mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="MATIC/USD"}[24h]) * 100 success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="MATIC/USD"}[24h])) / (4 * (1/30)), 1), 0) - sample_size: count(count by (source) (ocb_oracle_price{pair="MATIC/USD"})) + sample_size: sum(count_over_time(ocb_oracle_price{pair="MATIC/USD"}[24h])) series: ocb_oracle_max_deviation_pct{pair="MATIC/USD"} * 100 - slug: xrp-usd @@ -226,7 +226,7 @@ providers: p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="XRP/USD"}[24h]) * 100 mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="XRP/USD"}[24h]) * 100 success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="XRP/USD"}[24h])) / (3 * (1/30)), 1), 0) - sample_size: count(count by (source) (ocb_oracle_price{pair="XRP/USD"})) + sample_size: sum(count_over_time(ocb_oracle_price{pair="XRP/USD"}[24h])) series: ocb_oracle_max_deviation_pct{pair="XRP/USD"} * 100 - slug: ada-usd @@ -239,7 +239,7 @@ providers: p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="ADA/USD"}[24h]) * 100 mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="ADA/USD"}[24h]) * 100 success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="ADA/USD"}[24h])) / (3 * (1/30)), 1), 0) - sample_size: count(count by (source) (ocb_oracle_price{pair="ADA/USD"})) + sample_size: sum(count_over_time(ocb_oracle_price{pair="ADA/USD"}[24h])) series: ocb_oracle_max_deviation_pct{pair="ADA/USD"} * 100 - slug: doge-usd @@ -252,5 +252,5 @@ providers: p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="DOGE/USD"}[24h]) * 100 mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="DOGE/USD"}[24h]) * 100 success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="DOGE/USD"}[24h])) / (3 * (1/30)), 1), 0) - sample_size: count(count by (source) (ocb_oracle_price{pair="DOGE/USD"})) + sample_size: sum(count_over_time(ocb_oracle_price{pair="DOGE/USD"}[24h])) series: ocb_oracle_max_deviation_pct{pair="DOGE/USD"} * 100 diff --git a/benchmarks/osmosis-rpc.yml b/benchmarks/osmosis-rpc.yml new file mode 100644 index 00000000..aee8de7c --- /dev/null +++ b/benchmarks/osmosis-rpc.yml @@ -0,0 +1,212 @@ +# OpenChainBench. Bench № 091 + +slug: osmosis-rpc +number: "091" +title: Fastest free Osmosis RPC, live no-key endpoint latency +seo_title: "Fastest free Osmosis RPC 2026" +seo_description: "{{best_name}} leads free Osmosis RPC at {{best_p50}} (Tendermint status p50, 24h). 5 no-key providers measured every 60s from 3 regions." +subtitle: HTTP round-trip latency for the Tendermint `status` method against every free, no-key public Osmosis Cosmos SDK endpoint, audited every 60 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Osmosis is the first Cosmos SDK chain in the RPC latency cluster. + CometBFT (Tendermint) consensus, JSON-RPC 2.0 wire but a chain-family + specific method set: the probe is Tendermint `status` (returns + `sync_info.latest_block_height` as a decimal string plus + `latest_block_hash`) rather than `eth_getBlockByNumber`. Everything + else on this page is the same bench pattern used on Ethereum, Base, + Solana, Polkadot and the 20+ other chains: identical POST every 60 + seconds from three regions, latency measured client-side at + millisecond precision, per-region breakdowns first class. Provider + cohort at launch: Osmosis Foundation's `rpc.osmosis.zone`, Polkachu, + PublicNode (Allnodes), Imperator and LavenderFive. Lava, OnFinality, + Numia, AutoStake, BlockApsis, WhisperNode, Enigma-Validator, + StakeTown and reece.sh were audited alongside and excluded (Lava + 403 on the public osmosis endpoint, OnFinality osmosis public + gateway timing out at probe cadence, Numia 401 without a key, the + others returned connect errors or moved paths during the launch + sweep); each will be revisited when a stable no-key path reappears. + +abstract: | + Per-chain member of the RPC latency cluster, extended for Cosmos SDK. + We measure the round-trip latency of a single, identical JSON-RPC + call (Tendermint `status`) against every no-key public Osmosis + Cosmos SDK endpoint that sustains continuous probing, 5 providers at + launch, every 60 seconds, from us-east, eu-west and Singapore. The + harness classifies every response (ok / http_err / jsonrpc_err / + stale / timeout) with a Cosmos-scaled staleness gap (40 blocks ≈ 4 + minutes at Osmosis's ~6 s block time), so the leaderboard rewards + sustained, honest availability rather than a fast error message. + The cross-chain view lives on the parent `rpc-capabilities` + benchmark; this page is the Osmosis-scoped answer with per-region + breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 60 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"status\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request. Non-cacheable by design: the rotating id defeats edge caches that would answer a bare status query without touching a node." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result with a parsable decimal `sync_info.latest_block_height`), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 40 blocks behind the cross-provider tip, roughly 4 minutes at Osmosis's ~6 s block time), `timeout`. Latency without reliability is a misleading ranking signal." + - "No archive-depth loop on this chain. Cosmos SDK chains access historical state via ABCI queries keyed on module + KV-store, which does not map onto the flat `eth_getBalance(address, height)` probe cleanly." + - "No cross-provider hash quorum on this chain at v1. Tendermint `status` does return `latest_block_hash` on the same block the height is reported for, so a quorum check is technically feasible, but the initial Osmosis add-on ships without it to keep the reliability change surface small; the hash normalisation across Cosmos chains will be validated before wiring bench 083 here." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to `chain=\"osmosis\"`. Provider coverage at launch: 5 no-key endpoints (Osmosis Foundation, Polkachu, PublicNode, Imperator, LavenderFive)." + - "Excluded by the launch audit: Lava (osmosis.tendermintrpc.lava.build 403 without a key despite public branding), OnFinality (osmosis public gateway timing out at probe cadence), Numia (401), AutoStake (404), BlockApsis / WhisperNode / Enigma-Validator / StakeTown / reece.sh (connect errors, likely IPv6-only or geo-gated), Ankr + Chainstack (paid Cosmos tiers only)." + +findings: + - "{{best_name}} currently leads free Osmosis RPC at {{best_p50}} (Tendermint `status` p50, 24h) across 5 measured providers." + - "{{name:osmosis-official}} is operated by the Osmosis Foundation itself; the bench measures the same public endpoint that Keplr and Osmosis Frontier route through by default, so the number here reflects what a wallet or dApp integrator sees end-to-end." + - "{{name:polkachu}} runs one of the largest Cosmos validator + public RPC networks; latency here is directly comparable to the multi-chain gateways because we probe the same `status` method under the same 60 s cadence rule." + - "{{name:publicnode}} extends its universal multi-chain footprint from Ethereum + Polkadot onto Cosmos without a keyed path; the reliability column is what to read alongside p50 here since Cosmos JSON-RPC has different failure modes than EVM (peer set churn during epoch boundaries, gRPC gateway restarts)." + +faq: + - q: "What is the fastest free Osmosis RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (Tendermint `status` p50 over the last 24h), measured against 5 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which Osmosis RPCs work without an API key?" + a: "Five endpoints sustain continuous keyless probing at launch: Osmosis Foundation's `rpc.osmosis.zone`, Polkachu (`osmosis-rpc.polkachu.com`), PublicNode / Allnodes (`osmosis-rpc.publicnode.com`), Imperator (`rpc-osmosis.imperator.co`) and LavenderFive (`rpc.lavenderfive.com/osmosis`). Every listed endpoint was live-verified with a `status` POST returning a parsable decimal `sync_info.latest_block_height` before inclusion. Excluded by the audit: Lava's `osmosis.tendermintrpc.lava.build` returned 403 without a key, OnFinality's public osmosis gateway kept timing out at probe cadence, Numia rejected without an API key (401), Ankr and Chainstack require paid Cosmos tiers, and several community-run endpoints (BlockApsis, WhisperNode, Enigma-Validator, StakeTown, reece.sh) returned connect errors during the sweep." + - q: "Does the fastest Osmosis RPC change by region?" + a: "Often. Cosmos public infra is heavily weighted toward validator ops in specific regions; a gateway that wins from Amsterdam can lose from Singapore by multiples, especially for community-run providers with a single data centre. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is Osmosis RPC latency measured here, technically?" + a: "One identical Tendermint JSON-RPC POST (`status`) every 60 seconds against each provider from each of 3 regions, using the same plain HTTP client the EVM and Substrate clusters use. The response is a `result` object containing a `sync_info` block; the harness reads `sync_info.latest_block_height` (a decimal string on Cosmos SDK, unlike EVM / Substrate which hex-encode) and parses it with `strconv.ParseUint`. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest." + - q: "Why is the probe `status` and not `abci_info` or `block`?" + a: "Two reasons. First, Tendermint `status` returns `sync_info.latest_block_height` on every Cosmos SDK chain in a shape the cluster's cross-provider tip machinery understands without a chain-specific code path. Second, `abci_info` returns application-level metadata that a polite CDN can synthesise without touching the underlying node, defeating the point of a latency probe on the RPC path; `block` requires a height parameter and would either bias toward providers with a fast archive path or duplicate the head-block work `status` already does. `status` forces the endpoint to actually read the head from the consensus engine, which is what integrators care about." + - q: "Why is there no archive-depth or hash-quorum column for Osmosis?" + a: "Cosmos SDK chains access historical state via ABCI queries keyed on module + KV-store, which has no chain-agnostic depth analog for the EVM `eth_getBalance(address, height)` probe the cluster uses on other chains. Hash quorum (bench 083 cross-provider height-hash agreement) is technically feasible on Cosmos because `latest_block_hash` in the `status` response corresponds to the same block the height is reported for, but the initial Osmosis add-on ships without it to keep the reliability change surface small; the hash normalisation across Cosmos chains will be validated before wiring the quorum path here. Both columns are omitted honestly rather than filled with a proxy that would misrepresent the chain." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="osmosis"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: osmosis-official + name: Osmosis Foundation + tag: Osmosis Foundation's official public Cosmos SDK RPC + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single Tendermint `status` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Osmosis Foundation's official public RPC (`rpc.osmosis.zone`)." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="osmosis-official", chain="osmosis"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="osmosis-official", chain="osmosis"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="osmosis-official", chain="osmosis"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="osmosis-official", chain="osmosis"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="osmosis-official", chain="osmosis"}) / sum(ocb:rpc_call:rate_24h{provider="osmosis-official", chain="osmosis"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="osmosis-official", chain="osmosis"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="osmosis-official", chain="osmosis"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="osmosis-official", chain="osmosis", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="osmosis-official", chain="osmosis", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="osmosis-official", chain="osmosis", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="osmosis-official", chain="osmosis", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="osmosis-official", chain="osmosis", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="osmosis-official", chain="osmosis", region="sgp"}[1h]) + + - slug: polkachu + name: Polkachu + tag: Community Cosmos validator + public RPC network + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single Tendermint `status` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Polkachu's no-key Osmosis RPC endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="polkachu", chain="osmosis"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="polkachu", chain="osmosis"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="polkachu", chain="osmosis"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="polkachu", chain="osmosis"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="polkachu", chain="osmosis"}) / sum(ocb:rpc_call:rate_24h{provider="polkachu", chain="osmosis"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="polkachu", chain="osmosis"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="polkachu", chain="osmosis"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="polkachu", chain="osmosis", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="polkachu", chain="osmosis", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="polkachu", chain="osmosis", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="polkachu", chain="osmosis", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="polkachu", chain="osmosis", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="polkachu", chain="osmosis", region="sgp"}[1h]) + + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, 70+ chains, includes Cosmos + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single Tendermint `status` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Osmosis RPC endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="osmosis"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="osmosis"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="osmosis"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="osmosis"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="osmosis"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="osmosis"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="osmosis"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="osmosis"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="osmosis", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="osmosis", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="osmosis", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="osmosis", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="osmosis", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="osmosis", region="sgp"}[1h]) + + - slug: imperator + name: Imperator + tag: Cosmos-native infra provider, IBC + Osmosis ecosystem focus + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single Tendermint `status` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Imperator's no-key Osmosis RPC endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="imperator", chain="osmosis"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="imperator", chain="osmosis"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="imperator", chain="osmosis"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="imperator", chain="osmosis"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="imperator", chain="osmosis"}) / sum(ocb:rpc_call:rate_24h{provider="imperator", chain="osmosis"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="imperator", chain="osmosis"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="imperator", chain="osmosis"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="imperator", chain="osmosis", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="imperator", chain="osmosis", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="imperator", chain="osmosis", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="imperator", chain="osmosis", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="imperator", chain="osmosis", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="imperator", chain="osmosis", region="sgp"}[1h]) + + - slug: lavenderfive + name: LavenderFive + tag: Cosmos validator + no-key RPC path, multi-chain + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single Tendermint `status` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to LavenderFive's no-key Osmosis RPC endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lavenderfive", chain="osmosis"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="lavenderfive", chain="osmosis"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="lavenderfive", chain="osmosis"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="lavenderfive", chain="osmosis"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="lavenderfive", chain="osmosis"}) / sum(ocb:rpc_call:rate_24h{provider="lavenderfive", chain="osmosis"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="lavenderfive", chain="osmosis"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="lavenderfive", chain="osmosis"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lavenderfive", chain="osmosis", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="lavenderfive", chain="osmosis", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lavenderfive", chain="osmosis", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="lavenderfive", chain="osmosis", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lavenderfive", chain="osmosis", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="lavenderfive", chain="osmosis", region="sgp"}[1h]) diff --git a/benchmarks/perp-execution-quality.yml b/benchmarks/perp-execution-quality.yml index 3809f50b..9f28c266 100644 --- a/benchmarks/perp-execution-quality.yml +++ b/benchmarks/perp-execution-quality.yml @@ -4,8 +4,8 @@ slug: perp-execution-quality number: "054" title: Best DEX for a $BOT market order, live slippage ranked seo_title: "Best DEX to trade $BOT, live orderbook slippage" -seo_description: "{{best_name}} leads $BOT slippage at {{best_p50}} for a $10,000 buy (24h avg). Lighter vs Hyperliquid HIP-3 (xyz:BOT), measured live from public orderbooks every 30 seconds." -subtitle: Effective slippage in basis points for a $10,000 market buy of $BOT perps, walked live on each DEX orderbook every 30 seconds. Lower means the venue fills the size closer to mid. Lighter native and Hyperliquid HIP-3 (xyz:BOT) are the two DEX venues currently listing $BOT. +seo_description: "{{best_name}} leads $BOT slippage at {{best_p50}} for a $5,000 buy (24h avg). Lighter vs Hyperliquid HIP-3 (xyz:BOT), measured live from public orderbooks every 30 seconds." +subtitle: Effective slippage in basis points for a $5,000 market buy of $BOT perps, walked live on each DEX orderbook every 30 seconds. Lower means the venue fills the size closer to mid. Lighter native and Hyperliquid HIP-3 (xyz:BOT) are the two DEX venues currently listing $BOT. category: Trading status: live metric: Effective slippage @@ -23,7 +23,7 @@ seo_intro: | of USD notionals (from $100 up to $500,000), and publishes the effective slippage in basis points as a live gauge per venue, per side and per size. The headline number is the 24 hour average at the - $10,000 buy bucket, which is the size a mid-sized $BOT trader is most + $5,000 buy bucket, chosen to match the reliable book depth of the thinner $BOT venue (Hyperliquid HIP-3 xyz:BOT fills $5k in 100% of ticks and $10k in only ~23%) likely to execute at market. CEX venues are not part of this bench; DefiLlama covers the CEX side of the audit. @@ -49,14 +49,14 @@ methodology: - "Slippage bps: (avg_execution_price - mid) / mid x 10000 on the buy side, mirrored on the sell side, sign-normalized so every published value represents cost to the trader." - "Buckets published: $100, $1,000, $5,000, $10,000, $25,000, $50,000, $100,000, $500,000. Both sides (buy, sell)." - "Depth fallback: if the visible book does not cover 99% of the requested size, the slippage series for that (venue, side, size) is dropped for that tick and `perp_execution_max_fillable_usd{side}` reports the ceiling instead." - - "Headline. avg_over_time of `perp_execution_slippage_bps{asset=\"BOT\", side=\"buy\", size_usd=\"10000\"}` over 24h, per venue. A one-print spike does not move the ranking." + - "Headline. avg_over_time of `perp_execution_slippage_bps{asset=\"BOT\", side=\"buy\", size_usd=\"5000\"}` over 24h, per venue. A one-print spike does not move the ranking." - "Failures. A venue that errors or times out drops its `perp_execution_health` to 0 and increments `perp_execution_fetch_errors_total{error_type}`; the leaderboard tags it as stale." - "CEX venues are intentionally out of scope. Traders comparing against a Binance or Bybit fill should read DefiLlama for the CEX-side audit; the DEX bench does not aggregate what it cannot orderbook-walk from a public endpoint." findings: - - "{{best_name}} currently leads at {{best_p50}} of slippage on a $10,000 $BOT buy (24 h average) between the {{count}} DEX venues listing $BOT." - - "{{name:lighter}} sits at {{p50:lighter}} on the same $10,000 bucket. Fully onchain orderbook on zk-rollup, zero taker fee, so the number is essentially the crossed half-spread plus depth impact." - - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} on the same $10,000 bucket. HIP-3 markets settle through the same HyperBFT orderbook engine as native Hyperliquid perps but liquidity is provided by the deployer-affiliated market maker, so depth can be thinner than on a native flagship pair." + - "{{best_name}} currently leads at {{best_p50}} of slippage on a $5,000 $BOT buy (24 h average) between the {{count}} DEX venues listing $BOT." + - "{{name:lighter}} sits at {{p50:lighter}} on the same $5,000 bucket. Fully onchain orderbook on zk-rollup, zero taker fee, so the number is essentially the crossed half-spread plus depth impact." + - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} on the same $5,000 bucket. HIP-3 markets settle through the same HyperBFT orderbook engine as native Hyperliquid perps but liquidity is provided by the deployer-affiliated market maker, so depth can be thinner than on a native flagship pair." - "Both venues publish additional size buckets ($100 up to $500,000, both sides). The full grid is exposed via the raw `perp_execution_slippage_bps` series with the `size_usd` and `side` labels." - "$BOT is not currently listed on any other decentralized perp venue with a public orderbook endpoint. New listings will show up here automatically once their `(asset, venue)` pair is added to the harness config." @@ -68,7 +68,7 @@ prometheus: faq: - q: "Which DEX has the best execution for a $BOT market order right now?" - a: "{{best_name}} currently leads at {{best_p50}} of slippage on a $10,000 buy (24 h average). The leaderboard re-sorts every 30 seconds against fresh Prometheus samples so the answer reflects the last day of live orderbook walks on Lighter and Hyperliquid HIP-3 (`xyz:BOT`), not a marketing snapshot." + a: "{{best_name}} currently leads at {{best_p50}} of slippage on a $5,000 buy (24 h average). The leaderboard re-sorts every 30 seconds against fresh Prometheus samples so the answer reflects the last day of live orderbook walks on Lighter and Hyperliquid HIP-3 (`xyz:BOT`), not a marketing snapshot." - q: "Why is $BOT only tracked on two venues?" a: "Because those are the only two decentralized perp venues currently listing $BOT with a public orderbook endpoint: Lighter (market_id 185) and Hyperliquid HIP-3 under the `xyz:BOT` coin symbol. Native Hyperliquid does not have $BOT listed, so `coin=\"BOT\"` on `/info` returns nothing; the HIP-3 permissionless listing is the only path for $BOT on Hyperliquid infrastructure today." - q: "What is HIP-3 and how is `xyz:BOT` different from a native Hyperliquid perp?" @@ -86,15 +86,15 @@ providers: - slug: lighter name: Lighter tag: zk-rollup perp DEX, fully onchain orderbook - formula: "Average over 24h of the effective slippage in bps for a $10,000 buy on Lighter's native $BOT orderbook, walked live every 30 seconds from `/api/v1/orderBookOrders?market_id=185`." + formula: "Average over 24h of the effective slippage in bps for a $5,000 buy on Lighter's native $BOT orderbook, walked live every 30 seconds from `/api/v1/orderBookOrders?market_id=185`." queries: - p50: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="10000"}[24h]) - p90: quantile_over_time(0.90, perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="10000"}[24h]) - p99: quantile_over_time(0.99, perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="10000"}[24h]) - mean: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="10000"}[24h]) + p50: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="5000"}[24h]) + p90: quantile_over_time(0.90, perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="5000"}[24h]) + p99: quantile_over_time(0.99, perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="5000"}[24h]) + mean: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="5000"}[24h]) success: avg_over_time(perp_execution_health{asset="BOT", venue="lighter"}[24h]) - sample_size: count_over_time(perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="10000"}[24h]) - series: perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="10000"} + sample_size: count_over_time(perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="5000"}[24h]) + series: perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="5000"} - slug: hyperliquid # name must stay the venue brand: provider profiles prefer bench spec @@ -102,12 +102,12 @@ providers: # /products/hyperliquid page title. The market detail lives in the tag. name: Hyperliquid tag: HIP-3 permissionless perp, xyz:BOT market - formula: "Average over 24h of the effective slippage in bps for a $10,000 buy on the Hyperliquid HIP-3 `xyz:BOT` orderbook, walked live every 30 seconds from `POST /info {type:l2Book, coin:\"xyz:BOT\"}`." + formula: "Average over 24h of the effective slippage in bps for a $5,000 buy on the Hyperliquid HIP-3 `xyz:BOT` orderbook, walked live every 30 seconds from `POST /info {type:l2Book, coin:\"xyz:BOT\"}`." queries: - p50: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) - p90: quantile_over_time(0.90, perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) - p99: quantile_over_time(0.99, perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) - mean: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) + p50: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="5000"}[24h]) + p90: quantile_over_time(0.90, perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="5000"}[24h]) + p99: quantile_over_time(0.99, perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="5000"}[24h]) + mean: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="5000"}[24h]) success: avg_over_time(perp_execution_health{asset="BOT", venue="hyperliquid"}[24h]) - sample_size: count_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) - series: perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"} + sample_size: count_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="5000"}[24h]) + series: perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="5000"} diff --git a/benchmarks/perp-fees.yml b/benchmarks/perp-fees.yml index db6aff79..951839ac 100644 --- a/benchmarks/perp-fees.yml +++ b/benchmarks/perp-fees.yml @@ -241,6 +241,32 @@ providers: sample_size: count_over_time(perp_fees_all_in_bps{venue="extended"}[24h]) series: perp_fees_all_in_bps{venue="extended"} + - slug: aster + name: Aster + tag: BSC perp DEX, 5 bps taker, Binance-style REST book + formula: "Average over 24h of (5 bps documented base taker fee plus half-spread and impact from the Aster fapi depth endpoint asks walked for $1000 of buy notional in the selected asset), in bps." + queries: + p50: avg_over_time(perp_fees_all_in_bps{venue="aster"}[24h]) + p90: quantile_over_time(0.90, perp_fees_all_in_bps{venue="aster"}[24h]) + p99: quantile_over_time(0.99, perp_fees_all_in_bps{venue="aster"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps{venue="aster"}[24h]) + success: avg_over_time(perp_fees_health{venue="aster"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps{venue="aster"}[24h]) + series: perp_fees_all_in_bps{venue="aster"} + + - slug: edgex + name: edgeX + tag: Offchain CLOB perp DEX, 3.8 bps taker + formula: "Average over 24h of (3.8 bps documented base taker fee plus half-spread and impact from the edgeX public getDepth endpoint asks walked for $1000 of buy notional in the selected asset), in bps." + queries: + p50: avg_over_time(perp_fees_all_in_bps{venue="edgex"}[24h]) + p90: quantile_over_time(0.90, perp_fees_all_in_bps{venue="edgex"}[24h]) + p99: quantile_over_time(0.99, perp_fees_all_in_bps{venue="edgex"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps{venue="edgex"}[24h]) + success: avg_over_time(perp_fees_health{venue="edgex"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps{venue="edgex"}[24h]) + series: perp_fees_all_in_bps{venue="edgex"} + # Notional-tier companion panels. The panel metric leaves the chain label # unpinned and wraps the selector in avg(), so the ETH / BTC / SOL tab # filter is injected exactly like on the headline queries and the panels diff --git a/benchmarks/pm-api-latency.yml b/benchmarks/pm-api-latency.yml index 394efef7..b7452b9e 100644 --- a/benchmarks/pm-api-latency.yml +++ b/benchmarks/pm-api-latency.yml @@ -41,8 +41,8 @@ abstract: | this API up" is answered by direct measurement from three regions rather than by user reports. Samples that fail because our pinned market expired are classified probe_invalid and never count against the venue. - Third party data aggregators that resell venue prices (Mobula, Codex, - Predexon) are tracked separately in the pm-data-freshness bench and on + Third party data aggregators that resell venue prices (Codex, Predexon) + are tracked separately in the pm-data-freshness bench and on the data feeds tab of the prediction markets hub. This page ranks the five venues' own APIs only, so the leaderboard answers one clean question: which prediction market venue API is fastest right now. @@ -56,7 +56,7 @@ methodology: - "Division of labor with the sibling benches: how each venue behaves as request rates climb, including throttle onset and 429 handling, is measured in pm-rate-limits (bench 037). How fresh third party data providers relay Polymarket data is measured in pm-data-freshness (bench 032). This bench ranks the venues' own APIs on latency and uptime at a polite request rate." - "Myriad's origin is a single region US East deployment behind Heroku, so its latency from eu-west and sgp is dominated by geography. Reported as measured, called out per region." - "All five venues are probed by the same open source harness as bench 037, from the same processes, so the two benches share one probe budget and one identifying User-Agent: `OpenChainBench/1.0 (+https://openchainbench.com/methodology; contact@mobula.io)`. We publish latency and uptime measurements only, never market data." - - "Aggregators that resell venue data (Mobula, Codex, Predexon) are deliberately excluded from this leaderboard. They are not venue APIs, they are relays on top of venue APIs, and ranking them next to the venues they relay would compare two different products on one axis. Their freshness lag against the Polymarket CLOB T0 stream is the relevant metric, measured in the pm-data-freshness bench. The prediction markets hub data feeds tab lists each aggregator with the venues it covers." + - "Aggregators that resell venue data (Codex, Predexon) are deliberately excluded from this leaderboard. They are not venue APIs, they are relays on top of venue APIs, and ranking them next to the venues they relay would compare two different products on one axis. Their freshness lag against the Polymarket CLOB T0 stream is the relevant metric, measured in the pm-data-freshness bench. The prediction markets hub data feeds tab lists each aggregator with the venues it covers." - "Regions: us-east, eu-west, sgp (Railway). Histogram buckets 25ms to 10s." findings: @@ -65,7 +65,7 @@ findings: - "{{name:manifold}} would look faster than it is if cache hits counted: its whole API sits behind a 5 second CDN cache. With cache hits excluded its origin answers at {{p50:manifold}} p50, an honest number a trading bot polling fresh quotes will actually see." - "{{name:myriad}} serves every region from a single US East origin, so its cohort worst p50 of {{p50:myriad}} is mostly geography. Switch the region dimension to us-east to see the API itself rather than the speed of light." - "{{name:polymarket}} answers its midpoint endpoint at {{p50:polymarket}} p50. Because we probe it every 5 seconds from three regions, the uptime panel doubles as a Polymarket API status check that updates continuously instead of waiting for user reports." - - "For builders who consume venue data through a managed relay, the relevant question is freshness lag, not request latency. Mobula, Codex and Predexon are measured in the pm-data-freshness bench (T0 against the Polymarket CLOB stream). The prediction markets hub data feeds tab lists each aggregator with the venues it covers." + - "For builders who consume venue data through a managed relay, the relevant question is freshness lag, not request latency. Codex and Predexon are measured in the pm-data-freshness bench (T0 against the Polymarket CLOB stream). The prediction markets hub data feeds tab lists each aggregator with the venues it covers." disclaimer: "Uptime here means our probes succeeded from three specific regions at a polite request rate. A venue can be up for us and degraded for you, especially during regional network incidents, and a brief blip between probe cycles can go unrecorded. Treat the panel as a measured signal, not a guarantee." @@ -83,7 +83,7 @@ faq: - q: "Why do latency numbers exclude CDN cache hits?" a: "Because a cache hit measures the distance to the nearest edge node, not the venue's API. Manifold serves its entire API behind a 5 second cache and Kalshi's market list sits on CloudFront, so a naive average would mostly rank CDN proximity. Every sample carries a cache flag read from the response headers, and the headline aggregates only non cached, successful requests. The number you see is what a client polling for fresh quotes actually experiences at the origin." - q: "Does this benchmark cover throttling or data providers?" - a: "No, deliberately. What happens to each venue's API as request rates climb, including 429 onset and queueing behaviour, is its own benchmark, pm-rate-limits, which runs a careful daily ramp. And if you consume venue data through a provider such as Mobula or Codex rather than calling the venue directly, the relevant number is relay freshness, measured in pm-data-freshness. This page answers two narrower questions: which venue API is fastest at a polite request rate, and is it up right now." + a: "No, deliberately. What happens to each venue's API as request rates climb, including 429 onset and queueing behaviour, is its own benchmark, pm-rate-limits, which runs a careful daily ramp. And if you consume venue data through a provider such as Codex or Predexon rather than calling the venue directly, the relevant number is relay freshness, measured in pm-data-freshness. This page answers two narrower questions: which venue API is fastest at a polite request rate, and is it up right now." per_chain_explainer: - slug: polymarket @@ -101,6 +101,9 @@ per_chain_explainer: - slug: myriad h2: Myriad API latency body: "Myriad's API is a single region Heroku deployment in US East with no order book endpoint and no WebSocket. Its cohort worst {{p50:myriad}} p50 (24h, all regions) is mostly geography: from eu-west and Singapore every request crosses an ocean first. When checking whether the Myriad API is down, look at the us-east region figure, since a global latency rise that spares us-east is network weather, not a venue incident. Myriad also runs the tightest keyless request budget in the cohort, covered in the pm-rate-limits bench." + - slug: predexon + h2: Predexon API latency + body: "Predexon is a multi-venue aggregator that unifies Polymarket, Kalshi and Limitless behind one schema. Its API answers at {{p50:predexon}} p50 (24h, all regions), measured on the same market-price endpoint the harness probes on the native venues, so the direct comparison of a unified aggregator against three venue-native APIs is head to head. Predexon aggregates upstream data; a Predexon latency higher than the sum of its three underliers is the cost of unification. Lower is the value of having one contract instead of three." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/pm-rate-limits @@ -128,14 +131,14 @@ metric_panels: - id: uptime_24h label: Uptime 24h description: "Share of probe cycles that succeeded over the last 24 hours, averaged across the three probe regions. 100 percent means every 5 second probe of the venue's API came back healthy." - metric: 100 * avg(avg_over_time(pmapi_health{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad"}[24h])) + metric: 100 * avg(avg_over_time(pmapi_health{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad"}[24h])) or 100 * avg(avg_over_time(pmapi_health{source="predexon"}[24h])) label_key: venue unit: pct higher_is_better: true - id: uptime_7d label: Uptime 7d description: "Same health gauge averaged over 7 days. Short outages that vanish from the 24h figure stay visible here for a week." - metric: 100 * avg(avg_over_time(pmapi_health{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad"}[7d])) + metric: 100 * avg(avg_over_time(pmapi_health{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad"}[7d])) or 100 * avg(avg_over_time(pmapi_health{source="predexon"}[7d])) label_key: venue unit: pct higher_is_better: true @@ -228,3 +231,15 @@ providers: success: clamp_max(sum(rate(pmapi_requests_total{venue="myriad",source="direct",class="price",conn="warm",outcome="ok"}[24h])) / sum(rate(pmapi_requests_total{venue="myriad",source="direct",class="price",conn="warm",outcome!="probe_invalid"}[24h])), 1) sample_size: sum(increase(pmapi_requests_total{venue="myriad",source="direct",class="price",conn="warm",outcome!="probe_invalid"}[24h])) series: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{venue="myriad",source="direct",class="price",conn="warm",cache!="hit"}[1h])) by (le)) + - slug: predexon + name: Predexon + tag: Multi-venue aggregator API (Polymarket, Kalshi, Limitless), unified schema + formula: "p50 of warm, non CDN cached round trips against the Predexon market-price endpoints for the three covered venues, keyless-equivalent (free-tier key), successful requests only, 24h window." + queries: + p50: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{source="predexon",class="price",conn="warm",cache!="hit"}[24h])) by (le)) + p90: 1000 * histogram_quantile(0.90, sum(rate(pmapi_request_duration_seconds_bucket{source="predexon",class="price",conn="warm",cache!="hit"}[24h])) by (le)) + p99: 1000 * histogram_quantile(0.99, sum(rate(pmapi_request_duration_seconds_bucket{source="predexon",class="price",conn="warm",cache!="hit"}[24h])) by (le)) + mean: 1000 * sum(rate(pmapi_request_duration_seconds_sum{source="predexon",class="price",conn="warm",cache!="hit"}[24h])) / sum(rate(pmapi_request_duration_seconds_count{source="predexon",class="price",conn="warm",cache!="hit"}[24h])) + success: clamp_max(sum(rate(pmapi_requests_total{source="predexon",class="price",conn="warm",outcome="ok"}[24h])) / sum(rate(pmapi_requests_total{source="predexon",class="price",conn="warm",outcome!="probe_invalid"}[24h])), 1) + sample_size: sum(increase(pmapi_requests_total{source="predexon",class="price",conn="warm",outcome!="probe_invalid"}[24h])) + series: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{source="predexon",class="price",conn="warm",cache!="hit"}[1h])) by (le)) diff --git a/benchmarks/pm-data-freshness.yml b/benchmarks/pm-data-freshness.yml index 8f6836af..c6fb7bd1 100644 --- a/benchmarks/pm-data-freshness.yml +++ b/benchmarks/pm-data-freshness.yml @@ -4,7 +4,7 @@ slug: pm-data-freshness number: "032" title: Fastest prediction market data API, live freshness across venues seo_title: "Fastest prediction market data API 2026" -seo_description: "Fastest prediction market data API ranked live across Polymarket and Kalshi. Milliseconds Mobula and Codex lag the venue gateway publish on top markets." +seo_description: "Fastest prediction market data API ranked live across Polymarket and Kalshi. Milliseconds Codex and Predexon lag the venue gateway publish on top markets." subtitle: Per event delay between provider arrival and the venue gateway publish, measured every minute on the top markets by 24 hour volume across Polymarket and Kalshi. category: Aggregators @@ -22,8 +22,8 @@ seo_intro: | a trade and when the provider relays the same trade to its WebSocket subscribers. This benchmark measures exactly that. The harness subscribes to the same basket of top volume markets on each venue's own - canonical source (T0), on Mobula's PM WebSocket, and on Codex GraphQL - subscriptions. Each trade is cross correlated across the streams, and + canonical source (T0) and on Codex GraphQL subscriptions. Each trade + is cross correlated across the streams, and the per provider lag versus the venue gateway publish time is recorded as a Prometheus histogram. The leaderboard sorts by p50 freshness delta in milliseconds, lower is better. Use the venue tab at the top of the @@ -41,14 +41,12 @@ abstract: | seconds are not counted toward their p50, only toward their receive total, so a provider can look fresh on the leaderboard while silently dropping events. The success rate column flags that. Coverage today: - Mobula and Codex on Polymarket, Codex only on Kalshi (Mobula does not - yet cover Kalshi venue data). + Codex on Polymarket and Kalshi. methodology: - "Anchor rows: on each venue tab the venue itself (Polymarket, Kalshi) is the canonical T0, so its row reads as the harness network round trip, near zero by construction. It is kept on the board to document the reference point, not as a competing feed; the comparison rows are the data providers measured against it." - "Polymarket T0. `wss://ws-subscriptions-clob.polymarket.com/ws/market` is public, no auth, sub 50ms gateway publish latency from EU West. Cross correlation uses (conditionId, priceUSD rounded to 3 decimals, sizeUSD micros, 5s time bucket)." - "Kalshi T0. `https://api.elections.kalshi.com/v1/social/trades` (REST), polled every 5s with cursor pagination. Every trade carries `create_date` at microsecond precision; that timestamp is the canonical T0. Poll cadence affects only correlation timing, not the freshness number. The official Kalshi WebSocket needs RSA PSS signed headers from a US KYC account and 403s from non US IPs, not viable for a public benchmark." - - "Mobula PM WebSocket. `wss://pm-api-prod-eu.mobula.io`. Auth via API key in the subscribe payload. Cloudflare on the gateway requires a browser User Agent on the upgrade request, default Go HTTP UA is silently filtered. Covers Polymarket today, Kalshi coverage is not yet shipped." - "Codex GraphQL subscriptions. `wss://graph.codex.io/graphql` with the `graphql-transport-ws` subprotocol. Firehose `onPredictionTradesCreated` subscription filtered client side to the venue's marketIds. Covers both Polymarket and Kalshi." - "Basket. Top 20 active markets by 24h volume per venue, refreshed every 5 minutes. Polymarket pulls from `gamma-api.polymarket.com`, Kalshi pulls from `api.elections.kalshi.com/trade-api/v2/markets`." - "Histogram buckets: 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000 ms. The venue's own arrivals always land in the smallest bucket because by construction we measure other providers against it." @@ -57,21 +55,16 @@ methodology: findings: - "Each venue's own gateway is by definition the freshest source on the leaderboard for that venue. The lag versus its own publish time is on the order of the network round trip from the harness to the gateway, typically below 100 ms p50 from EU West." - - "{{name:mobula}} relays Polymarket trades with a p50 delta of {{p50:mobula}} versus the venue native gateway on the Polymarket tab. Mobula's PM WebSocket runs on a dedicated edge service in EU West and forwards events without orderbook reconstruction, which is why the gap to native is small. Mobula does not yet cover Kalshi, so the Kalshi tab shows Codex alongside the Kalshi T0 row only." - "{{name:codex}} sits at p50 {{p50:codex}} on the Polymarket tab because Codex indexes the chain event (Polygon block confirmation) rather than the off chain orderbook publish. The lag includes Polygon block time (~2s) plus ingestion. On Kalshi Codex ingests Kalshi's own WebSocket feed directly, so the delta is purely pipeline latency." - - "The spread between providers reflects integration depth: native gateway vs edge cached relay vs chain indexed pipeline. None of these is wrong, they answer different questions. For live trading UIs the gateway path is the only viable one." + - "The spread between providers reflects integration depth: native gateway vs chain indexed pipeline. None of these is wrong, they answer different questions. For live trading UIs the gateway path is the only viable one." faq: - q: "Which prediction market data API has the lowest latency right now?" a: "{{best_name}} currently leads at {{best_p50}} (p50 over the last 24h) measured as time from the venue's own canonical WebSocket publish to provider relay. The leaderboard re sorts every minute on fresh Prometheus samples, so the answer reflects the actual measured lag on the active market basket, not a marketing claim. Switch the venue tab at the top to see Polymarket vs Kalshi independently." - q: "What does 'freshness delta' mean for a prediction market API?" a: "We connect to the venue's own canonical WebSocket and to the provider's WebSocket simultaneously, subscribe to the same markets, and for every trade event we record how many milliseconds the provider takes to relay the event after the venue itself publishes it. Lower is better. The venue's own gateway publish time is the canonical T0 because by construction nothing downstream can be faster than the source." - - q: "Is Mobula's PM WebSocket faster than Codex?" - a: "When the Polymarket tab is selected, it depends on what each provider does under the hood. {{name:mobula}} is an edge cached relay of Polymarket's own gateway, so the p50 delta is roughly the network round trip between the two gateways plus a few ms of bookkeeping. {{name:codex}} ingests the on chain confirmation on Polygon, which adds the block time (~2 seconds) before any trade can be relayed. For a live UI building on Polymarket, the relay path wins on freshness. For on chain reconciliation or settlement workflows, the chain indexed path is what you actually want. On the Kalshi tab the comparison does not apply yet because Mobula does not cover Kalshi." - q: "Why don't you include Polymarket REST polling on this benchmark?" a: "When the Polymarket tab is selected, freshness is a WebSocket question. REST polling at 1s would have a floor freshness around 500ms (poll interval / 2) plus RTT, dominated by how often you poll. The Polymarket gateway WebSocket exists for exactly this reason, to avoid that floor. Adding REST as a row would make the leaderboard noisy without changing the conclusion: WebSocket beats polling by definition for real time data." - - q: "Why is only Codex shown on the Kalshi tab?" - a: "Because Mobula's PM API does not cover Kalshi venue data today. The Kalshi tab compares Codex against the Kalshi T0 source only. Mobula coverage of Kalshi is on the roadmap; when it ships the same provider will appear on both tabs and the comparison becomes apples to apples again." - q: "What does the Kalshi T0 source measure?" a: "Kalshi's own public WebSocket at `wss://external-api-ws.kalshi.com/trade-api/ws/v2`, channel `trade`. The harness reads the `msg.ts_ms` field of each fill as the canonical publish timestamp and uses it as T0 for every Kalshi side comparison. Just like Polymarket's CLOB WebSocket on the Polymarket tab, it is the source against which provider latency is measured." - q: "Are these numbers comparable to Kalshi or Limitless?" @@ -131,19 +124,6 @@ providers: sample_size: sum(increase(pm_events_total{provider="kalshi"}[24h])) series: pm_health{provider="kalshi"} * 0.5 - - slug: mobula - name: Mobula - tag: Edge cached venue relay, browser UA required (Polymarket only today) - formula: "Median ms lag versus the venue gateway, measured by cross correlating trades over the rotating basket of top 20 markets. Polymarket coverage today, Kalshi pending." - queries: - p50: histogram_quantile(0.50, sum(rate(pm_freshness_delta_ms_bucket{provider="mobula",kind="trade"}[24h])) by (le)) - p90: histogram_quantile(0.90, sum(rate(pm_freshness_delta_ms_bucket{provider="mobula",kind="trade"}[24h])) by (le)) - p99: histogram_quantile(0.99, sum(rate(pm_freshness_delta_ms_bucket{provider="mobula",kind="trade"}[24h])) by (le)) - mean: sum(rate(pm_freshness_delta_ms_sum{provider="mobula",kind="trade"}[24h])) / sum(rate(pm_freshness_delta_ms_count{provider="mobula",kind="trade"}[24h])) - success: clamp_max(sum(rate(pm_matched_total{provider="mobula",kind="trade"}[24h])) / sum(rate(pm_events_total{provider="polymarket",kind="trade"}[24h])), 1) - sample_size: sum(increase(pm_matched_total{provider="mobula",kind="trade"}[24h])) - series: histogram_quantile(0.50, sum(rate(pm_freshness_delta_ms_bucket{provider="mobula",kind="trade"}[1h])) by (le)) - - slug: codex name: Codex tag: Chain indexer on Polymarket, native ingestion on Kalshi diff --git a/benchmarks/pm-rate-limits.yml b/benchmarks/pm-rate-limits.yml index 0bc0c11d..5e3b82ad 100644 --- a/benchmarks/pm-rate-limits.yml +++ b/benchmarks/pm-rate-limits.yml @@ -87,7 +87,7 @@ faq: - q: "How do you test rate limits without abusing the APIs?" a: "Four guardrails. Tiers stay well inside documented budgets where they exist (at most 7 percent of Polymarket's book allowance). Every run aborts as soon as throttled plus server errors exceed 1 percent of a 10 second window, and Kalshi stops at the first 429. Only one region ramps a venue at a time, on disjoint UTC hours. And every request carries an identifying User-Agent with a contact address, so a venue can reach us or filter us selectively. The measurement is throttle onset, not stress to failure." - q: "Why measure the venue APIs directly instead of a data provider?" - a: "They answer different questions. If you build directly on a venue, its native API latency and throttle behaviour set your floor, and that is what this benchmark measures. If you consume the venue through a data provider such as Mobula or Codex, what matters is how fresh the provider's relay is, which is measured separately in the pm-data-freshness benchmark. Both pages link each other so you can compare the paths." + a: "They answer different questions. If you build directly on a venue, its native API latency and throttle behaviour set your floor, and that is what this benchmark measures. If you consume the venue through a data provider such as Codex or Predexon, what matters is how fresh the provider's relay is, which is measured separately in the pm-data-freshness benchmark. Both pages link each other so you can compare the paths." per_chain_explainer: - slug: polymarket diff --git a/benchmarks/polkadot-rpc.yml b/benchmarks/polkadot-rpc.yml new file mode 100644 index 00000000..9e8b0ffe --- /dev/null +++ b/benchmarks/polkadot-rpc.yml @@ -0,0 +1,161 @@ +# OpenChainBench. Bench № 086 + +slug: polkadot-rpc +number: "086" +title: Fastest free Polkadot RPC, live no-key endpoint latency +seo_title: "Fastest free Polkadot RPC 2026" +seo_description: "{{best_name}} leads free Polkadot RPC at {{best_p50}} (chain_getHeader p50, 24h). 3 no-key providers measured every 60s from 3 regions." +subtitle: HTTP round-trip latency for chain_getHeader against every free, no-key public Polkadot relay-chain endpoint, audited every 60 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Polkadot is the first non-EVM, non-Solana chain in the RPC latency + cluster. Substrate-based relay chain, JSON-RPC 2.0 wire but a + different method set: the probe is `chain_getHeader` (returns the + latest header with a hex-encoded block number) rather than + `eth_getBlockByNumber`. Everything else on this page is the same + bench pattern used on Ethereum, Base, Solana and the 20+ other + chains: identical POST every 60 seconds from three regions, latency + measured client-side at millisecond precision, per-region breakdowns + first class. Provider cohort at launch: Parity's official + `rpc.polkadot.io`, OnFinality's public gateway, and PublicNode + (Allnodes). Dwellir + 1RPC audited alongside and excluded (Dwellir + 503 during the launch sweep; 1RPC filters `chain_getHeader` on its + privacy relay); both will be revisited when a stable no-key path + reappears. + +abstract: | + Per-chain member of the RPC latency cluster, extended for Substrate. + We measure the round-trip latency of a single, identical JSON-RPC + call (`chain_getHeader`) against every no-key public Polkadot + relay-chain endpoint that sustains continuous probing, 3 providers + at launch, every 60 seconds, from us-east, eu-west and Singapore. The harness + classifies every response (ok / http_err / jsonrpc_err / stale / + timeout) with a relay-chain-scaled staleness gap (40 blocks ≈ 4 + minutes at Polkadot's ~6 s block time), so the leaderboard rewards + sustained, honest availability rather than a fast error message. + The cross-chain view lives on the parent `rpc-capabilities` + benchmark; this page is the Polkadot-scoped answer with per-region + breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 60 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"chain_getHeader\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request. Non-cacheable by design: the latest-header fetch with a rotating id defeats edge caches that would answer a bare head query without touching a node." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result with a parsable hex block number), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 40 relay blocks behind the cross-provider tip, roughly 4 minutes at Polkadot's ~6 s block time), `timeout`. Latency without reliability is a misleading ranking signal." + - "No archive-depth loop on this chain. Polkadot's state model is accessed via `state_getStorage` keyed by a Blake2-hashed storage key rather than the EVM `eth_getBalance(address, height)` pattern, so the archive probe used on the EVM cluster is not applied here." + - "No cross-provider hash quorum on this chain. `chain_getHeader` returns the parent-block hash rather than the current-block hash (the current hash is derived by Blake2 over the SCALE-encoded header). Bench 083's height-hash quorum check is therefore skipped for Polkadot; the same reliability signal is still captured via `ok` / `stale` classification against the rolling cross-provider tip." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to `chain=\"polkadot\"`. Provider coverage at launch: 3 no-key endpoints (Parity official, OnFinality public gateway, PublicNode). Excluded by the launch audit: 1RPC filters `chain_getHeader` as \"Not Allowed\" on its privacy relay; Dwellir returned 503 during the audit sweep; Ankr paid Polkadot tier only; Chainstack key-gated; RadiumBlock requires a referral header; Grove and Thirdweb return invalid-chain or key-gated." + +findings: + - "{{best_name}} currently leads free Polkadot RPC at {{best_p50}} (`chain_getHeader` p50, 24h) across 3 measured providers." + - "{{name:onfinality}} is a Web3 Foundation grantee that serves a large share of Polkadot ecosystem infra; the bench measures its public gateway under the same probe used for the multi-chain gateways, so the leaderboard is directly comparable to what a chain-agnostic integrator sees." + - "{{name:publicnode}} extends its universal multi-chain footprint onto Substrate without a keyed path; the reliability column is what to read alongside p50 here since Substrate JSON-RPC has different failure modes than EVM (SCALE decode timeouts, sudden peer drops during finality gaps)." + +faq: + - q: "What is the fastest free Polkadot RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (`chain_getHeader` p50 over the last 24h), measured against 3 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which Polkadot RPCs work without an API key?" + a: "Three endpoints sustain continuous keyless probing at launch: Parity's official `rpc.polkadot.io`, OnFinality's public gateway and PublicNode (operated by Allnodes). Every listed endpoint was live-verified with four consecutive `chain_getHeader` probes returning a parsable hex block number before inclusion. Excluded by the audit: 1RPC filters `chain_getHeader` as \"Not Allowed\" on its privacy relay, Dwellir returned 503 Service Unavailable during the sweep (will be revisited once stable), Ankr requires a paid Polkadot key, Chainstack is key-gated on Substrate, and RadiumBlock rejects requests without a referral header." + - q: "Does the fastest Polkadot RPC change by region?" + a: "Frequently, and often more sharply than EVM chains. Polkadot's public infra is heavily weighted toward Web3 Foundation grantees whose primary data centers sit in Europe; a gateway that wins from Amsterdam can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is Polkadot RPC latency measured here, technically?" + a: "One identical Substrate JSON-RPC POST (`chain_getHeader`) every 60 seconds against each provider from each of 3 regions, using the same plain HTTP client the EVM cluster uses. The response is a header object containing a hex-encoded relay-block number and the parent-block hash; the harness parses the number (`strconv.ParseUint(strings.TrimPrefix(hdr.Number, \"0x\"), 16, 64)`, same rule as EVM). Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest." + - q: "Why is the probe `chain_getHeader` and not `system_health`?" + a: "Two reasons. First, `chain_getHeader` returns the head block number, which lets the harness plug into the cluster's cross-provider tip machinery for staleness classification without adding a chain-specific code path. Second, `system_health` returns a small object about peer count and sync state that any polite CDN can synthesize without touching the underlying node, defeating the point of a latency probe on the RPC path. `chain_getHeader` forces the endpoint to actually read the head from the runtime, which is what integrators care about." + - q: "Why is there no archive-depth or hash-quorum column for Polkadot?" + a: "Polkadot's state model accesses historical state via `state_getStorage` keyed by a Blake2-hashed storage key, which has no chain-agnostic depth analog for the EVM `eth_getBalance(address, height)` probe the cluster uses on other chains. Similarly, `chain_getHeader` returns the parent-block hash (not the current-block hash: the current hash is derived by Blake2 over the SCALE-encoded header), so the cross-provider height-hash quorum check from bench 083 cannot be run against Polkadot without adding a Substrate-native encoder to the harness. Both columns are omitted honestly rather than filled with a proxy that would misrepresent the chain." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="polkadot"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: polkadot-official + name: Parity + tag: Parity's official Polkadot relay endpoint + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Parity's official Polkadot relay endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="polkadot-official", chain="polkadot"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="polkadot-official", chain="polkadot"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="polkadot-official", chain="polkadot"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="polkadot-official", chain="polkadot"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="polkadot-official", chain="polkadot"}) / sum(ocb:rpc_call:rate_24h{provider="polkadot-official", chain="polkadot"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="polkadot-official", chain="polkadot"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="polkadot-official", chain="polkadot"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="polkadot-official", chain="polkadot", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="polkadot-official", chain="polkadot", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="polkadot-official", chain="polkadot", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="polkadot-official", chain="polkadot", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="polkadot-official", chain="polkadot", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="polkadot-official", chain="polkadot", region="sgp"}[1h]) + + - slug: onfinality + name: OnFinality + tag: Substrate-native public gateway, Web3 Foundation grantee + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to OnFinality's public Polkadot endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="polkadot"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="onfinality", chain="polkadot"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="onfinality", chain="polkadot"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="onfinality", chain="polkadot"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="onfinality", chain="polkadot"}) / sum(ocb:rpc_call:rate_24h{provider="onfinality", chain="polkadot"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="onfinality", chain="polkadot"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="polkadot"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="polkadot", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="polkadot", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="polkadot", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="polkadot", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="polkadot", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="polkadot", region="sgp"}[1h]) + + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, 70+ chains, includes Substrate + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Polkadot endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="polkadot"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="polkadot"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="polkadot"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="polkadot"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="polkadot"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="polkadot"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="polkadot"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="polkadot"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="polkadot", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="polkadot", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="polkadot", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="polkadot", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="polkadot", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="polkadot", region="sgp"}[1h]) diff --git a/benchmarks/rpc-capabilities.yml b/benchmarks/rpc-capabilities.yml index cff135e2..cee5e02a 100644 --- a/benchmarks/rpc-capabilities.yml +++ b/benchmarks/rpc-capabilities.yml @@ -2,10 +2,10 @@ slug: rpc-capabilities number: "010" -title: Fastest free public RPC for Ethereum, BNB, Polygon and 7 more EVM chains +title: Fastest free public RPC for Ethereum, BNB, Polygon and 23 more chains (plus Solana and Polkadot) seo_title: "Fastest free public RPC 2026" seo_description: "Fastest free public RPC live on eth_getBlockByNumber p50. dRPC, PublicNode, Tenderly, Nodies, Lava ranked." -subtitle: HTTP round-trip latency for eth_getBlockByNumber against free, no-key public RPC endpoints across 10 EVM chains, audited every 60 seconds. +subtitle: HTTP round-trip latency for eth_getBlockByNumber against free, no-key public RPC endpoints across 26 chains (24 EVM plus Solana and Polkadot), audited every 60 seconds. category: RPCs status: live metric: RPC latency @@ -13,13 +13,14 @@ unit: ms higher_is_better: false disclaimer: > - Latency is conditional on success: the harness times only calls that - return a valid, fresh block, so an endpoint failing most calls can - still post a fast p50 from its rare successes. Treat rows below 50 - percent success as degraded. Providers whose probes stop succeeding - are pinned below the table as unresponsive. On the All chains tab - each number is the mean of per chain, per region 24h p50s; single - chain providers are measured on their one chain only. + Latency is conditional on success: the harness times only calls + returning a valid fresh block, so a mostly-failing endpoint can + post a fast p50. Rows below 50 percent success are degraded; + unresponsive probes pin below the table. The All chains aggregate + favours single-chain foundation endpoints (Base, Binance, + Arbitrum, Optimism, Avalanche officials) since they rank on one + chain only; use a chain tab for honest comparison. Dead endpoints + (cloudflare-eth POST returns -32603) are excluded. seo_intro: | This benchmark answers the question every developer reaching for a @@ -28,14 +29,15 @@ seo_intro: | chain my product runs on. Alchemy and Infura own the keyed-tier market; the free public side of the question lives elsewhere, in the RPC services dapps hit when a contributor's free quota is the - budget. We probe `eth_getBlockByNumber` every 60 seconds against 13 - audited providers across 10 EVM chains. PublicNode, dRPC, - MeowRPC, Tenderly Gateway, Nodies (POKT), Lava Network, - Flashbots Protect, Cloudflare and 5 chain-official foundation - endpoints (Base, Binance BSC, Avalanche, Arbitrum, Optimism). - Chains covered. Ethereum (8 providers), Arbitrum (7), Base (5), - Optimism (5), Avalanche (4), BNB (4), Polygon (4), Linea + Scroll + Mantle (3 - each). The headline question is per chain, not cross-chain. Five + budget. We probe `eth_getBlockByNumber` every 60 seconds against + audited providers across 26 chains (24 EVM plus Solana and + Polkadot). PublicNode, dRPC, MeowRPC, Tenderly Gateway, Nodies + (POKT), Lava Network, Flashbots Protect, bloXroute, OnFinality + and chain-official foundation endpoints (Base, Binance BSC, + Avalanche, Arbitrum, Optimism, Sonic, Gnosis, Celo, Blast, Taiko, + Moonbeam, Berachain, zkSync, Cronos, Fraxtal, Unichain, Soneium, + Monad, MegaETH, Parity for Polkadot, Solana Labs). + Chain footprint spans the L1/L2 majors down to long-tail chains. The headline question is per chain, not cross-chain. Five providers are single-chain by design (the foundation endpoints above), so a cross-chain aggregate mechanically rewards whichever single-chain endpoint happens to be fastest on its one chain. @@ -53,8 +55,10 @@ seo_intro: | abstract: | We measure the round-trip latency of a single, identical RPC call - (`eth_getBlockByNumber`) against every major no-key Ethereum-compatible - public RPC endpoint, across 10 EVM chains. The + (`eth_getBlockByNumber` for EVM chains, `getSlot` for Solana, + `chain_getHeader` for Polkadot) against every major no-key public + RPC endpoint, across 26 chains (24 EVM plus Solana and Polkadot). + The harness emits three metric families from one binary because they share the same (provider × chain) client matrix and same 15 s scrape interval: (a) `rpc_latency_milliseconds` for the speed @@ -78,7 +82,7 @@ methodology: - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err` (status ≠ 200 or transport failure), `jsonrpc_err` (HTTP 200 with an `error` field, the Cloudflare-eth trap), `stale` (returned block more than 20 behind the cross-provider tip for that chain), `timeout`. Counter `rpc_call_total{result}` powers the reliability leaderboard." - "Success gating: because failed probes record no latency, a mostly failing endpoint can post a fast p50 from its rare successes. The ledger pairs every latency figure with a Success column computed as ok calls over total calls in the same 24h window; read rows below 50 percent success as degraded. Providers with under 5 percent success lose their latency series to Prometheus staleness and are pinned below the table as unresponsive instead of being ranked." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head, depth) for `depth` in {300, 7200, 216_000, 1_296_000, 5_000_000}. Gauge `rpc_archive_depth_supported{depth}` is 1 when the response is non-pruned, 0 otherwise. The 300/7200 thresholds cover Geth's default pruned-cap range; 216k ≈ 1 month; 1.3M ≈ 6 months; 5M ≈ genesis-era full archive." - - "Chain coverage: 10 EVM chains. Ethereum (8 providers), Arbitrum (7), Base (5), Optimism (5), Avalanche (4), BNB (4), Polygon (4), Linea (3), Scroll (3), Mantle (3). Provider matrix per chain is documented in `miniapps/rpc-capabilities/cmd/script/config.go`." + - "Chain coverage: 26 chains (24 EVM plus Solana and Polkadot). EVM majors: Ethereum, Arbitrum, Base, Optimism, Avalanche, BNB, Polygon, Linea, Scroll, Mantle. EVM long-tail: Sonic, Gnosis, Celo, Blast, Taiko, Moonbeam, Berachain, zkSync, Cronos, Fraxtal, Unichain, Soneium, Monad, MegaETH. Non-EVM: Solana (getSlot probe), Polkadot (chain_getHeader probe). Provider matrix per chain is documented in `harnesses/rpc-capabilities/cmd/script/config.go`." - "Excluded: 1RPC (delisted 2026-07-09, under 13% success even at two chains and one probe per minute), Ankr (key-gated), gateway.fm (29 req/IP budget too tight for 15s polling), LlamaRPC + BlockPI + OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal + GetBlock + Chainstack (key-gated). Blink, formerly Merkle (no public eth_getBlockByNumber) + Lava and MeowRPC outside ETH/Arb excluded. Every (provider, chain) was live-verified no-key before inclusion." findings: @@ -95,7 +99,7 @@ faq: - q: "What is the fastest free public RPC right now?" a: "The question is per chain because several providers are single-chain by design (Base official, Binance, Arbitrum Foundation, Optimism Foundation, Avalanche) and a cross-chain aggregate mechanically rewards whichever single-chain endpoint happens to be fastest on its one chain. Current per-chain leaders. Ethereum {{best_name:chain:ethereum}} at {{best_p50:chain:ethereum}}, Base {{best_name:chain:base}} at {{best_p50:chain:base}}, BNB Chain {{best_name:chain:bnb}} at {{best_p50:chain:bnb}}, Arbitrum {{best_name:chain:arbitrum}} at {{best_p50:chain:arbitrum}}. The leaderboard re-sorts every 60 seconds against fresh Prometheus samples averaged across us-east, eu-west and sgp probes. Switch the chain tab to see the leader on the network your product runs on." - q: "Which public RPCs work without an API key in 2026?" - a: "13 audited providers across 10 EVM chains. Universal multi-chain (work on 4+ chains): PublicNode, dRPC, Tenderly Gateway (`gateway.tenderly.co/public/`), Nodies (POKT successor at `*-pokt.nodies.app`). Ethereum-specific or limited footprint: MeowRPC (ETH + Arbitrum only since 2025), Flashbots Protect, Cloudflare (read-degraded), Lava Network (ETH + Arbitrum no-key, other chains require key), Chain-official foundation RPCs: Arbitrum (`arb1.arbitrum.io/rpc`), Optimism (`mainnet.optimism.io`), Base (`mainnet.base.org`), Avalanche (`api.avax.network`), BNB (`bsc-dataseed1.binance.org`). Excluded: Ankr (key-gated), gateway.fm (29 req/IP), LlamaRPC / BlockPI / OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal / GetBlock / Chainstack (key-gated)." + a: "Audited providers across 26 chains (24 EVM plus Solana and Polkadot). Universal multi-chain: PublicNode, dRPC, Tenderly Gateway (`gateway.tenderly.co/public/`), Nodies (POKT at `*-pokt.nodies.app`). Ethereum-specific or limited footprint: MeowRPC (ETH + Arbitrum only), Flashbots Protect, Lava Network (ETH + Arbitrum + Sonic + Solana no-key). Chain-official foundation RPCs: Arbitrum, Optimism, Base, Avalanche, BNB, Sonic, Gnosis, Celo, Blast, Taiko, Moonbeam, Berachain, zkSync, Cronos, Fraxtal, Unichain, Soneium, Monad, MegaETH, Parity for Polkadot, Solana Labs, plus regional gateways like OnFinality and bloXroute. Excluded: Ankr (key-gated), gateway.fm (29 req/IP), LlamaRPC / BlockPI / OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal / GetBlock / Chainstack (key-gated), cloudflare-eth (POST returns -32603)." - q: "Does Cloudflare still have a free Ethereum RPC that works?" a: "Partially, and the trend is the wrong way. Cloudflare's public Ethereum gateway (cloudflare-eth) switched to a permissioned mode for many JSON-RPC methods. The endpoint still answers HTTP 200 quickly, but the body is increasingly a JSON-RPC error (`-32046 Cannot fulfill request`) rather than a block. This benchmark catches the trap by classifying a call as `ok` only when the HTTP status is 200 AND the JSON-RPC body has a usable `result` field, so Cloudflare's real success rate is visible in the Success column instead of hiding behind fast error responses. Latency without reliability is a misleading ranking signal for a public RPC." - q: "Why can an RPC with a low success rate still show a fast latency?" diff --git a/benchmarks/rpc-reliability.yml b/benchmarks/rpc-reliability.yml new file mode 100644 index 00000000..f588fcbf --- /dev/null +++ b/benchmarks/rpc-reliability.yml @@ -0,0 +1,297 @@ +# OpenChainBench. Bench № 083 + +slug: rpc-reliability +number: "083" +title: Most reliable free public RPC, correctness incidents across 7 EVM chains +seo_title: "Most reliable free public RPC 2026" +seo_description: "Free public RPCs ranked by correctness incidents: block-hash quorum disagreements, eth_getLogs completeness and eth_getBalance state integrity, live and keyless." +subtitle: "Correctness incidents per 24 hours for free no-key RPC endpoints: block hashes disagreeing with the cross-provider quorum, eth_getLogs counts deviating from the majority, divergent eth_getBalance state and blocked or gated methods, measured continuously alongside consensus head lag." +category: RPCs +status: live +metric: Reliability incidents +unit: count +higher_is_better: false + +disclaimer: > + A zero row means no incident was caught in the window, not a proof of + correctness. Most providers post zero most days; this bench exists for + the days they do not, and for the errors: a blocked eth_getLogs or a + balance read gated behind a paid key at 40 blocks of depth counts as + an incident because it is one from the caller's seat. + +seo_intro: | + Bench 010 answers which free public RPC is fastest; this page answers + the question that should come first. which one is telling the truth. + A fast endpoint that serves a stale head, drops logs from an + eth_getLogs response, or answers eth_getBalance from a diverged state + view is worse than a slow honest one, and none of that shows up in a + latency percentile. So we cross-examine the same keyless provider + cohort the latency bench probes, continuously, on three axes. First, + consensus head lag: every 60 seconds each provider's reported + (height, hash) pair feeds a per-chain quorum map, and the gap between + a provider's head and the highest head any provider reported is + published as a gauge. Second, hash integrity: when at least two + providers agree on the hash at a height, a provider reporting a + different hash at that same height is counted, once per height, as a + mismatch incident. Third, fixed-vector integrity: every 5 minutes, + one chain at a time, every provider gets the identical eth_getLogs + query for the chain's canonical USDC contract over a 10-block window + behind the tip, plus an eth_getBalance read at the same fixed block; + answers are compared across providers and a deviation from the strict + majority is an incident. Errors count too, deliberately: 1RPC blocks + eth_getLogs outright with a -32000, and PublicNode began gating + keyless reads a few dozen blocks deep behind a personal token + (-32602) in July 2026. Those are reliability failures a latency bench + renders invisible, and here they are the headline. + +abstract: | + We measure the correctness of free no-key RPC endpoints, not their + speed. The harness reuses the bench-010 probe matrix (same providers, + same chains, same 60-second header fetch) and adds three metric + families. rpc_consensus_lag_blocks tracks each provider's head + against the highest head seen across providers for that chain. + rpc_hash_mismatch_total counts same-height hash disagreements with + the two-or-more-provider quorum, at most once per height, with a + strict-plurality rule so a live reorg splitting providers two against + two counts nobody. rpc_integrity_check_total classifies fixed-vector + checks (eth_getLogs for the chain's canonical USDC over a 10-block + window at a daily-rotating depth behind tip, and eth_getBalance of a + fixed well-known address at the same block) as ok, error or disagree + against the cross-provider majority. The headline is the raw + incident count over 24 hours: errors plus disagreements plus hash + mismatches. No weighted composite: weights invite gaming disputes, + and an incident is an incident. + +methodology: + - "Consensus lag: the bench-010 latency probe fetches the full latest header every 60 seconds per (provider, chain); its height and hash, previously discarded, feed this bench. rpc_consensus_lag_blocks is the gap between the provider's reported head and the highest head any probed provider reported for that chain (rolling max). Valid-but-stale responses still count as observations; failed probes delete the series so dead endpoints age out." + - "Hash quorum: per chain, a height-to-hash vote map. When one hash at height H is backed by at least 2 providers AND strictly more than any competing hash, it is canonical; a provider that reported a different hash at H is counted in rpc_hash_mismatch_total, at most once per (provider, height). A 2-2 split, the signature of a live reorg, resolves to no quorum and counts nobody." + - "Fixed-vector integrity, cadence: every 5 minutes ONE chain is checked, rotating through the 7 chains with a canonical high-traffic USDC deployment (Ethereum, Arbitrum, Optimism, Base, Polygon, BNB, Avalanche). Each provider on that chain gets 2 checks per round, so a single-chain provider accrues ~82 checks per day and a 7-chain provider ~575." + - "Logs completeness vector: eth_getLogs for the chain's canonical USDC contract (Circle native deployments; Binance-peg on BNB) over a 10-block window ending at tip minus N. Returned log counts are compared across providers; deviation from the strict majority count increments rpc_logs_disagreement_total and books a disagree incident. Live calibration on Ethereum returned 1371 logs consistently on drpc, tenderly and blastapi over the same fixed range." + - "Anti-gaming rotation: N rotates daily over {20, 30, 40, 50, 60} blocks, so a provider cannot special-case a published fixed range, and request ids rotate on every call (the same edge-cache defeat as the latency probe). All depths stay far inside non-archive territory, so a pruned-but-honest node is never penalized." + - "State consistency vector: eth_getBalance of a fixed well-known address (the same Vitalik address the archive-depth probe uses) at the tip-minus-N block. The hex answer must be byte-identical across providers; divergence from the strict majority increments rpc_state_disagreement_total. Live calibration showed byte-identical balances across all responding providers at a fixed block." + - "Errors are signal: rpc_integrity_check_total{result} is ok, error or disagree. error covers blocked methods (1RPC answers eth_getLogs with -32000), depth gating (PublicNode keyless regressed to under 150 blocks of archive depth in July 2026, returning -32602 asking for a personal token), and transport failures. The headline counts error and disagree alike: a method you cannot call is unreliable regardless of why." + - "Headline: incidents per 24h = increase of integrity checks with result error or disagree, plus increase of hash mismatches, from the eu-west vantage. Raw counts, no weighting; the p90/p99 columns are consensus-lag quantiles in blocks, published as raw companion dimensions." + - "Single-region disclosure: incidents are counted from the eu-west probe only. Correctness, unlike latency, is location-independent in principle, but every provider fronts with anycast, so what we audit is the serving cluster eu-west traffic lands on; a diverged cache in another region is invisible to this bench. The us-east and sgp replicas emit the same metrics for cross-checking on the time-series chart." + - "Quorum honesty: strict plurality everywhere. Two agreeing providers outvote one dissenter; two against two resolves to no quorum and books nothing; a lone answer with no second opinion is unverifiable, not wrong, and books ok. Disagreement requires being outvoted by an established majority." + - "Cohort: 12 keyless providers from the bench-010 audited matrix. Excluded: LlamaRPC (fully down, HTTP 521, already delisted from the harness), 1RPC (delisted 2026-07-09 at under 13 percent success; when probed live it also blocked eth_getLogs with -32000, exactly the incident class this bench counts), Ankr / NodeReal / GetBlock / Chainstack (key-gated). Avalanche's foundation endpoint is probed by the harness and visible on bench 010; it is left out here only to keep the cohort at 12." + - "Provider matrix and vector addresses are documented in harnesses/rpc-capabilities/cmd/script/config.go and integrity.go; the harness is shared with bench 010, so both benches see the identical provider set and probe budget." + +findings: + - "Consensus lag is real and measurable at 60-second resolution: calibration rounds of simultaneous eth_getBlockByNumber across the Ethereum cohort caught individual gateways one block behind the cross-provider tip in 2 of 4 rounds, while every provider at equal height returned byte-identical hashes. The lag gauge turns that from an anecdote into a 24h p90/p99 per provider." + - "The completeness gap is not hypothetical. On the same fixed 10-block USDC range, drpc, tenderly and blastapi each returned exactly 1371 logs, 1RPC refused the method with -32000, and PublicNode's keyless tier had regressed its archive depth to under 150 blocks, answering -32602 and asking for a personal token. Three providers agreed, two could not answer; both failure modes are incidents here." + - "State reads are the quiet good news: eth_getBalance at a fixed recent block came back byte-identical across every responding provider during calibration. rpc_state_disagreement_total exists for the day that stops being true, which is precisely the day you want a public counter." + - "{{name:cloudflare}} shows why errors must count: the endpoint answers HTTP 200 fast and fails inside the JSON-RPC body. A correctness bench that only compared successful answers would score it clean; counting error results books its blocked calls as the incidents they are." + - "Zero is the normal reading and that is the point. Free public RPCs mostly do tell the truth; the bench prices the exceptions, publicly timestamps regressions like PublicNode's July 2026 keyless depth gating, and gives the honest majority a way to prove they stayed honest." + +faq: + - q: "What counts as a reliability incident?" + a: "Three things, summed over 24 hours from the eu-west probe. An integrity check that errored (method blocked, depth gated behind a key, transport failure). An integrity check whose answer deviated from the strict cross-provider majority (an eth_getLogs count differing from the count at least two other providers agree on, or an eth_getBalance hex that is not byte-identical to the majority answer). And a block-hash mismatch, where the provider reported a hash at a height where at least two providers agreed on a different one. Raw counts, no weights; lower is better and zero is common." + - q: "Why do method errors count as incidents when this is a correctness bench?" + a: "Because from the caller's seat a method you cannot call is a reliability failure regardless of the reason. 1RPC blocks eth_getLogs with a -32000 error; PublicNode's keyless tier started answering balance reads a few dozen blocks deep with -32602 and a demand for a personal token. Neither returns wrong data, but both break the contract a developer assumes when pasting a no-key URL into a dapp. Counting them alongside genuine disagreements keeps the headline honest: it measures whether the endpoint reliably does what an EVM JSON-RPC endpoint is expected to do." + - q: "Can a provider game this benchmark by special-casing the test queries?" + a: "The vectors are designed to make that expensive. The eth_getLogs window is anchored at tip minus N where N rotates daily over five depths, so the exact range is never fixed; request ids rotate on every call, defeating body-keyed edge caches; and the USDC contracts are the busiest transfer contracts on each chain, so serving them correctly IS serving real traffic correctly. The head-lag and hash-quorum signals cannot be gamed at all without actually running well-synced nodes, because they are computed from the same latest-header probe the latency bench uses." + - q: "Does a reorg unfairly flag providers as mismatching?" + a: "No, by construction. During a live reorg two providers can legitimately hold different hashes at the same height. The quorum rule requires a strict plurality: one hash backed by at least two providers and strictly more than any competing hash. A two-against-two split resolves to no quorum and counts nobody, and a mismatch is booked at most once per provider per height. What remains after those guards, a provider outvoted by an established majority at a settled height, is the signal this bench exists to record." + - q: "Why is this measured from one region only?" + a: "The headline counts incidents from the eu-west vantage because correctness, unlike latency, does not depend on where the client stands: a node serving the canonical chain serves it to everyone. The honest caveat is anycast: each probe sees the serving cluster its region lands on, so a diverged cache serving only another continent is invisible to the eu-west counter. The us-east and Singapore replicas emit the same metric families, and the per-region series are on the chart for cross-checking; the headline stays single-region so the same incident is never double-counted." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_consensus_lag_blocks + +# Real metrics emitted by the rpc-capabilities harness (bench-083 set): +# rpc_consensus_lag_blocks{provider, chain, region} gauge +# rpc_hash_mismatch_total{provider, chain, region} counter +# rpc_logs_count{provider, chain, region} gauge +# rpc_logs_disagreement_total{provider, chain, region} counter +# rpc_state_disagreement_total{provider, chain, region} counter +# rpc_integrity_check_total{provider, chain, region, check, result} +# counter, check in {logs, balance}, result in {ok, error, disagree} +# Counters are zero-initialized for the whole (provider x chain) matrix +# at harness start, so increase() reads 0 (not absent) for clean rows. +# Headline scopes region="eu-west" (see methodology); the chain tab +# injects chain="X" into every query. + +dimensions: + chain: + - { value: all, label: All chains } + - { value: ethereum, label: Ethereum } + - { value: polygon, label: Polygon } + - { value: arbitrum, label: Arbitrum } + - { value: optimism, label: Optimism } + - { value: base, label: Base } + - { value: bnb, label: BNB Chain } + - { value: avalanche, label: Avalanche } + +# clamp_min floor: the cell-rank loader drops samples <= 0 (guard against +# absent-series garbage on latency benches), but 0 incidents is this bench's +# BEST and most common value; the epsilon keeps clean providers ranked. +rank_matrix_query: clamp_min(sum by (provider, chain) (increase(rpc_integrity_check_total{region="eu-west",result=~"error|disagree"}[24h])) + sum by (provider, chain) (increase(rpc_hash_mismatch_total{region="eu-west"}[24h])), 0.001) + +# ~82 integrity checks per provider per chain per day (2 checks per +# 5-min rotation over 7 chains); single-chain providers are the floor. +expected_n: 40 + +ledger_columns: + - { label: "Incidents 24h", slot: p50, unit: count } + - { label: "Head lag p90", slot: p90, unit: count } + - { label: "Head lag p99", slot: p99, unit: count } + +providers: + - slug: publicnode + name: PublicNode + tag: Allnodes-operated; keyless archive depth gated since July 2026 + formula: "Incidents in 24h (eu-west): integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements, summed over its measured chains." + queries: + p50: sum(increase(rpc_integrity_check_total{provider="publicnode",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="publicnode",region="eu-west"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="publicnode",region="eu-west"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="publicnode",region="eu-west"}[24h])) + mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="publicnode",region="eu-west"}[24h])) + success: sum(increase(rpc_integrity_check_total{provider="publicnode",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="publicnode",region="eu-west"}[24h])) + sample_size: sum(increase(rpc_integrity_check_total{provider="publicnode",region="eu-west"}[24h])) + series: sum(increase(rpc_integrity_check_total{provider="publicnode",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="publicnode",region="eu-west"}[1h])) + + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked routing + formula: "Incidents in 24h (eu-west): integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements, summed over its measured chains." + queries: + p50: sum(increase(rpc_integrity_check_total{provider="drpc",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="drpc",region="eu-west"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="drpc",region="eu-west"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="drpc",region="eu-west"}[24h])) + mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="drpc",region="eu-west"}[24h])) + success: sum(increase(rpc_integrity_check_total{provider="drpc",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="drpc",region="eu-west"}[24h])) + sample_size: sum(increase(rpc_integrity_check_total{provider="drpc",region="eu-west"}[24h])) + series: sum(increase(rpc_integrity_check_total{provider="drpc",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="drpc",region="eu-west"}[1h])) + + - slug: tenderly + name: Tenderly + tag: Multi-chain public gateway, no key + formula: "Incidents in 24h (eu-west): integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements, summed over its measured chains." + queries: + p50: sum(increase(rpc_integrity_check_total{provider="tenderly",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="tenderly",region="eu-west"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="tenderly",region="eu-west"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="tenderly",region="eu-west"}[24h])) + mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="tenderly",region="eu-west"}[24h])) + success: sum(increase(rpc_integrity_check_total{provider="tenderly",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="tenderly",region="eu-west"}[24h])) + sample_size: sum(increase(rpc_integrity_check_total{provider="tenderly",region="eu-west"}[24h])) + series: sum(increase(rpc_integrity_check_total{provider="tenderly",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="tenderly",region="eu-west"}[1h])) + + - slug: nodies + name: Nodies + tag: POKT Network's decentralized public RPC successor + formula: "Incidents in 24h (eu-west): integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements, summed over its measured chains." + queries: + p50: sum(increase(rpc_integrity_check_total{provider="nodies",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="nodies",region="eu-west"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="nodies",region="eu-west"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="nodies",region="eu-west"}[24h])) + mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="nodies",region="eu-west"}[24h])) + success: sum(increase(rpc_integrity_check_total{provider="nodies",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="nodies",region="eu-west"}[24h])) + sample_size: sum(increase(rpc_integrity_check_total{provider="nodies",region="eu-west"}[24h])) + series: sum(increase(rpc_integrity_check_total{provider="nodies",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="nodies",region="eu-west"}[1h])) + + - slug: lava + name: Lava + tag: Decentralized RPC mesh (ETH + Arbitrum no-key) + formula: "Incidents in 24h (eu-west) on Ethereum and Arbitrum: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements." + queries: + p50: sum(increase(rpc_integrity_check_total{provider="lava",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="lava",region="eu-west"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="lava",region="eu-west"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="lava",region="eu-west"}[24h])) + mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="lava",region="eu-west"}[24h])) + success: sum(increase(rpc_integrity_check_total{provider="lava",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="lava",region="eu-west"}[24h])) + sample_size: sum(increase(rpc_integrity_check_total{provider="lava",region="eu-west"}[24h])) + series: sum(increase(rpc_integrity_check_total{provider="lava",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="lava",region="eu-west"}[1h])) + + - slug: meowrpc + name: MeowRPC + tag: Free public RPC (ETH + Arbitrum) + formula: "Incidents in 24h (eu-west) on Ethereum and Arbitrum: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements." + queries: + p50: sum(increase(rpc_integrity_check_total{provider="meowrpc",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="meowrpc",region="eu-west"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="meowrpc",region="eu-west"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="meowrpc",region="eu-west"}[24h])) + mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="meowrpc",region="eu-west"}[24h])) + success: sum(increase(rpc_integrity_check_total{provider="meowrpc",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="meowrpc",region="eu-west"}[24h])) + sample_size: sum(increase(rpc_integrity_check_total{provider="meowrpc",region="eu-west"}[24h])) + series: sum(increase(rpc_integrity_check_total{provider="meowrpc",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="meowrpc",region="eu-west"}[1h])) + + - slug: flashbots + name: Flashbots + tag: Private-mempool read proxy, Ethereum only + formula: "Incidents in 24h (eu-west) on Ethereum: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements." + queries: + p50: sum(increase(rpc_integrity_check_total{provider="flashbots",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="flashbots",region="eu-west"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="flashbots",region="eu-west"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="flashbots",region="eu-west"}[24h])) + mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="flashbots",region="eu-west"}[24h])) + success: sum(increase(rpc_integrity_check_total{provider="flashbots",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="flashbots",region="eu-west"}[24h])) + sample_size: sum(increase(rpc_integrity_check_total{provider="flashbots",region="eu-west"}[24h])) + series: sum(increase(rpc_integrity_check_total{provider="flashbots",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="flashbots",region="eu-west"}[1h])) + + - slug: cloudflare + name: Cloudflare + tag: Permissioned mode; the errors-are-signal exhibit + formula: "Incidents in 24h (eu-west) on Ethereum: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements. Blocked methods count as errors." + queries: + p50: sum(increase(rpc_integrity_check_total{provider="cloudflare",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="cloudflare",region="eu-west"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="cloudflare",region="eu-west"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="cloudflare",region="eu-west"}[24h])) + mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="cloudflare",region="eu-west"}[24h])) + success: sum(increase(rpc_integrity_check_total{provider="cloudflare",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="cloudflare",region="eu-west"}[24h])) + sample_size: sum(increase(rpc_integrity_check_total{provider="cloudflare",region="eu-west"}[24h])) + series: sum(increase(rpc_integrity_check_total{provider="cloudflare",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="cloudflare",region="eu-west"}[1h])) + + - slug: base-official + name: Base + tag: Coinbase-operated, Base mainnet only + formula: "Incidents in 24h (eu-west) on Base: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements." + queries: + p50: sum(increase(rpc_integrity_check_total{provider="base-official",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="base-official",region="eu-west"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="base-official",region="eu-west"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="base-official",region="eu-west"}[24h])) + mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="base-official",region="eu-west"}[24h])) + success: sum(increase(rpc_integrity_check_total{provider="base-official",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="base-official",region="eu-west"}[24h])) + sample_size: sum(increase(rpc_integrity_check_total{provider="base-official",region="eu-west"}[24h])) + series: sum(increase(rpc_integrity_check_total{provider="base-official",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="base-official",region="eu-west"}[1h])) + + - slug: arbitrum-official + name: Arbitrum + tag: Arbitrum Foundation RPC, Arbitrum One only + formula: "Incidents in 24h (eu-west) on Arbitrum One: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements." + queries: + p50: sum(increase(rpc_integrity_check_total{provider="arbitrum-official",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="arbitrum-official",region="eu-west"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="arbitrum-official",region="eu-west"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="arbitrum-official",region="eu-west"}[24h])) + mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="arbitrum-official",region="eu-west"}[24h])) + success: sum(increase(rpc_integrity_check_total{provider="arbitrum-official",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="arbitrum-official",region="eu-west"}[24h])) + sample_size: sum(increase(rpc_integrity_check_total{provider="arbitrum-official",region="eu-west"}[24h])) + series: sum(increase(rpc_integrity_check_total{provider="arbitrum-official",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="arbitrum-official",region="eu-west"}[1h])) + + - slug: optimism-official + name: Optimism + tag: Optimism Foundation RPC, OP mainnet only + formula: "Incidents in 24h (eu-west) on Optimism: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements." + queries: + p50: sum(increase(rpc_integrity_check_total{provider="optimism-official",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="optimism-official",region="eu-west"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="optimism-official",region="eu-west"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="optimism-official",region="eu-west"}[24h])) + mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="optimism-official",region="eu-west"}[24h])) + success: sum(increase(rpc_integrity_check_total{provider="optimism-official",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="optimism-official",region="eu-west"}[24h])) + sample_size: sum(increase(rpc_integrity_check_total{provider="optimism-official",region="eu-west"}[24h])) + series: sum(increase(rpc_integrity_check_total{provider="optimism-official",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="optimism-official",region="eu-west"}[1h])) + + - slug: binance + name: Binance + tag: BNB Chain dataseed RPC, BNB only + formula: "Incidents in 24h (eu-west) on BNB Chain: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements." + queries: + p50: sum(increase(rpc_integrity_check_total{provider="binance",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="binance",region="eu-west"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="binance",region="eu-west"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="binance",region="eu-west"}[24h])) + mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="binance",region="eu-west"}[24h])) + success: sum(increase(rpc_integrity_check_total{provider="binance",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="binance",region="eu-west"}[24h])) + sample_size: sum(increase(rpc_integrity_check_total{provider="binance",region="eu-west"}[24h])) + series: sum(increase(rpc_integrity_check_total{provider="binance",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="binance",region="eu-west"}[1h])) diff --git a/benchmarks/solana-rpc.yml b/benchmarks/solana-rpc.yml index 63a32c21..72cf7d9a 100644 --- a/benchmarks/solana-rpc.yml +++ b/benchmarks/solana-rpc.yml @@ -41,7 +41,7 @@ methodology: findings: - "{{best_name}} currently leads free Solana RPC at {{best_p50}} (getSlot p50, 24h) across 5 measured providers." - - "{{name:solana-official}} ({{p50:solana-official}}) is the endpoint every tutorial pastes. Solana Labs documents its limits at 100 requests per 10 seconds per IP, generous for development and exactly the kind of endpoint this bench exists to sanity-check." + - "{{name:solana}} ({{p50:solana}}) is the endpoint every tutorial pastes. Solana Labs documents its limits at 100 requests per 10 seconds per IP, generous for development and exactly the kind of endpoint this bench exists to sanity-check." - "{{name:publicnode}} ({{p50:publicnode}}) extends its EVM cluster footprint to Solana from the same Allnodes infrastructure, and arrives with the credibility of leading a large share of the EVM per-chain boards." - "{{name:lava}} ({{p50:lava}}) routes through the Lava gateway mesh, the same architecture as its Ethereum and Arbitrum endpoints; expect higher variance than single-origin providers." - "{{name:leorpc}} ({{p50:leorpc}}) and {{name:solanavibestation}} ({{p50:solanavibestation}}) are the community tier: smaller operations whose continued keyless availability this bench monitors live rather than assumes." @@ -64,7 +64,7 @@ prometheus: window: 24h freshness_metric: rpc_latency_milliseconds -rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{provider=~"solana-official|publicnode|lava|leorpc|solanavibestation", chain="solana"}) +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{provider=~"solana|publicnode|lava|leorpc|solanavibestation", chain="solana"}) dimensions: region: @@ -74,28 +74,28 @@ dimensions: - { value: sgp, label: Singapore } providers: - - slug: solana-official - name: Solana Labs + - slug: solana + name: Solana tag: Chain-official api.mainnet-beta.solana.com, 100 req per 10s documented formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `getSlot` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to api.mainnet-beta.solana.com." queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solana-official", chain="solana"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="solana-official", chain="solana"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="solana-official", chain="solana"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="solana-official", chain="solana"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="solana-official", chain="solana"}) / sum(ocb:rpc_call:rate_24h{provider="solana-official", chain="solana"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="solana-official", chain="solana"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="solana-official", chain="solana"}[1h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solana", chain="solana"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="solana", chain="solana"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="solana", chain="solana"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="solana", chain="solana"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="solana", chain="solana"}) / sum(ocb:rpc_call:rate_24h{provider="solana", chain="solana"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="solana", chain="solana"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="solana", chain="solana"}[1h])) regions: - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solana-official", chain="solana", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="solana-official", chain="solana", region="us-east"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solana", chain="solana", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="solana", chain="solana", region="us-east"}[1h]) - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solana-official", chain="solana", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="solana-official", chain="solana", region="eu-west"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solana", chain="solana", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="solana", chain="solana", region="eu-west"}[1h]) - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solana-official", chain="solana", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="solana-official", chain="solana", region="sgp"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="solana", chain="solana", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="solana", chain="solana", region="sgp"}[1h]) - slug: publicnode name: PublicNode diff --git a/benchmarks/token-deployment-cost.yml b/benchmarks/token-deployment-cost.yml index 6c00a430..35c5cc40 100644 --- a/benchmarks/token-deployment-cost.yml +++ b/benchmarks/token-deployment-cost.yml @@ -95,6 +95,7 @@ providers: p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="solana"}[24h]) mean: avg_over_time(token_deployment_cost_usd{chain="solana"}[24h]) series: token_deployment_cost_usd{chain="solana"} + sample_size: count_over_time(token_deployment_cost_usd{chain="solana"}[7d]) - slug: sui name: Sui @@ -107,6 +108,7 @@ providers: p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="sui"}[24h]) mean: avg_over_time(token_deployment_cost_usd{chain="sui"}[24h]) series: token_deployment_cost_usd{chain="sui"} + sample_size: count_over_time(token_deployment_cost_usd{chain="sui"}[7d]) - slug: aptos name: Aptos @@ -119,6 +121,7 @@ providers: p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="aptos"}[24h]) mean: avg_over_time(token_deployment_cost_usd{chain="aptos"}[24h]) series: token_deployment_cost_usd{chain="aptos"} + sample_size: count_over_time(token_deployment_cost_usd{chain="aptos"}[7d]) - slug: osmosis name: Osmosis @@ -131,6 +134,7 @@ providers: p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="osmosis"}[24h]) mean: avg_over_time(token_deployment_cost_usd{chain="osmosis"}[24h]) series: token_deployment_cost_usd{chain="osmosis"} + sample_size: count_over_time(token_deployment_cost_usd{chain="osmosis"}[7d]) - slug: injective name: Injective @@ -143,6 +147,7 @@ providers: p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="injective"}[24h]) mean: avg_over_time(token_deployment_cost_usd{chain="injective"}[24h]) series: token_deployment_cost_usd{chain="injective"} + sample_size: count_over_time(token_deployment_cost_usd{chain="injective"}[7d]) - slug: cardano name: Cardano @@ -155,6 +160,7 @@ providers: p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="cardano"}[24h]) mean: avg_over_time(token_deployment_cost_usd{chain="cardano"}[24h]) series: token_deployment_cost_usd{chain="cardano"} + sample_size: count_over_time(token_deployment_cost_usd{chain="cardano"}[7d]) - slug: stellar name: Stellar @@ -167,3 +173,4 @@ providers: p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="stellar"}[24h]) mean: avg_over_time(token_deployment_cost_usd{chain="stellar"}[24h]) series: token_deployment_cost_usd{chain="stellar"} + sample_size: count_over_time(token_deployment_cost_usd{chain="stellar"}[7d]) diff --git a/benchmarks/token-trade-coverage.yml b/benchmarks/token-trade-coverage.yml new file mode 100644 index 00000000..4aabbcc2 --- /dev/null +++ b/benchmarks/token-trade-coverage.yml @@ -0,0 +1,131 @@ +# OpenChainBench. Bench № 090 + +slug: token-trade-coverage +number: "090" +title: Most complete onchain trade data API, live coverage across Solana, EVM and Stellar +seo_title: "Best token trade data API 2026 (Mobula vs Bitquery vs Codex)" +seo_description: "Which onchain data API returns the most complete trade history for a newly launched token? Live coverage % across Mobula, Bitquery and Codex on Solana, Ethereum, BSC, Base and Stellar." +subtitle: For each reference token per chain, we fetch every trade in a fixed measurement window from each provider and compare against the union baseline. Higher percent means the provider returned more of the true trade set, with fewer missed DEXs and fewer dropped fills. +category: Aggregators +status: draft +metric: Trade capture rate +unit: pct +higher_is_better: true + +seo_intro: | + This benchmark measures how much of a token's real trade history each + onchain data API actually returns. For a fixed set of reference tokens + per chain (Solana, Ethereum, BSC, Base, Stellar), the harness fetches + every trade in the same measurement window from Mobula, Bitquery, + Codex, then computes each provider's capture rate against + the union baseline (max count across the three). A rate of 100 means + the provider returned as many trades as the best-covering provider on + that token. A rate of 20 means the provider missed 80 percent of the + swaps that did happen. The comparison is chain-scoped because + coverage is not uniform: a provider strong on Solana DEX indexing can + be weak on niche BSC pools, and no provider covers every chain + equally (Stellar coverage in particular is uneven since only two of + the three measured providers index it at all today). + +abstract: | + Coverage is measured on a fixed set of reference tokens per chain, + querying each provider's public trade endpoint for the same time + window. We take the union of all trades returned across all providers + as the baseline (denominator) and each provider's returned count as + the numerator. Reference tokens are chosen for meaningful trade + volume in the measurement window so a coverage gap actually shows. + Providers use their official public endpoints. Mobula through + /api/2/trades/filters, Bitquery through streaming.bitquery.io + GraphQL, Codex through graph.codex.io GraphQL. + Stellar is measured only for providers that officially list a + Stellar trades endpoint; providers that do not index Stellar at all + are omitted from that chain's row rather than counted as zero. + +methodology: + - "Reference tokens: a fixed set per chain, chosen for enough trade volume in the measurement window that a coverage gap is visible above sampling noise." + - "Measurement window: 60 minutes rolling. The window is the same for every provider in a given cycle so counts are directly comparable." + - "Mobula: `GET https://api.mobula.io/api/2/trades/filters?from={ts_ms}&to={ts_ms}` with `Authorization: `. Cursor pagination, limit=5000." + - "Bitquery: `POST https://streaming.bitquery.io/graphql` with `X-API-KEY`. GraphQL query on `DEXTradeByTokens` with `Block.Time.since` / `till` on the realtime dataset. Hard limit 10000 per call." + - "Codex: `POST https://graph.codex.io/graphql` with `Authorization: `. GraphQL query on `getTokenEvents` with `timestamp` bounds. Cursor pagination." + - "Stellar coverage measured only for providers that ship an official Stellar trades endpoint. Providers with no Stellar support are excluded from the Stellar row rather than counted as zero (that would penalise all three providers unfairly against a chain none of them promised)." + - "Capture rate: `provider_count / max(provider_count across all providers on that (chain, token)) * 100`. Union baseline is the largest observed count, not a synthetic ground truth." + - "Cadence: every 30 minutes per (provider, chain, token). One measurement is short (a handful of API calls per token) so the full cycle finishes well inside the 30-minute window." + - "Failure handling: HTTP timeouts and 5xx count as zero trades for that cycle. Persistent 5xx (three cycles in a row) surfaces as `unresponsive` on the leaderboard, not silent zeros." + +findings: + - "{{best_name}} currently leads at {{best_p50}} average trade capture across {{count}} measured providers. The union baseline updates every cycle: as one provider extends coverage the ceiling moves and the others' relative capture rates shift." + - "{{name:mobula}} averages {{p50:mobula}}. Mobula's advantage on this bench is DEX breadth. Coverage of pump.fun, meme AMMs and small aggregator DEXs is what drives the number up on new-launch Solana tokens where competitors often index only Raydium or Meteora." + - "{{name:bitquery}} averages {{p50:bitquery}}. Bitquery ships strong Solana and EVM coverage through its `DEXTradeByTokens` GraphQL, but the 10000-row hard cap on a single query truncates high-volume tokens in the first hour after launch; the harness paginates when possible, but not every trade type is paginable." + - "{{name:codex}} averages {{p50:codex}}. Codex (formerly Defined) prioritises depth on EVM and Solana DEX event indexing, which shows here as strong per-chain coverage on the chains they index and no coverage at all on Stellar." + - "Stellar coverage is uneven: only providers that officially list a Stellar trades endpoint appear on that chain's row. This is the honest way to represent the current state of the market. Mobula added Stellar recently, others have not shipped it yet." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/token-trade-coverage + +prometheus: + window: 24h + expected_freshness_seconds: 3600 + +faq: + - q: "Which crypto data API returns the most complete trade history for a token?" + a: "{{best_name}} currently leads at {{best_p50}} average trade capture across {{count}} measured providers. This is measured per (chain, token) and averaged, so the leaderboard reflects real coverage across a mixed sample rather than a single lucky token." + - q: "What does 'capture rate' actually mean here?" + a: "For each reference token on each chain, the harness fetches trades from every provider over the same time window. The largest count across providers is the baseline (union of what's discoverable through public APIs). Each provider's rate is its own count divided by that baseline, expressed as a percent. A rate of 100 on a token means the provider returned as many trades as the best-covering provider. A rate of 40 means it missed 60 percent of the swaps the best-covering provider found." + - q: "Why not use a canonical onchain ground truth from RPC nodes?" + a: "In principle you could walk the block explorer and reconstruct every swap. In practice the ground truth depends on which DEX programs, protocols and vault contracts you include, and that decision is itself opinionated. The union-of-providers baseline is a defensible proxy that mirrors what a trader integrating a public API can actually see. It undercounts the truth uniformly across providers, so relative ranking is preserved even if absolute completeness is not 100." + - q: "Which chains are covered?" + a: "Solana, Ethereum, BSC, Base and Stellar. Every provider is measured on each chain it officially supports. Stellar is included because Mobula added Stellar trades to its API recently; other providers that do not ship a Stellar endpoint are excluded from the Stellar row rather than counted as zero." + +dimensions: + chain: + - value: all + label: All chains + - value: solana + label: Solana + - value: ethereum + label: Ethereum + - value: bsc + label: BNB Chain + - value: base + label: Base + - value: stellar + label: Stellar + +providers: + - slug: mobula + name: Mobula + tag: /api/2/trades/filters + formula: "Trades returned by Mobula /api/2/trades/filters divided by union-across-providers count on the same (chain, token) in a rolling 60 min window." + queries: + p50: avg_over_time(ocb_token_trade_capture_pct{provider="mobula"}[24h]) + p90: quantile_over_time(0.90, ocb_token_trade_capture_pct{provider="mobula"}[24h]) + p99: quantile_over_time(0.99, ocb_token_trade_capture_pct{provider="mobula"}[24h]) + mean: avg_over_time(ocb_token_trade_capture_pct{provider="mobula"}[24h]) + success: clamp_max(avg_over_time(ocb_token_trade_probe_ok{provider="mobula"}[24h]), 1) + sample_size: count_over_time(ocb_token_trade_capture_pct{provider="mobula"}[24h]) + series: ocb_token_trade_capture_pct{provider="mobula"} + + - slug: bitquery + name: Bitquery + tag: streaming.bitquery.io GraphQL + formula: "Trades returned by Bitquery DEXTradeByTokens (realtime dataset) divided by union-across-providers count on the same (chain, token) in a rolling 60 min window." + queries: + p50: avg_over_time(ocb_token_trade_capture_pct{provider="bitquery"}[24h]) + p90: quantile_over_time(0.90, ocb_token_trade_capture_pct{provider="bitquery"}[24h]) + p99: quantile_over_time(0.99, ocb_token_trade_capture_pct{provider="bitquery"}[24h]) + mean: avg_over_time(ocb_token_trade_capture_pct{provider="bitquery"}[24h]) + success: clamp_max(avg_over_time(ocb_token_trade_probe_ok{provider="bitquery"}[24h]), 1) + sample_size: count_over_time(ocb_token_trade_capture_pct{provider="bitquery"}[24h]) + series: ocb_token_trade_capture_pct{provider="bitquery"} + + - slug: codex + name: Codex + tag: graph.codex.io GraphQL + formula: "Trades returned by Codex getTokenEvents divided by union-across-providers on the same (chain, token) in a rolling 60 min window. Excluded from Stellar row." + queries: + p50: avg_over_time(ocb_token_trade_capture_pct{provider="codex"}[24h]) + p90: quantile_over_time(0.90, ocb_token_trade_capture_pct{provider="codex"}[24h]) + p99: quantile_over_time(0.99, ocb_token_trade_capture_pct{provider="codex"}[24h]) + mean: avg_over_time(ocb_token_trade_capture_pct{provider="codex"}[24h]) + success: clamp_max(avg_over_time(ocb_token_trade_probe_ok{provider="codex"}[24h]), 1) + sample_size: count_over_time(ocb_token_trade_capture_pct{provider="codex"}[24h]) + series: ocb_token_trade_capture_pct{provider="codex"} diff --git a/benchmarks/tokenized-stock-peg.yml b/benchmarks/tokenized-stock-peg.yml new file mode 100644 index 00000000..dfb89752 --- /dev/null +++ b/benchmarks/tokenized-stock-peg.yml @@ -0,0 +1,222 @@ +# OpenChainBench. Bench № 076 + +slug: tokenized-stock-peg +number: "076" +title: Tokenized stock price accuracy, live onchain vs Nasdaq across 11 equities +seo_title: "Tokenized stock price tracker 2026" +seo_description: "Do tokenized stocks track the real market? AAPL, TSLA, NVDA and 8 more on Robinhood Chain vs their Nasdaq price, deviation in bps, live and keyless." +subtitle: "Absolute deviation between each tokenized equity's Uniswap v4 pool price on Robinhood Chain and its real market reference price, in basis points, labeled by market session. The only live public measurement of whether onchain stocks actually track their underlying." + +category: RWA +status: live +metric: Price deviation +unit: bps +higher_is_better: false + +seo_intro: | + Robinhood Chain tokenizes ~95 equities, and its Uniswap v4 pools trade + them 24/7 while the Nasdaq closes every evening and every weekend. + This page answers the question that setup begs. how far does the + onchain price drift from the real one. Every 60 seconds we read each + pool's spot price straight from the chain (StateView getSlot0, keyless) + and compare it to the live reference price, labeling every sample with + the market session it was taken in (pre, regular, post, closed). The + regular-hours deviation is the headline ranking; the closed-state + series is the weekend drift nobody else publishes: what AAPL is worth + onchain on a Saturday when no market maker has a reference to arb + against. Of the ~95 tokenized equities only 14 have pools with real + liquidity; we measure the 11 with a listed underlying. SpaceX trades + onchain too, but SpaceX is not listed anywhere, so there is no + reference price to measure against, which says something about + tokenized private equity all by itself. + +abstract: | + Every 60 seconds the harness reads the Uniswap v4 spot price of 11 + tokenized equities on Robinhood Chain (one batched eth_call to the + StateView contract, keyless) and fetches the reference equity price + from Yahoo Finance (one batched spark call). Deviation is + 10000 x |pool - reference| / reference, in basis points, published + with a market_state label derived from Yahoo's holiday-aware session + windows. No API keys, no transactions, both legs reproducible from + public endpoints. + +methodology: + - "Onchain leg: one JSON-RPC batch per tick to StateView (0xF3334192D15450CdD385c8B70e03f9A6bD9E673b) getSlot0(poolId) on the Uniswap v4 PoolManager singleton, keyless against the official Robinhood Chain RPC. sqrtPriceX96 converts to USDG per share with the per-pool currency ordering and the USDG 6 vs stock 18 decimal gap (factor 1e12)." + - "Reference leg: Yahoo Finance v8 spark batch, one call for all 11 symbols per tick, browser User-Agent, no key. regularMarketPrice doubles as the last-close reference when the market is closed, which is exactly the weekend baseline." + - "Market session labels: derived from Yahoo currentTradingPeriod epochs (pre / regular / post / closed), which Yahoo publishes holiday-aware, so the harness maintains no NYSE calendar. The headline ranking pins market_state=\"regular\"; the closed series is the drift panel." + - "Deviation: 10000 x |pool_price - reference_price| / reference_price, sampled every 60 seconds, quantiles over 24h via quantile_over_time." + - "Cohort: the 11 official tokenized equities (name pattern * Robinhood Token, shared verified Stock implementation) whose USDG pool has real liquidity and swap history: NVDA, AAPL, GOOGL, TSLA, PLTR, META, AMD, MSFT, AMZN, SPY, MU. Pool fees range 0.3% to 2%." + - "Excluded, with reasons: SPCX (SpaceX pool holds ~$196k but SpaceX is not listed, no reference price exists), SNDK and QQQ (pool depth under $2k, deviation would be noise), CRCL (pool at zero liquidity), ~80 other official tokens (issuer-seeded placeholder pools at 90-95% fee, zero swaps), HOOD (never issued onchain, all HOOD tokens on the chain are third-party spam)." + - "Thin liquidity is part of the story, not a defect: pool depths run $24k to $330k, so a single mid-size swap can move the onchain price meaningfully off the reference until arbitrage closes it. The bench measures how fast that closure actually happens." + - "Reading grid: an AMM price can sit anywhere inside the pool's fee band without creating an arbitrage opportunity, so a 2% fee pool resting 150 bps off its reference is economically at equilibrium, not broken. Cross-symbol ranking therefore partly reflects each pool's fee tier (disclosed in every tag); the within-symbol trend over time is the purest signal." + - "Quote-asset caveat: pool prices are denominated in USDG and compared against USD references. A USDG peg wobble would appear as a correlated deviation across all 11 symbols simultaneously, which is the signature to check before reading a broad move as tracking error." + - "Caveats: the issuer can pause or blocklist a token (the harness drops the sample and lets the series age out rather than freezing), and the Stock contract carries a split multiplier; a corporate action can look like a one-day deviation spike until reconciled." + +findings: + - "{{best_name}} tracks its reference tightest at {{best_p50}} (p50, 24h) across {{count}} measured tokenized equities." + - "{{name:aapl}} deviates {{p50:aapl}} (p50, 24h) with roughly $330k of pool depth, the deepest tokenized-stock pool on the chain alongside {{name:nvda}} ({{p50:nvda}}, ~$290k)." + - "{{name:tsla}} ({{p50:tsla}}) has the most active pool of the cohort by swap count, which is what keeps an AMM price honest between arbitrage passes." + - "The spread between the regular-hours deviation and the closed-market drift is the number this bench exists for: pools keep trading nights and weekends with no reference to arb against, and the drift until Monday open is measurable here, live." + - "Of the ~95 equities Robinhood tokenized, only 14 have a pool anyone actually trades. The other 80 sit in issuer-seeded placeholder pools at 90-95% fees with zero swaps. Tokenization is easy; markets are hard." + +faq: + - q: "Do tokenized stocks track their real market price?" + a: "During regular Nasdaq hours, mostly yes: arbitrageurs keep the Uniswap v4 pools within tens of basis points of the reference on the liquid names, and this page shows the live per-symbol number. Outside market hours the tether loosens, the pools keep trading around the clock with no authoritative reference, and the closed-session drift measured here is the honest answer to how far a 24/7 stock wanders from its 9:30-to-4 anchor." + - q: "What happens to tokenized stock prices on weekends?" + a: "The pools trade continuously, so the onchain price becomes pure supply and demand against the Friday close. This bench samples that drift every minute all weekend and publishes it as the closed-session series. Monday pre-market usually snaps it back; the interesting data is how far it got before that." + - q: "Why only 11 stocks when Robinhood tokenized about 95?" + a: "Because only 14 of the 95 have a USDG pool with real liquidity and swap activity, and 3 of those are unmeasurable: SpaceX has no listed reference price, and SNDK plus QQQ hold under $2k of depth. The other 80 tokens sit in placeholder pools at 90-95% fees that nobody has ever traded. The gap between tokens issued and markets that exist is one of this page's findings." + - q: "How is the onchain price read?" + a: "One batched keyless eth_call per minute to the StateView contract of the Uniswap v4 singleton on Robinhood Chain, decoding sqrtPriceX96 from getSlot0 for each pool and converting with the pool's currency ordering and the 6-vs-18 decimals gap. No indexer, no API key, reproducible with curl." + - q: "Where does the reference price come from?" + a: "Yahoo Finance's public chart API, one batched call per minute with the session windows read from currentTradingPeriod, which is holiday-aware. When the market is closed the reference is the last regular close, which is exactly the baseline the weekend drift should be measured against." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/tokenized-stock-peg + +prometheus: + window: 24h + freshness_metric: tsp_deviation_bps + +# Metrics emitted by the tokenized-stock-peg harness: +# tsp_deviation_bps{asset, market_state} gauge, abs deviation in bps +# tsp_price_onchain_usdg{asset} gauge, v4 pool spot +# tsp_price_reference_usd{asset} gauge, yahoo reference +# tsp_reference_age_seconds{asset} gauge +# tsp_market_session{market_state} gauge, active session flag +# tsp_health{asset} gauge +# tsp_source_call_total{source, result} counter +# tsp_source_latency_milliseconds{source} gauge + +providers: + - slug: nvda + name: NVDA + tag: "Nvidia, ~$290k pool depth, 0.3% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the NVDA Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="nvda"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"} + - slug: aapl + name: AAPL + tag: "Apple, ~$330k pool depth, 1% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the AAPL Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="aapl"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"} + - slug: googl + name: GOOGL + tag: "Alphabet, ~$280k pool depth, 1% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the GOOGL Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="googl"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"} + - slug: tsla + name: TSLA + tag: "Tesla, most active pool of the cohort, 0.3% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the TSLA Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="tsla"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"} + - slug: pltr + name: PLTR + tag: "Palantir, ~$31k pool depth, 1% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the PLTR Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="pltr"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"} + - slug: meta + name: META + tag: "Meta, ~$89k pool depth, 0.3% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the META Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="meta"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"} + - slug: amd + name: AMD + tag: "AMD, ~$25k pool depth, 1% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the AMD Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="amd"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"} + - slug: msft + name: MSFT + tag: "Microsoft, ~$50k pool depth, 2% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the MSFT Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="msft"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"} + - slug: amzn + name: AMZN + tag: "Amazon, ~$24k pool depth, 2% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the AMZN Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="amzn"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"} + - slug: spy + name: SPY + tag: "S&P 500 ETF, ~$65k pool depth, 1% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the SPY Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="spy"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"} + - slug: mu + name: MU + tag: "Micron, ~$35k pool depth, 1% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the MU Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="mu"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"} diff --git a/benchmarks/tokenized-stock-weekend-drift.yml b/benchmarks/tokenized-stock-weekend-drift.yml new file mode 100644 index 00000000..95a4699d --- /dev/null +++ b/benchmarks/tokenized-stock-weekend-drift.yml @@ -0,0 +1,210 @@ +# OpenChainBench. Bench № 079 + +slug: tokenized-stock-weekend-drift +number: "079" +title: Tokenized stock weekend drift, how far each equity wanders Friday to Monday +seo_title: "Tokenized stock weekend drift 2026" +seo_description: "How far do onchain tokenized stocks drift from Friday close during the weekend? AAPL, TSLA, NVDA and 8 more measured live on Robinhood Chain, in bps." +subtitle: "Maximum deviation between each tokenized stock's pool price and the last Nasdaq close, measured every minute across the 60-hour closed-market window from Friday 21:00 UTC to Monday 13:30 UTC." + +category: RWA +status: live +metric: Weekend max drift +unit: bps +higher_is_better: false + +seo_intro: | + Nasdaq closes every Friday at 4 pm ET and reopens Monday at 9:30 am, + a 60 hour window with no reference price. The AMM pools on + Robinhood Chain keep trading 24/7 anyway. This page measures how + far the onchain price of each tokenized stock actually wanders + during those 60 hours before the Monday open snaps it back. Same + data pipeline as the tokenized stock peg benchmark, filtered to the + closed session and aggregated per weekend. The interesting rows are + the thin pools: MSFT and AMZN drift multiple hundreds of basis + points on a quiet Sunday because no arbitrageur has a reference to + arb against until the bell rings on Monday. The chart nobody else + publishes. + +abstract: | + Every 60 seconds during the closed market session, the harness + records the deviation between the Uniswap v4 pool price on + Robinhood Chain and the last Yahoo regular-hours close. The + weekend metric is the maximum of that deviation over the full + 60-hour Friday-close-to-Monday-open window, per symbol. Purely + derived from the tokenized-stock-peg series already in Prometheus, + no new probe. + +methodology: + - "Data source: the tsp_deviation_bps series emitted by the tokenized-stock-peg harness with the label market_state=\"closed\". No new probe; the metric is a recording rule on existing samples." + - "Session windows come from Yahoo currentTradingPeriod (holiday aware), which the harness already labels; the closed state runs from post-market close (roughly 21:00 UTC) to the next regular open (roughly 13:30 UTC)." + - "Weekend max drift: max_over_time(tsp_deviation_bps{issuer=\"robinhood\", market_state=\"closed\"}[72h]), aggregated per calendar weekend and labeled with the ISO week. The 72h window covers all pre-market and after-hours slots on either side of the weekend, not just the 60h weekend gap." + - "Cohort: the 11 tokenized equities measured by the tokenized stock peg bench (NVDA, AAPL, GOOGL, TSLA, PLTR, META, AMD, MSFT, AMZN, SPY, MU). Same pool addresses, same reference, same fee tiers." + - "The measurement is a bound, not a fair value: an AMM can sit anywhere inside its fee band without creating arbitrage, so weekend drift on a 2 percent fee pool starts at 200 bps by construction. Read the drift number alongside the pool fee, disclosed on the tokenized-stock-peg page." + - "Not a claim about corporate fair value: the reference is Friday close, not the theoretical opening indication. When earnings, dividends or macro events happen on the weekend, Monday open will differ from Friday close by the same amount whether the pool moved or not." + +findings: + - "{{best_name}} held tightest last weekend at {{best_p50}} (max drift, bps) across {{count}} tokenized equities." + - "{{name:tsla}} ({{p50:tsla}}) has the most active pool of the cohort, and its weekend drift is the closest read on what a 24/7 stock is worth on a Sunday when nobody has a reference to arb against." + - "The largest weekend drifts come from the thin-pool symbols (MU, AMD, PLTR, MSFT, AMZN, NVDA) sitting on the 2 percent fee tier at pool depths in the low tens of thousands. That is not a peg failure, it is the arithmetic of a wide fee band with no arbitrageur reference during the 60 hour Nasdaq close." + - "Every reading resets at Monday open. The page is the only place that captures the intra-weekend maximum before the reset happens." + +faq: + - q: "What is the weekend drift on Robinhood Chain tokenized stocks?" + a: "It is the largest gap between the pool price and Friday's Nasdaq close observed anywhere in the 60 hour weekend, per symbol, measured every 60 seconds. Nasdaq is closed all weekend but the AMM keeps trading, and this page shows how far apart the two go before the Monday open snaps them together again." + - q: "Why do pools drift when nobody is trading?" + a: "Some pools do get traded on the weekend, and even a moderate imbalance moves the price meaningfully on a thin depth. But even zero-volume pools can drift on paper if a single one-sided quote lands: the price is the last swap, and there is no reference to arb against until Monday open. That is what the number here is capturing." + - q: "Is a high drift bad?" + a: "It reads as inefficient markets on paper, but during the 60 hour Nasdaq close there is no fair value to converge to, and every drift closes at the next open. The number matters most for downstream products (lending, structured payoffs) that use the tokenized price as an oracle when the real market is closed." + - q: "Which stocks drift the most on weekends?" + a: "The pattern is fee tier times pool depth. On the current cohort, MU, AMD, PLTR (2 percent fee tier, sub 50k depth) hit the widest weekend drifts, sometimes several hundred basis points. GOOGL, SPY and TSLA (0.3 percent fee tier, several hundred thousand of depth) drift the least." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/tokenized-stock-peg + +prometheus: + window: 24h + freshness_metric: tsp_deviation_bps + +providers: + - slug: nvda + name: NVDA + tag: "Nvidia, ~$290k pool depth, 0.3% fee" + formula: "Maximum deviation between the NVDA pool on Robinhood Chain and Friday's Nasdaq close, over the closed-market window, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="nvda"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[7d]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[72h]) + + - slug: aapl + name: AAPL + tag: "Apple, ~$330k pool depth, 1% fee" + formula: "Maximum deviation between the AAPL pool on Robinhood Chain and Friday's Nasdaq close, over the closed-market window, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="aapl"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[7d]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[72h]) + + - slug: googl + name: GOOGL + tag: "Alphabet, ~$280k pool depth, 1% fee" + formula: "Maximum deviation between the GOOGL pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="googl"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[7d]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[72h]) + + - slug: tsla + name: TSLA + tag: "Tesla, most active pool of the cohort, 0.3% fee" + formula: "Maximum deviation between the TSLA pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="tsla"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[7d]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[72h]) + + - slug: msft + name: MSFT + tag: "Microsoft, ~$50k pool depth, 2% fee" + formula: "Maximum deviation between the MSFT pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="msft"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[7d]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[72h]) + + - slug: amzn + name: AMZN + tag: "Amazon, ~$24k pool depth, 2% fee" + formula: "Maximum deviation between the AMZN pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="amzn"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[7d]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[72h]) + + - slug: meta + name: META + tag: "Meta, ~$89k pool depth, 0.3% fee" + formula: "Maximum deviation between the META pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="meta"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[7d]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[72h]) + + - slug: amd + name: AMD + tag: "AMD, ~$25k pool depth, 1% fee" + formula: "Maximum deviation between the AMD pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="amd"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[7d]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[72h]) + + - slug: pltr + name: PLTR + tag: "Palantir, ~$31k pool depth, 1% fee" + formula: "Maximum deviation between the PLTR pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="pltr"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[7d]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[72h]) + + - slug: spy + name: SPY + tag: "S&P 500 ETF, ~$65k pool depth, 1% fee" + formula: "Maximum deviation between the SPY pool and Friday's NYSE close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="spy"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[7d]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[72h]) + + - slug: mu + name: MU + tag: "Micron, ~$35k pool depth, 1% fee" + formula: "Maximum deviation between the MU pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="mu"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[7d]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[72h]) diff --git a/benchmarks/tron-rpc.yml b/benchmarks/tron-rpc.yml new file mode 100644 index 00000000..627b2521 --- /dev/null +++ b/benchmarks/tron-rpc.yml @@ -0,0 +1,163 @@ +# OpenChainBench. Bench № 093 + +slug: tron-rpc +number: "093" +title: Fastest free TRON RPC, live no-key JSON-RPC endpoint latency +seo_title: "Fastest free TRON RPC 2026" +seo_description: "{{best_name}} leads free TRON RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 3 no-key JSON-RPC-compat providers measured every 60s from 3 regions." +subtitle: HTTP round-trip latency for `eth_getBlockByNumber("latest", false)` against every free, no-key public TRON JSON-RPC-compatible endpoint, audited every 60 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + TRON exposes two RPC surfaces: the native Tron HTTP API + (`wallet/getnowblock`, `wallet/triggersmartcontract`) and an + EVM-compatible JSON-RPC endpoint (`/jsonrpc`) that speaks standard + Ethereum method names (`eth_blockNumber`, `eth_getBlockByNumber`, + `eth_getBalance`). This bench measures the JSON-RPC surface because + that is the path every TRON-compatible EVM wallet, TronWeb bridge + and cross-chain bot integrates against. Provider cohort at launch: + TronGrid (Tron Foundation official), dRPC and PublicNode / Allnodes. + Ankr's JSON-RPC path is keyless in branding but rejected our probes + with `API key is not allowed to access blockchain`; Chainstack, + NOWNodes, GetBlock, Tatum and BlockPI require API keys on the TRON + JSON-RPC path; OnFinality and Blast API have no public TRON + JSON-RPC gateway at this time. The native TRON REST API surface + (used by TronWeb + TronBox) is out of scope for the RPC cluster + and would need a chain-specific probe added. + +abstract: | + Per-chain member of the RPC latency cluster, extended to the TRON + JSON-RPC-compatible surface. We measure the round-trip latency of a + single, identical JSON-RPC call + (`eth_getBlockByNumber("latest", false)`) against every no-key + public TRON JSON-RPC endpoint that sustains continuous probing, + 3 providers at launch, every 60 seconds, from us-east, eu-west and + Singapore. The harness classifies every response + (ok / http_err / jsonrpc_err / stale / timeout) with the EVM + staleness gap tuned for TRON's ~3 s block time (25 blocks ≈ 75 + seconds). The cross-chain view lives on the parent `rpc-capabilities` + benchmark; this page is the TRON-scoped answer with per-region + breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 60 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\", false]}`. Plain HTTP POST, identical for every endpoint, no API key in any request. Non-cacheable by design: the rotating id defeats edge caches that would answer a bare head query without touching a node." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result with a parsable hex block number), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 25 blocks behind the cross-provider tip, roughly 75 seconds at TRON's ~3 s block time), `timeout`. Latency without reliability is a misleading ranking signal." + - "Surface scope: only the EVM-compatible JSON-RPC surface (`/jsonrpc` endpoints) is measured, because that is the path every cross-chain wallet, TronWeb / EVM bridge and arbitrage bot integrates against. The native TRON REST API (`wallet/getnowblock`, `wallet/triggersmartcontract`) is out of scope for this bench; a chain-specific `tron-rest` bench can be added later when there is clear demand from integrators using TronWeb directly." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to `chain=\"tron\"`. Provider coverage at launch: 3 no-key JSON-RPC endpoints (TronGrid, dRPC, PublicNode). The TRON JSON-RPC provider market is materially smaller than the EVM one; most TRON infra vendors expose only the native TRON REST API keyless, and gate the JSON-RPC path behind an API key." + - "Excluded by the launch audit: Ankr (`rpc.ankr.com/tron_jsonrpc` returned `API key is not allowed to access blockchain` without a key), Chainstack / NOWNodes / GetBlock / Tatum / BlockPI (all require an API key on the TRON JSON-RPC path even though branded as public), OnFinality + Blast API + AllThatNode (no public TRON JSON-RPC gateway), Tron Foundation's `rpc.trongrid.io` (DNS not resolving; the correct official host is `api.trongrid.io/jsonrpc`)." + +findings: + - "{{best_name}} currently leads free TRON RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h) across 3 measured providers." + - "{{name:trongrid}} is Tron Foundation's official public API gateway; the bench measures the same `api.trongrid.io/jsonrpc` endpoint that every TronWeb + tronbox integrator points at first. Latency here reflects what a keyless TRON-EVM integrator sees end-to-end." + - "{{name:drpc}} routes TRON JSON-RPC through its multi-provider load balancer; latency here is a good proxy for what a cross-chain integrator (multi-chain wallets, bridges) can expect from a load-balanced setup." + - "{{name:publicnode}} extends its universal multi-chain footprint to TRON JSON-RPC without a keyed path; the reliability column matters as much as p50 since TRON JSON-RPC compat is a thin layer over the native TRON API and can drift under load." + +faq: + - q: "What is the fastest free TRON RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (eth_getBlockByNumber p50 over the last 24h), measured against 3 no-key JSON-RPC providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which TRON RPCs work without an API key?" + a: "Three JSON-RPC endpoints sustain continuous keyless probing at launch: TronGrid (`api.trongrid.io/jsonrpc`), dRPC (`tron.drpc.org`) and PublicNode / Allnodes (`tron.publicnode.com/jsonrpc`). Every listed endpoint was live-verified with an `eth_getBlockByNumber` POST returning a parsable hex head before inclusion. Excluded by the audit: Ankr's TRON JSON-RPC path rejects keyless probes with `API key is not allowed to access blockchain`; Chainstack, NOWNodes, GetBlock, Tatum and BlockPI require an API key on the TRON JSON-RPC path even though branded as public; OnFinality, Blast API and AllThatNode have no public TRON JSON-RPC gateway. The TRON JSON-RPC provider market is materially smaller than the EVM one , most TRON infra vendors expose only the native TRON REST API keyless." + - q: "Why only 3 providers when other RPC benches have 5+?" + a: "TRON's JSON-RPC compat layer is thinner than the EVM one, and most infra vendors gate it behind an API key even when their native TRON REST endpoints are keyless. The 3 providers on this page are all keyless endpoints that responded correctly to a plain POST during the launch audit; when other vendors open a keyless JSON-RPC path we will add them. Latency and reliability numbers here are still meaningful because the 3 providers span a Foundation-official endpoint, a multi-provider aggregator (dRPC) and a universal multi-chain gateway (PublicNode) , the three infra archetypes an integrator would compare against each other anyway." + - q: "Does this bench cover the native TRON REST API (`wallet/getnowblock`)?" + a: "No. This bench scopes only the EVM-compatible JSON-RPC surface (`/jsonrpc` endpoints), because that is the path cross-chain wallets, TronWeb bridges, and arbitrage bots integrate against. The native TRON REST API surface (used by TronWeb + TronBox directly) has different method semantics (address encoding, resource model, `wallet/*` vs `walletsolidity/*` paths) and would need a chain-specific probe added. That will land as a separate `tron-rest` bench when the demand from integrators using TronWeb directly is clear." + - q: "Does the fastest TRON RPC change by region?" + a: "Often. TRON public infra concentrates operations in specific regions; a gateway that wins from Amsterdam can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is TRON RPC latency measured here, technically?" + a: "One identical JSON-RPC POST (`eth_getBlockByNumber(\"latest\", false)`) every 60 seconds against each provider from each of 3 regions, using the same plain HTTP client the rest of the EVM cluster uses. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="tron"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: trongrid + name: TronGrid + tag: Tron Foundation's official public API gateway + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to TronGrid's no-key TRON JSON-RPC endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="trongrid", chain="tron"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="trongrid", chain="tron"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="trongrid", chain="tron"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="trongrid", chain="tron"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="trongrid", chain="tron"}) / sum(ocb:rpc_call:rate_24h{provider="trongrid", chain="tron"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="trongrid", chain="tron"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="trongrid", chain="tron"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="trongrid", chain="tron", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="trongrid", chain="tron", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="trongrid", chain="tron", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="trongrid", chain="tron", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="trongrid", chain="tron", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="trongrid", chain="tron", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Multi-provider RPC load balancer, edge-close routing + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key TRON JSON-RPC endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="tron"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="tron"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="tron"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="tron"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="tron"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="tron"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="tron"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="tron"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="tron", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="tron", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="tron", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="tron", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="tron", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="tron", region="sgp"}[1h]) + + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, 70+ chains, keyless TRON JSON-RPC path + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key TRON JSON-RPC endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="tron"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="tron"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="tron"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="tron"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="tron"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="tron"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="tron"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="tron"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="tron", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="tron", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="tron", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="tron", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="tron", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="tron", region="sgp"}[1h]) diff --git a/benchmarks/usdy-nav-basis.yml b/benchmarks/usdy-nav-basis.yml new file mode 100644 index 00000000..581ceb1d --- /dev/null +++ b/benchmarks/usdy-nav-basis.yml @@ -0,0 +1,92 @@ +# OpenChainBench. Bench № 078 + +slug: usdy-nav-basis +number: "078" +title: USDY NAV basis, live market price vs official redemption rate +seo_title: "USDY price vs NAV 2026" +seo_description: "Does USDY trade at its NAV? Live basis between Ondo's tokenized treasury market price (Orca, Pyth) and its official redemption rate, in bps, keyless." +subtitle: "Signed basis between USDY's onchain market price and the official Ondo redemption rate published on Pyth, in basis points. Tokenized treasuries are the largest RWA segment; this is the live answer to whether the market actually prices the NAV." + +category: RWA +status: live +metric: NAV basis +unit: bps +higher_is_better: false + +seo_intro: | + USDY is Ondo's yield-bearing tokenized treasury note, one of the + largest RWA tokens by float. Its official value is the redemption + rate Ondo publishes (the NAV a redeeming holder receives), but its + price on the open market is whatever the pools say. This page + measures the gap live: the Orca USDY/USDC whirlpool on Solana (the + deepest genuine venue, about $2.9M) and the Pyth market composite, + each against the Pyth redemption rate feed, every 60 seconds, + keyless on every leg. A persistent negative basis means the market + discounts the NAV (liquidity preference, exit friction); a positive + one means buyers pay a premium over redemption value. Most tokenized + treasuries (OUSG, BUIDL, BENJI) are transfer-restricted and never + trade on open pools, so their NAV can never be market-tested; USDY + is the rare one where the question is measurable at all. + +abstract: | + Every 60 seconds the harness reads the Ondo redemption rate and the + Pyth USDY/USD market composite from one Hermes call, and the Orca + USDY/USDC whirlpool price from one Solana getAccountInfo (sqrtPrice + decoded from the account bytes). Basis = (market - NAV) / NAV in + signed basis points per venue. No keys, no transactions. + +methodology: + - "NAV leg: the Pyth Hermes feed Crypto.USDY/USD.RR (the redemption rate Ondo publishes onchain), fetched keyless. The same Hermes call carries the Pyth USDY/USD market composite, which doubles as a venue row." + - "Market leg, Orca: the USDY/USDC whirlpool on Solana (~$2.9M, the deepest genuine USDY pool anywhere), price decoded straight from getAccountInfo bytes (sqrtPrice u128 at offset 65, both mints 6 decimals). Keyless against a public RPC." + - "Basis: signed, (market - NAV) / NAV x 10000. Negative = the market discounts the NAV. The ranking sorts by absolute basis; the sign is the story and both are displayed." + - "Excluded, verified 2026-07-13: the Arbitrum Camelot USDY/USDC pool holds 232 USDY against 7M USDC, effectively drained, its stale price sitting ~345bps under NAV with near zero volume. Kept out of the ranking as the textbook example of why pool depth gates peg quality; revisited monthly." + - "Also not measurable, disclosed: OUSG, BUIDL and BENJI are transfer-restricted mint/redeem instruments with no genuine open pools, so no market test of their NAV exists to publish." + - "USDY is yield accruing: the redemption rate rises daily, so a naive USD peg comparison would show permanent drift. Comparing against the live RR feed removes that by construction." + +findings: + - "{{best_name}} trades closest to NAV at {{best_p50}} (p50 absolute basis, 24h) across {{count}} measured venues." + - "{{name:orca-solana}} ({{p50:orca-solana}}) is the deepest genuine USDY venue anywhere at about $2.9M; its basis is the closest thing to a market verdict on Ondo's published NAV." + - "{{name:pyth-market}} ({{p50:pyth-market}}) aggregates offchain and onchain USDY trading into one composite; its spread against the RR feed is the cleanest single number for the NAV discount." + - "The excluded Camelot pool on Arbitrum is the finding that did not make the table: drained to 232 USDY, price frozen ~345bps under NAV. Depth is not a detail in RWA pricing, it is the whole game." + +faq: + - q: "Does USDY trade at its NAV?" + a: "Close to it, with a measurable basis that this page tracks live. At verification the Orca pool sat within a few bps of the redemption rate and the Pyth market composite about 10bps under. A persistent discount reflects exit friction and liquidity preference, not a broken product; the point is that it is now measured instead of assumed." + - q: "Why only two venues?" + a: "Because genuine USDY liquidity is rarer than the token's float suggests. The Arbitrum Camelot pool is drained and excluded (232 USDY left, price frozen well under NAV), and most other tokenized treasuries never trade openly at all. Two honest venues beat five misleading ones." + - q: "What does a negative basis mean?" + a: "The market prices USDY under its redemption value. Redeeming through Ondo takes time and has minimums, so sellers who want out now accept a small discount. The size and persistence of that discount is exactly what this bench publishes." + - q: "Why is USDY measurable when OUSG and BUIDL are not?" + a: "OUSG, BUIDL and BENJI are transfer-restricted: they move between allowlisted addresses and cannot trade on open AMMs, so no market price exists to compare against NAV. USDY circulates freely and has genuine pools, which makes it the one tokenized treasury where the NAV question can be answered by measurement." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/usdy-nav-basis + +prometheus: + window: 24h + freshness_metric: usdy_basis_bps + +providers: + - slug: orca-solana + name: Orca (Solana) + tag: "USDY/USDC whirlpool, ~$2.9M, deepest genuine USDY venue" + formula: "p50 over 24h of the absolute signed basis (bps) between the Orca whirlpool USDY/USDC price and the Pyth redemption rate." + queries: + p50: quantile_over_time(0.50, abs(usdy_basis_bps{venue="orca-solana"})[24h:]) + p90: quantile_over_time(0.90, abs(usdy_basis_bps{venue="orca-solana"})[24h:]) + p99: quantile_over_time(0.99, abs(usdy_basis_bps{venue="orca-solana"})[24h:]) + mean: avg_over_time(abs(usdy_basis_bps{venue="orca-solana"})[24h:]) + success: avg_over_time(usdy_health{venue="orca-solana"}[24h]) + sample_size: count_over_time(usdy_basis_bps{venue="orca-solana"}[7d]) + series: usdy_basis_bps{venue="orca-solana"} + - slug: pyth-market + name: Pyth market composite + tag: "Crypto.USDY/USD aggregate vs the RR feed, same oracle network" + formula: "p50 over 24h of the absolute signed basis (bps) between the Pyth USDY/USD market composite and the Pyth redemption rate." + queries: + p50: quantile_over_time(0.50, abs(usdy_basis_bps{venue="pyth-market"})[24h:]) + p90: quantile_over_time(0.90, abs(usdy_basis_bps{venue="pyth-market"})[24h:]) + p99: quantile_over_time(0.99, abs(usdy_basis_bps{venue="pyth-market"})[24h:]) + mean: avg_over_time(abs(usdy_basis_bps{venue="pyth-market"})[24h:]) + success: avg_over_time(usdy_health{venue="pyth-market"}[24h]) + sample_size: count_over_time(usdy_basis_bps{venue="pyth-market"}[7d]) + series: usdy_basis_bps{venue="pyth-market"} diff --git a/benchmarks/validator-yield.yml b/benchmarks/validator-yield.yml index 93ed2e5c..ba37a8e2 100644 --- a/benchmarks/validator-yield.yml +++ b/benchmarks/validator-yield.yml @@ -15,6 +15,11 @@ higher_is_better: true disclaimer: | Honest scope. (1) `net_yield = gross_apr × uptime`. NOT "yield net of MEV", Stakewiz `total_apy` already folds Jito MEV into gross, MEV is INCLUDED. `mev_share_bps` exists for transparency, not subtraction. (2) Solana median is dragged by ~42 vals at 0% APY (commission 100% or zero leader slots). (3) Hyperliquid: centralised sequencer, no separate MEV layer, ~30 vals only. (4) Ethereum: one network-average consensus-layer APR from the beacon spec formula, execution tips and MEV excluded. +provider_notes: + solana: "MEV inc." + ethereum: "MEV excl." + hyperliquid: "no MEV" + seo_intro: | This benchmark answers the question every staker asks when choosing a chain or a validator. what is the live net yield I @@ -183,6 +188,58 @@ providers: sample_size: ocb_chain_total_validators{chain="ethereum"} series: ocb_chain_median_net_yield_bps{chain="ethereum"} + - slug: cardano + name: Cardano + tag: Top 50 pools by stake · Koios API, MEV n/a + formula: "Median net yield in bps across the top 50 Cardano pools by active stake, where net = ρ × (1 − τ) × (1 − margin) with ρ from Koios epoch_params (monetary_expansion) and τ (treasury_growth_rate). Simplified vs full a0/k/saturation curve." + queries: + p50: ocb_chain_median_net_yield_bps{chain="cardano"} + p90: quantile(0.90, ocb_validator_net_yield_bps{chain="cardano"}) + p99: quantile(0.99, ocb_validator_net_yield_bps{chain="cardano"}) + mean: avg(ocb_validator_net_yield_bps{chain="cardano"}) + success: clamp_max(avg_over_time(ocb_validator_uptime_pct{chain="cardano"}[24h]) / 100, 1) + sample_size: ocb_chain_total_validators{chain="cardano"} + series: ocb_chain_median_net_yield_bps{chain="cardano"} + + - slug: sui + name: Sui + tag: All active validators · suix_getValidatorsApy, no MEV + formula: "Median net APR in bps across all active Sui validators, from suix_getValidatorsApy on a public Sui RPC. apy is already net of commission per Sui docs." + queries: + p50: ocb_chain_median_net_yield_bps{chain="sui"} + p90: quantile(0.90, ocb_validator_net_yield_bps{chain="sui"}) + p99: quantile(0.99, ocb_validator_net_yield_bps{chain="sui"}) + mean: avg(ocb_validator_net_yield_bps{chain="sui"}) + success: clamp_max(avg_over_time(ocb_validator_uptime_pct{chain="sui"}[24h]) / 100, 1) + sample_size: ocb_chain_total_validators{chain="sui"} + series: ocb_chain_median_net_yield_bps{chain="sui"} + + - slug: cosmos-hub + name: Cosmos Hub + tag: Top 200 bonded validators · LCD REST, no MEV + formula: "Per-validator net = network_APR × (1 − commission_rate), where network_APR = inflation × (1 − community_tax) / bonded_ratio. Inflation and bonded_ratio from Cosmos LCD staking + mint modules; community_tax hardcoded to 2%." + queries: + p50: ocb_chain_median_net_yield_bps{chain="cosmos-hub"} + p90: quantile(0.90, ocb_validator_net_yield_bps{chain="cosmos-hub"}) + p99: quantile(0.99, ocb_validator_net_yield_bps{chain="cosmos-hub"}) + mean: avg(ocb_validator_net_yield_bps{chain="cosmos-hub"}) + success: clamp_max(avg_over_time(ocb_validator_uptime_pct{chain="cosmos-hub"}[24h]) / 100, 1) + sample_size: ocb_chain_total_validators{chain="cosmos-hub"} + series: ocb_chain_median_net_yield_bps{chain="cosmos-hub"} + + - slug: avalanche + name: Avalanche + tag: Top 100 P-Chain validators, no MEV + formula: "Per-validator net = base 8.5% APR × uptime × (1 − delegationFee/100), from platform.getCurrentValidators on Avalanche P-Chain. Base APR is the documented protocol constant since P-Chain does not publish realised per-validator APR." + queries: + p50: ocb_chain_median_net_yield_bps{chain="avalanche"} + p90: quantile(0.90, ocb_validator_net_yield_bps{chain="avalanche"}) + p99: quantile(0.99, ocb_validator_net_yield_bps{chain="avalanche"}) + mean: avg(ocb_validator_net_yield_bps{chain="avalanche"}) + success: clamp_max(avg_over_time(ocb_validator_uptime_pct{chain="avalanche"}[24h]) / 100, 1) + sample_size: ocb_chain_total_validators{chain="avalanche"} + series: ocb_chain_median_net_yield_bps{chain="avalanche"} + # Ethereum publishes ONE synthetic series (validator="beacon-network"), # the network-average consensus-layer nominal APR from the beacon spec # reward formula on the live total effective balance (ultrasound.money, diff --git a/benchmarks/ws-head-latency-base.yml b/benchmarks/ws-head-latency-base.yml new file mode 100644 index 00000000..bf9f3643 --- /dev/null +++ b/benchmarks/ws-head-latency-base.yml @@ -0,0 +1,132 @@ +# OpenChainBench. Bench № 087 + +slug: ws-head-latency-base +number: "087" +title: Fastest Base WebSocket newHeads push, live block-push lag across RPC providers +seo_title: "Fastest Base WebSocket RPC newHeads push 2026" +seo_description: "{{best_name}} pushes Base newHeads first. Live WebSocket block-push lag for the free keyless Base cohort, measured per block against the earliest arrival." +subtitle: Per-block WebSocket push lag in milliseconds versus the earliest provider to deliver the same Base head, measured continuously from one eu-west vantage point. + +category: RPCs +status: live +metric: Block push lag +unit: ms +higher_is_better: false + +seo_intro: | + Base blocks land every 2 seconds, six times more frequently than + Ethereum, and any real-time bot on Base (perp keeper, MEV searcher, + live dashboard) inherits its provider's head push pipeline six times + as often. This page ranks the free keyless Base WebSocket endpoints + by the same relative race the Ethereum sibling uses: earliest + provider to deliver block N sets T0, every other provider's sample + is arrival minus T0. The harness holds one persistent + `eth_subscribe("newHeads")` connection per provider from an eu-west + host and timestamps every frame on receipt. Sample volume on Base + is substantially higher than Ethereum thanks to the faster cadence, + so the leaderboard reflects a rich distribution rather than a + handful of tail observations. Provider cohort at launch: PublicNode + and dRPC keyless endpoints. Tenderly does not expose a keyless Base + WebSocket path at the time of ship. + +abstract: | + Per-chain member of the WebSocket head-latency cluster, applied to + Base. Same harness, same race scoring rules, same reporting + cadence: one persistent WebSocket per provider from the same + eu-west host, cohort settle window 5 seconds after the first + arrival, lag histogram per provider, gap counter for missed blocks + inside a live subscription. The cross-chain view lives on the + parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency-ethereum) + benchmark; this page is the Base-scoped answer. + +methodology: + - "Providers measured (keyless WebSocket endpoints): PublicNode (wss://base-rpc.publicnode.com), dRPC (wss://base.drpc.org). Tenderly does not expose a keyless Base WebSocket path and is scoped out until a public gateway is announced. Keyed providers (Alchemy, Infura, Chainstack, QuickNode) join the cohort when contributor keys are wired via WS_URL__BASE env vars." + - "Cohort saturation: Blast, LlamaRPC, BlockPI, 1RPC, mainnet.base.org and Ankr all reject a keyless WebSocket handshake at the time of ship (verified 2026-07), which is why the keyless tier stops at two." + - "Chain scope: every query on this page is pinned to chain=\"base\". Base block cadence is 2 seconds, so a 24h window carries roughly 43000 samples per provider (versus ~7200 on Ethereum), well above the 7000 sample floor used by the parent bench." + - "Race scoring: identical to the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency-ethereum) benchmark. First provider to deliver block N sets T0. Each provider's sample for block N is arrival(N) minus T0 in milliseconds. Cohort closes 5 seconds after the first arrival; late arrivals are scored as missed blocks via ws_block_gap_total rather than as huge latency samples." + - "Relative, not absolute: subtraction of two arrivals over the same path removes vantage-point network latency. Consequence: the fastest provider reads near zero by construction. The honest readings are win rate, trailers' lag distribution, and gap counts." + - "Aggregation: per-provider lag histogram with a sub-millisecond floor (0.5 ms bucket up through 10 s tail), p50/p90/p99 via histogram_quantile over the 24h rate. Coverage score = ws_head_blocks_seen_total divided by cohort maximum, clamped to 1. A provider that pushes fast but skips blocks loses on this column, not on the latency percentiles." + - "Connection discipline: one persistent WebSocket per (provider, chain), client ping every 30s, read deadline 60s, 90s head watchdog forcing reconnect when a socket keeps the heartbeat alive but silently drops the subscription. Exponential backoff 2s to 60s on reconnect." + - "Vantage point: a single eu-west host. Multi-region deployment is a follow-up on the parent bench roadmap; the same limitation and the reasoning behind it are documented there." + +findings: + - "{{best_name}} leads the Base cohort at {{best_p50}} (p50, 24 h) across {{count}} measured providers. Base's 2 second cadence means each provider gets 6 times more samples per unit time than the Ethereum sibling; the distribution here has less tail noise than on Ethereum for the same 24h window." + - "{{name:drpc}} trails the winner by {{p50:drpc}} at the median on Base. That is the extra time a Base perp keeper or liquidation bot on dRPC's keyless endpoint waits for block N after the fastest feed already delivered it." + - "The p99 column is the integration-grade number for a chain that produces a block every 2 seconds: {{p99:publicnode}} / {{p99:drpc}}. A multi-second p99 on Base means routinely missing an entire block cycle." + - "Base is 2 second cadence, Ethereum 12 seconds, Solana ~400ms slots. Cross-chain comparison of the absolute lag numbers is not meaningful; use each per-chain leaderboard on its own terms and the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency-ethereum) page for the Ethereum reference." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/ws-head-latency + +expected_n: 7000 + +prometheus: + window: 24h + freshness_metric: ws_head_blocks_seen_total + +rank_matrix_query: histogram_quantile(0.50, sum by (provider, region, le) (rate(ws_head_lag_milliseconds_bucket{chain="base"}[24h]))) + +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +faq: + - q: "What is the fastest free Base RPC over WebSocket right now?" + a: "{{best_name}} currently wins most per-block races on Base with a median lag of {{best_p50}} versus the earliest arrival (p50, 24 h) across {{count}} measured keyless providers. The leaderboard re-scores on every Base block, roughly every 2 seconds, so the answer reflects sustained live measurement, not a one-off test. Check the win rate and the trailers' p99 before wiring a latency-sensitive Base bot to any single provider." + - q: "How is Base WebSocket head lag measured on OpenChainBench?" + a: "One Go harness holds a persistent `eth_subscribe(\"newHeads\")` WebSocket per provider from the same eu-west host. Every frame is timestamped on receipt at millisecond precision. Each block is scored as a race: earliest arrival sets T0 and every other provider's lag is arrival minus T0. The cohort closes 5 seconds after the first arrival, then p50/p90/p99 are computed via `histogram_quantile` over 24 hours." + - q: "Why does the fastest provider read near zero?" + a: "By construction. The measurement is relative to the fastest arrival in the cohort, not to an external reference clock, so the provider that wins most races accumulates mostly zero samples. This is deliberate: without a reference node co-located with the Base sequencer, a single vantage point cannot separate its own network latency from the provider's push pipeline. Read the leader's win rate and the trailers' distributions rather than the leader's absolute number." + - q: "Why isn't Tenderly ranked on Base?" + a: "Tenderly's public Base gateway does not expose a keyless WebSocket path for `eth_subscribe(\"newHeads\")` at the time this bench went live. When it does, the harness reads the endpoint from an env variable and Tenderly joins the leaderboard automatically." + - q: "Base blocks land every 2 seconds. How does that change the numbers?" + a: "Sample volume is roughly 6 times higher than Ethereum for the same 24h window. That tightens the confidence interval on every percentile: a 24h p50 on Base is drawn from ~43000 samples per provider versus ~7200 on Ethereum. The percentile numbers themselves are still relative to the per-block winner, so the interpretation is unchanged; the Base leaderboard just carries less tail noise than the Ethereum one." + +providers: + - slug: publicnode + name: PublicNode + tag: Keyless WS, wss://base-rpc.publicnode.com + formula: "Median ms behind the earliest provider to push each Base newHeads frame, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base"}[24h]))) + mean: sum(rate(ws_head_lag_milliseconds_sum{provider="publicnode", chain="base"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="publicnode", chain="base"}[24h])) + success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="publicnode", chain="base"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="base"}[24h]))), 1) + sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="publicnode", chain="base"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base", region="us-east"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base", region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base", region="eu-west"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base", region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base", region="sgp"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base", region="sgp"}[1h]))) + + - slug: drpc + name: dRPC + tag: Keyless WS, wss://base.drpc.org + formula: "Median ms behind the earliest provider to push each Base newHeads frame, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. Lag is relative to the per-block winner." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base"}[24h]))) + mean: sum(rate(ws_head_lag_milliseconds_sum{provider="drpc", chain="base"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="drpc", chain="base"}[24h])) + success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="drpc", chain="base"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="base"}[24h]))), 1) + sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="drpc", chain="base"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base", region="us-east"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base", region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base", region="eu-west"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base", region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base", region="sgp"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base", region="sgp"}[1h]))) diff --git a/benchmarks/ws-head-latency-ethereum.yml b/benchmarks/ws-head-latency-ethereum.yml new file mode 100644 index 00000000..e79078c8 --- /dev/null +++ b/benchmarks/ws-head-latency-ethereum.yml @@ -0,0 +1,193 @@ +# OpenChainBench. Bench № 081 + +slug: ws-head-latency-ethereum +number: "081" +title: Fastest Ethereum WebSocket newHeads push, live block-push lag across RPC providers +seo_title: "Fastest Ethereum WebSocket RPC newHeads push 2026" +seo_description: "{{best_name}} pushes Ethereum newHeads first. Live WebSocket block-push lag for PublicNode, dRPC and Tenderly, measured per block against the earliest arrival in the cohort." +subtitle: Per-block WebSocket push lag in milliseconds versus the earliest provider to deliver the same Ethereum head, measured continuously from one eu-west vantage point. + +category: RPCs +status: live +metric: Block push lag +unit: ms +higher_is_better: false + +seo_intro: | + Every trading bot, MEV searcher, liquidation keeper and live dashboard + that subscribes to `eth_subscribe("newHeads")` cares about one number + first: how many milliseconds after the network produces a block does my + RPC provider actually push it to me? Marketing pages say "real-time + WebSocket streaming"; this page says who delivers block N first and by + how much the others trail. The harness holds one persistent WebSocket + per provider from the same eu-west host, timestamps every newHeads + frame on receipt, and scores each Ethereum block as a race: the + earliest arrival sets T0, every other provider's lag is its arrival + minus T0. The headline is pinned to Ethereum, where all three keyless + providers compete; the same harness also races Base heads and Solana + slots for cross-chain context. Because the measurement is relative, + the leader reads near zero by construction, so the win-rate and the + shape of the trailing distribution (p50 vs p99) are the numbers to + read, not the absolute magnitude. {{best_name}} currently leads at + {{best_p50}} (p50, 24 h) across {{count}} measured providers. + +abstract: | + We race RPC providers on WebSocket block delivery. One harness holds a + persistent `eth_subscribe("newHeads")` connection to each provider from + the same host and timestamps every head frame on receipt. Per block, + the earliest arrival across providers defines T0; each provider's + sample is its own arrival minus T0, in milliseconds. A block's cohort + stays open 5 seconds after the first arrival, then it is scored: + lag histogram per provider, a win for the earliest, and a gap counter + for any live provider that never delivered the block. The measurement + is relative to the fastest provider observed from our vantage point, + not an absolute network latency; the same-path subtraction is exactly + what makes the ordering fair. Solana runs the identical race on + `slotSubscribe` slot notifications. + +methodology: + - "Providers measured (keyless WebSocket endpoints): PublicNode (wss://ethereum-rpc.publicnode.com), dRPC (wss://eth.drpc.org), Tenderly (wss://mainnet.gateway.tenderly.co), Onfinality (wss://eth.api.onfinality.io/public-ws). Keyed providers (Alchemy, Infura, Chainstack, QuickNode) join the cohort when contributor keys are wired via WS_URL__ETHEREUM env vars; the harness skips them cleanly when unset." + - "Cohort saturation: Ankr, Blast, LlamaRPC, BlockPI, 1RPC, NodeReal and Omniatech all reject a keyless WebSocket handshake at the time of ship (verified 2026-07), which is why the keyless tier stops at four (PublicNode, dRPC, Tenderly, Onfinality)." + - "Chains: Ethereum (headline, all three providers compete), Base via newHeads and Solana via slotSubscribe as secondary cohorts. Headline queries on this page are pinned to chain=ethereum so the ranking never mixes block cadences." + - "Race scoring: the first provider to deliver block N sets T0. Each provider's sample for block N is arrival(N) minus T0 in milliseconds. The cohort closes 5 seconds after the first arrival; a provider arriving later than that is scored as a missed block (ws_block_gap_total), not as a huge latency sample." + - "Relative, not absolute: a single vantage point cannot separate its own network path from provider pipeline time, so we subtract the two arrivals over the same path instead. Consequence: the fastest provider reads ~0 by construction, and the honest readings are win rate, the trailers' lag distribution, and gap counts, not the leader's absolute number." + - "Blocks where only one provider delivered within the window emit no lag samples and no win: a one-horse race carries no relative information. Gap counters still increment for live providers that missed the block." + - "Connection discipline: one persistent WebSocket per (provider, chain), client ping every 30s, read deadline 60s, plus a 90s head watchdog that forces a reconnect when a provider keeps the socket heartbeat alive but silently drops the subscription (a failure mode we have observed in production on other benches). Reconnects use exponential backoff 2s to 60s and are counted in ws_reconnects_total." + - "Coverage score: ws_head_blocks_seen_total per provider divided by the cohort maximum over the same 24h window, clamped to 1. A provider that pushes fast but skips blocks loses on this column rather than hiding in the latency percentiles." + - "Aggregation: per-provider lag histogram with a sub-millisecond floor (0.5 ms bucket up through 10 s tail), p50/p90/p99 via histogram_quantile over the 24h rate. Duplicate announcements of the same block on one connection are dropped; per-provider liveness is exported as ws_health (1 = connected and delivered a message in the last 120s)." + - "Cheat vector disclosure: a provider could theoretically pre-announce heads it has not fully validated to win the race. We keyed the cohort on block number as pushed; hash-level cross-checking against a reference node is on the v2 list. Reading win rate together with the gap counter already flags a provider that pushes early but wrong (retracted heads show up as cohort inconsistencies and reorg noise)." + - "Vantage point: a single eu-west host. Geography moves absolute arrival times but affects all providers over the same path; still, a provider whose nearest edge is far from eu-west is disadvantaged, and a us-east probe is the planned second vantage before any cross-region claim is made." + +findings: + - "{{best_name}} leads the Ethereum cohort at {{best_p50}} (p50, 24 h) across {{count}} measured providers. Remember the construction: the per-block winner defines zero, so the leader's near-zero p50 mostly says it wins most races; the informative numbers are the trailers' medians and the p99 tails." + - "{{name:drpc}} trails the winner by {{p50:drpc}} at the median. That is the extra time a liquidation bot or MEV searcher on dRPC's keyless endpoint waits for block N after the fastest feed already delivered it." + - "{{name:tenderly}} trails by {{p50:tenderly}} (p50, 24 h). Tenderly's gateway is built around simulation and developer tooling rather than raw head-push fan-out, and the push pipeline depth shows up in this race." + - "The p99 column is the integration-grade number: {{p99:publicnode}} / {{p99:drpc}} / {{p99:tenderly}}. A provider with a tight p50 but a multi-second p99 will occasionally hand your bot a stale head exactly when the chain is busiest." + - "Win rate and block coverage are the robust readings. A provider can look decent on median lag while silently skipping blocks; the success column (blocks seen vs cohort max) and ws_block_gap_total keep that visible." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/ws-head-latency + +expected_n: 7000 + +prometheus: + window: 24h + freshness_metric: ws_head_blocks_seen_total + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on provider and region. +rank_matrix_query: histogram_quantile(0.50, sum by (provider, region, le) (rate(ws_head_lag_milliseconds_bucket{chain="ethereum"}[24h]))) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +faq: + - q: "Which RPC provider pushes new Ethereum blocks first over WebSocket?" + a: "{{best_name}} currently wins most per-block races, with a median lag of {{best_p50}} versus the earliest arrival (p50, 24 h) across {{count}} measured keyless providers. The leaderboard re-scores on every Ethereum block, roughly every 12 seconds, so the answer reflects sustained live measurement, not a one-off test. Check the win rate and the trailers' p99 before wiring a latency-sensitive bot to any single provider." + - q: "Why does the fastest provider show a lag near zero?" + a: "By construction. Each block is scored as a race: the earliest arrival among providers defines T0 and every sample is measured against it, so the provider that wins most races accumulates mostly zero samples. This is deliberate. A single vantage point cannot measure absolute chain-to-client latency without a reference node co-located with block producers; it can measure, fairly, which provider delivers the same block earlier over the same network path. Read the leader's win rate and the trailers' distributions, not the leader's absolute number." + - q: "Is this absolute latency from block production to my client?" + a: "No. The number is relative lag versus the fastest provider observed from our eu-west host. Absolute delivery latency includes the propagation time from the block producer to each provider's infrastructure plus the path to you, which shifts with your geography. What transfers to your integration is the ordering and the spread: if a provider trails the winner by 800 ms at the median from our vantage point, its pipeline is structurally behind, and that gap does not disappear because you probe from another region." + - q: "Could a provider cheat by pushing heads before validating them?" + a: "In principle yes: pre-announcing an unvalidated head wins the race at the cost of occasionally pushing a block that gets reorged or retracted. That is why the bench exports more than the lag histogram. A provider that pushes early but wrong shows up as cohort inconsistencies, elevated reorg noise and gaps, and the coverage column (blocks seen versus the cohort maximum) penalises skipped or retracted blocks. Hash-level cross-checking against an independent reference node is planned for v2 of the harness." + - q: "How is WebSocket head lag measured on OpenChainBench?" + a: "One Go harness holds a persistent eth_subscribe('newHeads') WebSocket per provider from the same eu-west host, plus slotSubscribe for the Solana cohort. Every frame is timestamped on receipt with time.Now() precision. A block's cohort stays open for 5 seconds after the first arrival, then each provider's lag versus the earliest is observed into a Prometheus histogram (0.5 ms to 10 s buckets, sub-millisecond floor so the winner does not collapse to a single interpolation artifact); p50/p90/p99 come from histogram_quantile over the 24 h window. Connections carry a 90 s silent-subscription watchdog and exponential-backoff reconnects so a flaky endpoint degrades to health=0 instead of corrupting the race." + - q: "Why are only PublicNode, dRPC and Tenderly listed?" + a: "V1 measures the keyless tier: endpoints anyone can open a WebSocket to without an account, which is also the tier where claims are hardest to verify and marketing is loudest. Alchemy, Infura and Chainstack require API keys; the harness already accepts them via environment variables and they join the leaderboard once contributor keys are wired into the deployment. Keyed tiers usually run on better-provisioned infrastructure, so expect the keyless numbers here to be the floor, not the ceiling, of each vendor's performance." + +providers: + - slug: publicnode + name: PublicNode + tag: Keyless WS, wss://ethereum-rpc.publicnode.com + formula: "Median ms behind the earliest provider to push each Ethereum newHeads frame, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum"}[24h]))) + mean: sum(rate(ws_head_lag_milliseconds_sum{provider="publicnode", chain="ethereum"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="publicnode", chain="ethereum"}[24h])) + success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="publicnode", chain="ethereum"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="ethereum"}[24h]))), 1) + sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="publicnode", chain="ethereum"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum", region="us-east"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum", region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum", region="eu-west"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum", region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum", region="sgp"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum", region="sgp"}[1h]))) + + - slug: drpc + name: dRPC + tag: Keyless WS, wss://eth.drpc.org + formula: "Median ms behind the earliest provider to push each Ethereum newHeads frame, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. Lag is relative to the per-block winner." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum"}[24h]))) + mean: sum(rate(ws_head_lag_milliseconds_sum{provider="drpc", chain="ethereum"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="drpc", chain="ethereum"}[24h])) + success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="drpc", chain="ethereum"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="ethereum"}[24h]))), 1) + sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="drpc", chain="ethereum"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum", region="us-east"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum", region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum", region="eu-west"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum", region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum", region="sgp"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum", region="sgp"}[1h]))) + + - slug: tenderly + name: Tenderly + tag: Keyless WS, wss://mainnet.gateway.tenderly.co + formula: "Median ms behind the earliest provider to push each Ethereum newHeads frame, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. Lag is relative to the per-block winner." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum"}[24h]))) + mean: sum(rate(ws_head_lag_milliseconds_sum{provider="tenderly", chain="ethereum"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="tenderly", chain="ethereum"}[24h])) + success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="tenderly", chain="ethereum"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="ethereum"}[24h]))), 1) + sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="tenderly", chain="ethereum"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum", region="us-east"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum", region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum", region="eu-west"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum", region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum", region="sgp"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum", region="sgp"}[1h]))) + + - slug: onfinality + name: OnFinality + tag: Keyless WS, wss://eth.api.onfinality.io/public-ws + formula: "Median ms behind the earliest provider to push each Ethereum newHeads frame, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. Lag is relative to the per-block winner." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum"}[24h]))) + mean: sum(rate(ws_head_lag_milliseconds_sum{provider="onfinality", chain="ethereum"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="onfinality", chain="ethereum"}[24h])) + success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="onfinality", chain="ethereum"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="ethereum"}[24h]))), 1) + sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="onfinality", chain="ethereum"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum", region="us-east"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum", region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum", region="eu-west"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum", region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum", region="sgp"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum", region="sgp"}[1h]))) diff --git a/benchmarks/ws-head-latency-solana.yml b/benchmarks/ws-head-latency-solana.yml new file mode 100644 index 00000000..26ff4d07 --- /dev/null +++ b/benchmarks/ws-head-latency-solana.yml @@ -0,0 +1,135 @@ +# OpenChainBench. Bench № 088 + +slug: ws-head-latency-solana +number: "088" +title: Fastest Solana WebSocket slotSubscribe push, live slot-push lag across RPC providers +seo_title: "Fastest Solana WebSocket RPC slotSubscribe push 2026" +seo_description: "{{best_name}} pushes Solana slots first. Live WebSocket slot-push lag for the free keyless Solana cohort, measured per slot against the earliest arrival." +subtitle: Per-slot WebSocket push lag in milliseconds versus the earliest provider to deliver the same Solana slot, measured continuously from one eu-west vantage point. + +category: RPCs +status: live +metric: Slot push lag +unit: ms +higher_is_better: false + +seo_intro: | + Solana slots land every ~400ms, thirty times faster than Ethereum + blocks, and every real-time integrator on Solana (perp keeper, + liquidator, market maker, on-chain scanner) inherits its provider's + slot push pipeline thirty times as often. This page ranks the free + keyless Solana WebSocket endpoints by the same relative race the + EVM sibling uses on `eth_subscribe("newHeads")`, applied to + Solana's `slotSubscribe` RPC. The harness holds one persistent + WebSocket per provider from an eu-west host and timestamps every + slot notification on receipt. Sample volume is roughly 30 times + higher than the Ethereum sibling for the same 24h window, so the + distribution is dense and tail estimates are stable. Provider + cohort at launch: PublicNode's public Solana gateway and the + Solana Foundation's `api.mainnet-beta.solana.com`. + +abstract: | + Per-chain member of the WebSocket head-latency cluster, applied to + Solana. Same harness, same race scoring rules, adapted for + Solana's slot model instead of EVM blocks: `slotSubscribe` + notifications carry a slot number, the harness treats each + distinct slot as one race, the first provider to deliver slot N + sets T0, every other provider's sample is arrival minus T0 in + milliseconds. The cross-chain view lives on the parent + [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency-ethereum) + benchmark; this page is the Solana-scoped answer. + +methodology: + - "Providers measured (keyless WebSocket endpoints): PublicNode (wss://solana-rpc.publicnode.com) and Solana Foundation (wss://api.mainnet-beta.solana.com). Keyed providers (Alchemy, Chainstack, Helius, QuickNode) join the cohort when contributor keys are wired via WS_URL__SOLANA env vars." + - "Cohort saturation: dRPC's free Solana tier explicitly disallows slotSubscribe (\"method is not available on freetier\") and the other public Solana WSS gateways audited (Blast, BlockPI, Ankr, extrnode, Chainstack public) all reject the handshake (verified 2026-07), which is why the keyless tier stops at two." + - "Chain scope: every query on this page is pinned to chain=\"solana\". Solana slot cadence is ~400ms, so a 24h window carries roughly 216000 samples per provider, an order of magnitude beyond the 7000 sample floor used across the head-lag cluster." + - "RPC method: `slotSubscribe`, not `signatureSubscribe` or `logsSubscribe`. The notification payload includes the slot number and the parent slot; the harness dedupes on slot number and uses the first arrival per slot to define T0." + - "Race scoring: identical to the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency-ethereum) benchmark, applied per slot instead of per block. First provider to deliver slot N sets T0. Each provider's sample for slot N is arrival(N) minus T0 in milliseconds. Cohort closes 5 seconds after the first arrival." + - "Relative, not absolute: subtraction of two arrivals over the same path removes vantage-point network latency. Consequence: the fastest provider reads near zero by construction. The honest readings are win rate, trailers' lag distribution, and gap counts, not the leader's absolute number." + - "Aggregation: per-provider lag histogram with a sub-millisecond floor (0.5 ms bucket up through 10 s tail), p50/p90/p99 via histogram_quantile over the 24h rate. Coverage score = ws_head_blocks_seen_total divided by cohort maximum, clamped to 1." + - "Connection discipline: one persistent WebSocket per (provider, chain), client ping every 30s, read deadline 60s, 90s slot watchdog forcing reconnect when a socket keeps the heartbeat alive but silently drops the subscription. Exponential backoff 2s to 60s on reconnect." + - "Vantage point: a single eu-west host. Multi-region deployment is a follow-up on the parent bench roadmap; the same limitation and reasoning are documented there." + +findings: + - "{{best_name}} leads the Solana cohort at {{best_p50}} (p50, 24 h) across {{count}} measured providers. Solana's ~400ms cadence means each provider gets 30 times more samples per unit time than the Ethereum sibling; the distribution here has very little tail noise for the same 24h window." + - "{{name:solana}} versus {{name:publicnode}}: the Solana Foundation's public endpoint and PublicNode's Solana gateway measured on identical terms. A latency-sensitive Solana integration should read the win rate alongside p50 to see which endpoint delivers slot N first most often." + - "The p99 column matters more on Solana than on any EVM chain in the cluster: at ~400ms slots, a p99 above 1 second means the provider is routinely 2-3 slots behind the leader. That gap is the exact window where a keeper or liquidator loses to a co-located competitor." + - "Cross-chain comparison of absolute lag numbers is not meaningful: Solana runs at 400ms slots, Base at 2s, Ethereum at 12s. Use each per-chain leaderboard on its own terms and the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency-ethereum) page for the Ethereum reference." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/ws-head-latency + +expected_n: 50000 + +prometheus: + window: 24h + freshness_metric: ws_head_blocks_seen_total + +rank_matrix_query: histogram_quantile(0.50, sum by (provider, region, le) (rate(ws_head_lag_milliseconds_bucket{chain="solana"}[24h]))) + +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +faq: + - q: "What is the fastest free Solana RPC over WebSocket right now?" + a: "{{best_name}} currently wins most per-slot races on Solana with a median lag of {{best_p50}} versus the earliest arrival (p50, 24 h) across {{count}} measured keyless providers. The leaderboard re-scores on every Solana slot, roughly every 400ms, so the answer reflects sustained live measurement, not a one-off test. Check the win rate and the trailers' p99 before wiring a latency-sensitive Solana keeper or MEV integration to any single provider." + - q: "How is Solana WebSocket slot lag measured on OpenChainBench?" + a: "One Go harness holds a persistent `slotSubscribe` WebSocket per provider from the same eu-west host. Every notification is timestamped on receipt at millisecond precision, keyed on slot number, and deduplicated. Each slot is scored as a race: earliest arrival sets T0 and every other provider's lag is arrival minus T0. The cohort closes 5 seconds after the first arrival, then p50/p90/p99 are computed via `histogram_quantile` over 24 hours." + - q: "Why does the fastest provider read near zero?" + a: "By construction. The measurement is relative to the fastest arrival in the cohort, not to an external reference clock, so the provider that wins most races accumulates mostly zero samples. Without a validator co-located with block producers, a single vantage point cannot separate its own network latency from the provider's push pipeline. Read the leader's win rate and the trailers' distributions rather than the leader's absolute number." + - q: "Why aren't Alchemy and Helius on the leaderboard?" + a: "Both are keyed-only for Solana WebSocket subscriptions and V1 measures the keyless tier. The harness reads Alchemy and Helius endpoints from env variables and adds them to the cohort automatically once contributor keys are wired into the deployment." + - q: "Solana slots land every ~400ms. How does that change the numbers?" + a: "Sample volume is roughly 30 times higher than Ethereum for the same 24h window. Percentile estimates are drawn from ~216000 samples per provider versus ~7200 on Ethereum, so tail noise is negligible. The percentile numbers themselves are still relative to the per-slot winner, so the interpretation is unchanged. At Solana's cadence, a p99 above 1 second means the provider is routinely 2 to 3 slots behind the leader, which is the exact window where a keeper or liquidator loses to a co-located competitor." + - q: "Why `slotSubscribe` and not `signatureSubscribe` or `logsSubscribe`?" + a: "The race we score is head delivery: which endpoint pushes the newest thing first. On Solana that is a slot notification; `slotSubscribe` fires on every slot with the slot number and parent slot in the payload. `signatureSubscribe` targets one specific transaction and `logsSubscribe` filters by program, so neither is a fair head-delivery race across providers." + +providers: + - slug: publicnode + name: PublicNode + tag: Keyless WS, wss://solana-rpc.publicnode.com + formula: "Median ms behind the earliest provider to push each Solana slotSubscribe notification, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. The per-slot winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana"}[24h]))) + mean: sum(rate(ws_head_lag_milliseconds_sum{provider="publicnode", chain="solana"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="publicnode", chain="solana"}[24h])) + success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="publicnode", chain="solana"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="solana"}[24h]))), 1) + sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="publicnode", chain="solana"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana", region="us-east"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana", region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana", region="eu-west"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana", region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana", region="sgp"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana", region="sgp"}[1h]))) + + - slug: solana + name: Solana + tag: Keyless WS, wss://api.mainnet-beta.solana.com + formula: "Median ms behind the earliest provider to push each Solana slotSubscribe notification, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. Lag is relative to the per-slot winner." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana", chain="solana"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana", chain="solana"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana", chain="solana"}[24h]))) + mean: sum(rate(ws_head_lag_milliseconds_sum{provider="solana", chain="solana"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="solana", chain="solana"}[24h])) + success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="solana", chain="solana"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="solana"}[24h]))), 1) + sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="solana", chain="solana"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana", chain="solana"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana", chain="solana", region="us-east"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana", chain="solana", region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana", chain="solana", region="eu-west"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana", chain="solana", region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana", chain="solana", region="sgp"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana", chain="solana", region="sgp"}[1h]))) diff --git a/benchmarks/xstocks-peg.yml b/benchmarks/xstocks-peg.yml new file mode 100644 index 00000000..7215b6f8 --- /dev/null +++ b/benchmarks/xstocks-peg.yml @@ -0,0 +1,219 @@ +# OpenChainBench. Bench № 077 + +slug: xstocks-peg +number: "077" +title: xStocks price accuracy, live onchain vs Nasdaq across 12 equities on Solana +seo_title: "xStocks price tracker 2026" +seo_description: "Do Backed's xStocks track the real market? AAPLx, TSLAx, NVDAx and 9 more on Solana vs their Nasdaq price, deviation in bps, live and keyless." +subtitle: "Absolute deviation between each xStock's executable Jupiter price on Solana and its real market reference, in basis points, labeled by market session. Companion to the Robinhood Chain tokenized stock bench: same methodology, different issuer, so the two pages read as a head to head." + +category: RWA +status: live +metric: Price deviation +unit: bps +higher_is_better: false + +seo_intro: | + Backed's xStocks are the other big tokenized equity program: 20+ US + stocks and ETFs issued on Solana and distributed through Kraken and + Bybit, trading around the clock on Raydium and Orca with pool depths + that reach several million dollars. This page measures how closely + each xStock's executable price tracks its real market reference, + every 60 seconds, keyless on both legs. The price measured is the + Jupiter aggregated quote for actually swapping one share in each + direction, so it is an executable mid, not a theoretical pool spot. + Sessions are labeled pre, regular, post and closed, and the closed + series is the weekend drift panel. The same methodology runs against + Robinhood Chain's tokenized equities on the companion bench, which + turns the two pages into the first public issuer head to head for + tokenized stocks. Fun fact this page makes measurable: HOODx, the + tokenized Robinhood stock, trades here on Solana, while Robinhood's + own chain does not tokenize HOOD at all. + +abstract: | + Every 60 seconds the harness quotes one share of each of 12 xStocks + through Jupiter's keyless lite API in both directions (sell to USDC, + buy back), takes the executable mid, corrects for the Token-2022 + scaled amount multiplier where present, and compares against the + Yahoo reference price with holiday-aware session labels. Deviation is + 10000 x |mid - reference| / reference in basis points. Same metric + contract as the Robinhood Chain bench with issuer="xstocks". + +methodology: + - "Onchain leg: Jupiter lite-api swap quotes, keyless, both directions per symbol (sell 1 share to USDC, buy the proceeds back), spaced 1.1s apart, ~26s per sweep for 12 symbols. Price = mid of the two implied prices: an executable aggregated price across Raydium, Orca and every routed venue, not a single pool spot." + - "Token-2022 correction: 7 of 12 xStocks mints carry a ScaledUiAmount multiplier (~1.0009 observed). One batched price/v3 call per tick supplies the live multiplier per mint (usdPrice over usdPricePrescaled), converting raw quote units to exactly one UI share. Ignoring it would bake a ~9bp systematic error into affected symbols." + - "Reference leg: Yahoo Finance spark batch, one keyless call for all 12 underliers per tick, 1 minute candles; session labels (pre, regular, post, closed) derived from Yahoo currentTradingPeriod, holiday aware. HOODx maps to HOOD, SPYx to SPY, QQQx to QQQ, COINx to COIN." + - "Deviation: 10000 x |executable mid - reference| / reference, quantiles over 24h via quantile_over_time. Headline pins market_state=\"regular\"; the closed series is the weekend drift panel." + - "Cohort: the 12 xStocks with verified Jupiter routes at under 2bp of 1-share price impact (2026-07-13): TSLAx, NVDAx, AAPLx, MSFTx, AMZNx, GOOGLx, METAx, HOODx, SPYx, QQQx, COINx, PLTRx. All mints 8 decimals, verified individually; counterfeit lookalike mints excluded by address allowlist." + - "Round-trip honesty: the sell/buy spread observed at verification was ~11bp on TSLAx. Using the mid rather than one side keeps the fee component out of the deviation number; the spread itself is executable cost, not tracking error." + - "Quote asset caveat: prices are in USDC against USD references. A USDC peg wobble would appear as a correlated deviation across all 12 symbols simultaneously." + - "Cross-issuer reading: this bench shares its metric contract with the Robinhood Chain tokenized stock bench (issuer label). Same equity, same reference, two issuers: the pages are directly comparable, with the depth difference disclosed (Solana xStocks pools run 10x to 100x deeper)." + +findings: + - "{{best_name}} tracks its reference tightest at {{best_p50}} (p50, 24h) across {{count}} measured xStocks." + - "{{name:tsla}} deviates {{p50:tsla}} (p50, 24h) against a Raydium pool holding about $2.1M, roughly 50x the depth of the equivalent Robinhood Chain pool, and the tighter tracking that buys is exactly what the cross-issuer comparison exists to show." + - "{{name:hood}} ({{p50:hood}}) is the page's inside joke made measurable: the tokenized Robinhood stock trades on Solana via a third-party issuer while Robinhood's own chain does not tokenize HOOD." + - "{{name:spy}} ({{p50:spy}}) and {{name:qqq}} ({{p50:qqq}}) give the bench its index rows, useful because index arbitrage against deep ETF markets should in theory be the tightest peg of all." + - "The number to watch on weekends: pools trade around the clock while the reference freezes at Friday close, and the closed-session drift measured here is the honest answer to what a 24/7 stock is worth on a Sunday." + +faq: + - q: "Do xStocks track their real stock price?" + a: "During regular hours the liquid names track within tens of basis points, and this page shows the live per-symbol number measured as an executable Jupiter mid, not a theoretical pool spot. Outside market hours the peg loosens by construction, and the closed-session series quantifies exactly how much." + - q: "How is this different from the Robinhood Chain tokenized stock bench?" + a: "Same methodology, different issuer and chain: Backed's xStocks on Solana measured through Jupiter, Robinhood's tokens on their own chain measured through Uniswap v4. The two benches share a metric contract, so the same equity can be compared across issuers directly, depth differences disclosed." + - q: "Why measure the Jupiter quote instead of a specific pool?" + a: "Because it is what a user actually gets: the aggregated executable route across every venue with the fee baked into the round trip. Taking the mid of both directions removes the fee component and leaves the tracking signal. A single pool spot can sit anywhere inside its fee band without being arbitrageable." + - q: "What is the ScaledUiAmount correction?" + a: "Seven xStocks mints use Token-2022 scaled amounts, where raw token units map to UI shares through a live multiplier (about 1.0009 at verification). The harness reads the multiplier every tick from Jupiter's price API and applies it, without which those symbols would carry a permanent ~9bp phantom deviation." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/xstocks-peg + +prometheus: + window: 24h + freshness_metric: tsp_deviation_bps + +providers: + - slug: tsla + name: TSLAx + tag: "Tesla xStock, deepest pool of the cohort (~$2.1M Raydium)" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for TSLAx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="tsla"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"} + - slug: nvda + name: NVDAx + tag: "Nvidia xStock, ~$2.7M Raydium pool" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for NVDAx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="nvda"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"} + - slug: aapl + name: AAPLx + tag: "Apple xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for AAPLx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="aapl"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"} + - slug: msft + name: MSFTx + tag: "Microsoft xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for MSFTx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="msft"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"} + - slug: amzn + name: AMZNx + tag: "Amazon xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for AMZNx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="amzn"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"} + - slug: googl + name: GOOGLx + tag: "Alphabet xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for GOOGLx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="googl"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"} + - slug: meta + name: METAx + tag: "Meta xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for METAx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="meta"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"} + - slug: hood + name: HOODx + tag: "Robinhood stock, tokenized by Backed, not by Robinhood" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for HOODx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="hood"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"} + - slug: spy + name: SPYx + tag: "S&P 500 ETF xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for SPYx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="spy"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"} + - slug: qqq + name: QQQx + tag: "Nasdaq 100 ETF xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for QQQx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="qqq"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"} + - slug: coin + name: COINx + tag: "Coinbase xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for COINx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="coin"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"} + - slug: pltr + name: PLTRx + tag: "Palantir xStock, thinnest route of the cohort (1.8bp impact)" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for PLTRx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="pltr"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[7d]) + series: tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"} diff --git a/harnesses/bridge-monitor/README.md b/harnesses/bridge-monitor/README.md index f2c6d855..e9508e23 100644 --- a/harnesses/bridge-monitor/README.md +++ b/harnesses/bridge-monitor/README.md @@ -16,7 +16,7 @@ Two binaries live here: - **`cmd/monitor/`** (the Dockerfile default) is the continuous quote loop + execution scheduler. It scrapes every bridge for quotes, executes the paid leg on its schedule, and exposes every Prometheus metric the two benches above consume. - **`cmd/rebalance/`** is a one-shot utility that keeps the execution wallets topped up across chains so the paid leg never runs out of inventory mid-cycle. Build separately with `go build ./cmd/rebalance`. -The numbers on openchainbench.com come from an instance of `cmd/monitor/` running on Mobula's Railway infra. Anyone with the wallet capital documented below can clone this directory, fill `.env`, and reproduce them end-to-end. +The numbers on openchainbench.com come from an instance of `cmd/monitor/` running on the OpenChainBench VPS (`ocb-par-main`, docker-compose in `/opt/ocb/`, container `ocb-bridge-monitor`). Anyone with the wallet capital documented below can clone this directory, fill `.env`, and reproduce them end-to-end. ## How it works @@ -32,7 +32,7 @@ Plus daily P&L cron, wallet balance metrics, pre-flight tier simulation, and per This harness is a **data producer only**. it exposes `/metrics` on port `9090` (overridable via `METRICS_PORT`). The shared OpenChainBench Prometheus (see [`/infrastructure/prometheus`](../../infrastructure/prometheus)) scrapes that endpoint: ``` -bridge-monitor.railway.internal:9090 ──► prometheus.railway.internal ──► public site +ocb-bridge-monitor:2112 ──► ocb-prometheus (docker network `web`) ──► public site ``` ## Test routes @@ -117,9 +117,11 @@ EXECUTION_MODE=single-test TEST_AMOUNT_USD=0.50 go run ./cmd/monitor/ EXECUTION_MODE=production go run ./cmd/monitor/ ``` -## Run on Railway +## Run on the OCB VPS -This service is deployed from the OpenChainBench repo, root directory `harnesses/bridge-monitor/`. Set the env vars listed below; the shared Prometheus will pick it up via DNS automatically. +Deployed as the `bridge-monitor` service in `/opt/ocb/docker-compose.yml` on `ocb-par-main`. The image builds from this directory; env file lives at `/run/ocb/.env.bridge-monitor` (SOPS-managed), state persisted at `/data/state/bridge-monitor`. The compose-networked `ocb-prometheus` scrapes `:2112/metrics` automatically. + +Deploy via `rsync` + `docker compose up -d --build bridge-monitor` from a checkout on the VPS. ## Project layout diff --git a/harnesses/chain-kpis/cmd/script/defillama.go b/harnesses/chain-kpis/cmd/script/defillama.go index c3ee658a..e221aa9d 100644 --- a/harnesses/chain-kpis/cmd/script/defillama.go +++ b/harnesses/chain-kpis/cmd/script/defillama.go @@ -45,7 +45,14 @@ func fetchDefillamaChain(c Chain) { anyOK := false if tvl, err := defillamaTvl(c.DefiLlama); err == nil { - chainTvlUsd.WithLabelValues(c.Slug).Set(tvl) + // Skip zero: DefiLlama serves the whole historical series as + // zero for chains where the relay layer holds no DeFi (Polkadot + // today), so we would render a "$0" card that reads as broken + // rather than as "no data". Card hides when the gauge stays + // unpublished. Non-zero values, including small ones, publish. + if tvl > 0 { + chainTvlUsd.WithLabelValues(c.Slug).Set(tvl) + } anyOK = true } else { chainKpisFetchErrors.WithLabelValues(c.Slug, "defillama", classifyError(err.Error())).Inc() diff --git a/harnesses/chain-kpis/cmd/script/registry.go b/harnesses/chain-kpis/cmd/script/registry.go index eb084453..cb2aca55 100644 --- a/harnesses/chain-kpis/cmd/script/registry.go +++ b/harnesses/chain-kpis/cmd/script/registry.go @@ -52,6 +52,7 @@ var Registry = []Chain{ {Slug: "arbitrum", DefiLlama: "Arbitrum", Mobula: "Arbitrum", NativeSymbol: "ETH"}, {Slug: "optimism", DefiLlama: "Optimism", Mobula: "Optimistic", NativeSymbol: "ETH"}, {Slug: "base", DefiLlama: "Base", Mobula: "Base", NativeSymbol: "ETH"}, + {Slug: "robinhood", DefiLlama: "Robinhood Chain", Mobula: "Robinhood Chain", NativeSymbol: "ETH"}, {Slug: "zksync", DefiLlama: "ZKsync Era", Mobula: "ZkSync", NativeSymbol: "ETH"}, {Slug: "linea", DefiLlama: "Linea", Mobula: "Linea", NativeSymbol: "ETH"}, {Slug: "scroll", DefiLlama: "Scroll", Mobula: "Scroll", NativeSymbol: "ETH"}, @@ -82,4 +83,12 @@ var Registry = []Chain{ // stablecoin (~$1), the wrong asset for a native-token card. {Slug: "fraxtal", DefiLlama: "Fraxtal", Mobula: "", NativeSymbol: "FRXETH"}, {Slug: "soneium", DefiLlama: "Soneium", Mobula: "", NativeSymbol: "ETH"}, + // Polkadot relay chain. DefiLlama tracks the chain name but reports + // zero TVL and 500s on the DEX endpoint: relay chain has no DeFi and + // parachain DeFi (Acala, Moonbeam, Hydration) lives under those slugs. + // Stables endpoint returns real values via Asset Hub USDC/USDT + // issuance. DOT native price and mcap flow through Mobula. The zero + // TVL guard in defillama.go drops the empty TVL card so only real + // cards render. + {Slug: "polkadot", DefiLlama: "Polkadot", Mobula: "", NativeSymbol: "DOT"}, } diff --git a/harnesses/l2-block-time/cmd/script/config.go b/harnesses/l2-block-time/cmd/script/config.go index 98bdb9c4..0879337f 100644 --- a/harnesses/l2-block-time/cmd/script/config.go +++ b/harnesses/l2-block-time/cmd/script/config.go @@ -13,6 +13,12 @@ type L2Chain struct { Slug string Name string URL string + // Poll switches the chain to HTTP block-number polling instead of a + // WebSocket newHeads subscription, for sequencers that expose no + // public WSS (Robinhood Chain). Block time is then derived from + // block-number deltas over each poll window; per-block jitter is + // not captured and the methodology discloses it. + Poll bool } // l2Chains is the source of truth for which Layer-2s appear in the @@ -22,6 +28,12 @@ type L2Chain struct { // rebuild — useful if a public endpoint goes flaky. func l2Chains() []L2Chain { return []L2Chain{ + { + Slug: "robinhood", + Name: "Robinhood Chain", + URL: envDefault("RPC_HTTP_ROBINHOOD", "https://rpc.mainnet.chain.robinhood.com"), + Poll: true, + }, { Slug: "arbitrum", Name: "Arbitrum One", diff --git a/harnesses/l2-block-time/cmd/script/sequencer_poll.go b/harnesses/l2-block-time/cmd/script/sequencer_poll.go new file mode 100644 index 00000000..30a03d10 --- /dev/null +++ b/harnesses/l2-block-time/cmd/script/sequencer_poll.go @@ -0,0 +1,106 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// HTTP polling fallback for sequencers with no public WebSocket +// (Robinhood Chain). Every pollInterval we read eth_blockNumber; a +// window that advanced by N blocks contributes N histogram samples of +// (elapsed / N) ms. This yields an accurate average block time while +// keeping the "one sample per block" weighting of the WS path; what it +// cannot capture is per-block jitter, which the bench methodology +// discloses for polled chains. +const ( + pollInterval = 2 * time.Second + pollTimeout = 10 * time.Second + maxWindowBlend = 600 // safety cap on samples per window (reconnect gaps) +) + +func runPollChain(ch L2Chain) error { + client := &http.Client{Timeout: pollTimeout} + var lastNum int64 + var lastAt time.Time + fails := 0 + + t := time.NewTicker(pollInterval) + defer t.Stop() + fmt.Printf("[%s] HTTP polling mode, interval=%s\n", ch.Slug, pollInterval) + + for now := range t.C { + num, err := fetchBlockNumber(client, ch.URL) + if err != nil || num == 0 { + fails++ + if fails >= 5 { + blockTimeHealth.WithLabelValues(ch.Slug).Set(0) + } + if fails == 5 || fails%150 == 0 { + fmt.Printf("[%s] poll error x%d: %v\n", ch.Slug, fails, err) + } + continue + } + fails = 0 + blockTimeHealth.WithLabelValues(ch.Slug).Set(1) + + if lastNum > 0 && num > lastNum && !lastAt.IsZero() { + delta := num - lastNum + elapsedMs := float64(now.Sub(lastAt).Milliseconds()) + perBlock := elapsedMs / float64(delta) + if perBlock > 0 && perBlock < maxSampleMs { + blockTimeGauge.WithLabelValues(ch.Slug).Set(perBlock) + n := int(delta) + if n > maxWindowBlend { + n = maxWindowBlend + } + for i := 0; i < n; i++ { + blockTimeHistogram.WithLabelValues(ch.Slug).Observe(perBlock) + } + blockTimeSampleCtr.WithLabelValues(ch.Slug).Add(float64(n)) + } + } + lastNum = num + lastAt = now + } + return nil +} + +func fetchBlockNumber(client *http.Client, url string) (int64, error) { + body := []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":%d}`, + time.Now().UnixNano(), + )) + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return 0, err + } + if resp.StatusCode != 200 { + return 0, fmt.Errorf("status %d", resp.StatusCode) + } + var env struct { + Result string `json:"result"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return 0, err + } + n := parseHexInt64(env.Result) + if n == 0 { + return 0, fmt.Errorf("empty block number") + } + return n, nil +} diff --git a/harnesses/l2-block-time/cmd/script/sequencer_ws.go b/harnesses/l2-block-time/cmd/script/sequencer_ws.go index 26d59f69..e9327aaf 100644 --- a/harnesses/l2-block-time/cmd/script/sequencer_ws.go +++ b/harnesses/l2-block-time/cmd/script/sequencer_ws.go @@ -75,7 +75,12 @@ func StartSequencerWS() { go func() { backoff := minBackoff for { - err := runChain(ch) + var err error + if ch.Poll { + err = runPollChain(ch) + } else { + err = runChain(ch) + } blockTimeHealth.WithLabelValues(ch.Slug).Set(0) blockTimeReconnectCtr.WithLabelValues(ch.Slug).Inc() if err != nil { diff --git a/harnesses/perp-fees/cmd/script/aster.go b/harnesses/perp-fees/cmd/script/aster.go new file mode 100644 index 00000000..0480ed83 --- /dev/null +++ b/harnesses/perp-fees/cmd/script/aster.go @@ -0,0 +1,102 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// Aster (BSC-based perp DEX, Binance-style REST). Public no-auth REST for +// the depth endpoint; symbol convention is uppercase USDT-quoted +// (ETHUSDT, BTCUSDT, SOLUSDT). The taker fee is not exposed by any public +// endpoint: the documented base rate (5 bps, docs.asterdex.com) is used +// and disclosed in the spec formula. +// TODO: switch to fee schedule API when Aster ships one. + +const asterBase = "https://fapi.asterdex.com" + +// Documented base taker rate (5 bps = 0.0005). Revisit if Aster ships a +// public fees endpoint. +const asterTakerBps = 5.0 + +type asterDepth struct { + Bids [][]string `json:"bids"` // [price, qty] + Asks [][]string `json:"asks"` +} + +func fetchAster(v VenueConfig) PerpSample { + s := PerpSample{Venue: v.Slug, Asset: v.Asset, At: time.Now().UTC().Format(time.RFC3339)} + start := time.Now() + client := &http.Client{Timeout: 8 * time.Second} + + symbol := v.Asset + "USDT" + s.TakerFeeBps = asterTakerBps + + var book asterDepth + url := fmt.Sprintf("%s/fapi/v1/depth?symbol=%s&limit=100", asterBase, symbol) + if err := asterGet(client, url, &book); err != nil { + s.Err = fmt.Sprintf("orderbook: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + if len(book.Bids) == 0 || len(book.Asks) == 0 { + s.Err = "empty_orderbook" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + if len(book.Bids[0]) < 2 || len(book.Asks[0]) < 2 { + s.Err = "malformed_orderbook" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + bestBid, _ := strconv.ParseFloat(book.Bids[0][0], 64) + bestAsk, _ := strconv.ParseFloat(book.Asks[0][0], 64) + mid := (bestBid + bestAsk) / 2 + if mid <= 0 { + s.Err = "bad_mid" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + s.MidPrice = mid + + levels := make([]bookLevel, 0, len(book.Asks)) + for _, a := range book.Asks { + if len(a) < 2 { + continue + } + px, _ := strconv.ParseFloat(a[0], 64) + sz, _ := strconv.ParseFloat(a[1], 64) + levels = append(levels, bookLevel{Px: px, Sz: sz}) + } + effective, err := walkBookForNotional(levels, v.NotionalUSD) + if err != nil { + s.Err = fmt.Sprintf("walk: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + s.SpreadBps = (effective - mid) / mid * 10000 + s.AllInBps = s.TakerFeeBps + s.SpreadBps + applyBookTiers(&s, levels, mid) + + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s +} + +func asterGet(client *http.Client, url string, out any) error { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpFees/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return json.Unmarshal(body, out) +} diff --git a/harnesses/perp-fees/cmd/script/config.go b/harnesses/perp-fees/cmd/script/config.go index aa2bc52f..89f01d78 100644 --- a/harnesses/perp-fees/cmd/script/config.go +++ b/harnesses/perp-fees/cmd/script/config.go @@ -49,6 +49,16 @@ func loadConfig() *Config { // Extended's taker fee is documented (2.5 bps), not API-exposed; // disclosed in the spec formula. {slug: "extended", display: "Extended", assets: []string{"ETH", "BTC", "SOL"}}, + // Aster (BSC-based, Binance-style REST). Public depth endpoint + // used for the walk; taker fee (5 bps, docs.asterdex.com) is + // hardcoded because no fee schedule API is published — disclosed + // in the spec formula. + {slug: "aster", display: "Aster", assets: []string{"ETH", "BTC", "SOL"}}, + // edgeX (offchain CLOB perp DEX). Public depth endpoint keyed by + // contractId (map hardcoded after a one-time getMetaData lookup); + // taker fee (3.8 bps, edgex-1.gitbook.io) hardcoded because no + // public fee schedule API — disclosed in the spec formula. + {slug: "edgex", display: "edgeX", assets: []string{"ETH", "BTC", "SOL"}}, } venues := make([]VenueConfig, 0, len(defs)*3) diff --git a/harnesses/perp-fees/cmd/script/dydx.go b/harnesses/perp-fees/cmd/script/dydx.go index 5b6d4ebe..43148723 100644 --- a/harnesses/perp-fees/cmd/script/dydx.go +++ b/harnesses/perp-fees/cmd/script/dydx.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "sort" "strconv" "time" ) @@ -62,18 +63,45 @@ func fetchDYdX(v VenueConfig) PerpSample { s.FetchLatencyMs = time.Since(start).Milliseconds() return s } - bestBid, _ := strconv.ParseFloat(book.Bids[0].Price, 64) - bestAsk, _ := strconv.ParseFloat(book.Asks[0].Price, 64) - mid := (bestBid + bestAsk) / 2 - s.MidPrice = mid - // Walk asks - levels := make([]bookLevel, 0, len(book.Asks)) + // Convert and sort. The dYdX v4 indexer's orderbook endpoint returns + // levels in insertion order, NOT sorted by price — walking as-is means + // the "top of book" starts at whichever level was most recently posted, + // which inflates the effective price by a factor that matches the + // observed 1.7-2x too-high headline (asks walked out of order push the + // weighted average well past the true best offer). Sort asks ascending + // and bids descending before consuming. + asks := make([]bookLevel, 0, len(book.Asks)) for _, a := range book.Asks { px, _ := strconv.ParseFloat(a.Price, 64) sz, _ := strconv.ParseFloat(a.Size, 64) - levels = append(levels, bookLevel{Px: px, Sz: sz}) + if px > 0 && sz > 0 { + asks = append(asks, bookLevel{Px: px, Sz: sz}) + } } + bids := make([]bookLevel, 0, len(book.Bids)) + for _, b := range book.Bids { + px, _ := strconv.ParseFloat(b.Price, 64) + sz, _ := strconv.ParseFloat(b.Size, 64) + if px > 0 && sz > 0 { + bids = append(bids, bookLevel{Px: px, Sz: sz}) + } + } + if len(asks) == 0 || len(bids) == 0 { + s.Err = "empty_orderbook" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + sort.Slice(asks, func(i, j int) bool { return asks[i].Px < asks[j].Px }) + sort.Slice(bids, func(i, j int) bool { return bids[i].Px > bids[j].Px }) + + bestBid := bids[0].Px + bestAsk := asks[0].Px + mid := (bestBid + bestAsk) / 2 + s.MidPrice = mid + + // Walk asks (long open = buying against the ask side). + levels := asks effective, err := walkBookForNotional(levels, v.NotionalUSD) if err != nil { s.Err = fmt.Sprintf("walk: %v", err) diff --git a/harnesses/perp-fees/cmd/script/edgex.go b/harnesses/perp-fees/cmd/script/edgex.go new file mode 100644 index 00000000..1b29b527 --- /dev/null +++ b/harnesses/perp-fees/cmd/script/edgex.go @@ -0,0 +1,123 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// edgeX (offchain CLOB perp DEX). Public no-auth REST. Contract IDs are +// resolved via GET /api/v1/public/meta/getMetaData; hardcoded here after a +// one-time lookup (verified via getMetaData on 2026-07-16). The taker fee +// is not exposed by any public endpoint: the documented base rate +// (3.8 bps, edgex-1.gitbook.io) is used and disclosed in the spec formula. +// TODO: switch to fee schedule API when edgeX ships one. + +const edgexBase = "https://pro.edgex.exchange" + +// Documented base taker rate (3.8 bps = 0.00038). Revisit if edgeX ships a +// public fees endpoint. +const edgexTakerBps = 3.8 + +// Asset → contractId map. Verified via getMetaData on 2026-07-16. If edgeX +// re-numbers contracts, update these IDs (or wire in a dynamic lookup). +var edgexContractIDs = map[string]string{ + "ETH": "10000002", + "BTC": "10000001", + "SOL": "10000003", +} + +type edgexDepthResp struct { + Code string `json:"code"` + Data []struct { + Bids []struct { + Price string `json:"price"` + Size string `json:"size"` + } `json:"bids"` + Asks []struct { + Price string `json:"price"` + Size string `json:"size"` + } `json:"asks"` + } `json:"data"` + Msg string `json:"msg"` +} + +func fetchEdgex(v VenueConfig) PerpSample { + s := PerpSample{Venue: v.Slug, Asset: v.Asset, At: time.Now().UTC().Format(time.RFC3339)} + start := time.Now() + client := &http.Client{Timeout: 8 * time.Second} + + contractID, ok := edgexContractIDs[v.Asset] + if !ok { + s.Err = "asset_not_mapped" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + s.TakerFeeBps = edgexTakerBps + + var book edgexDepthResp + url := fmt.Sprintf("%s/api/v1/public/quote/getDepth?contractId=%s&level=100", edgexBase, contractID) + if err := edgexGet(client, url, &book); err != nil { + s.Err = fmt.Sprintf("orderbook: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + if len(book.Data) == 0 { + s.Err = "empty_orderbook" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + d := book.Data[0] + if len(d.Bids) == 0 || len(d.Asks) == 0 { + s.Err = "empty_orderbook" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + bestBid, _ := strconv.ParseFloat(d.Bids[0].Price, 64) + bestAsk, _ := strconv.ParseFloat(d.Asks[0].Price, 64) + mid := (bestBid + bestAsk) / 2 + if mid <= 0 { + s.Err = "bad_mid" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + s.MidPrice = mid + + levels := make([]bookLevel, 0, len(d.Asks)) + for _, a := range d.Asks { + px, _ := strconv.ParseFloat(a.Price, 64) + sz, _ := strconv.ParseFloat(a.Size, 64) + levels = append(levels, bookLevel{Px: px, Sz: sz}) + } + effective, err := walkBookForNotional(levels, v.NotionalUSD) + if err != nil { + s.Err = fmt.Sprintf("walk: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + s.SpreadBps = (effective - mid) / mid * 10000 + s.AllInBps = s.TakerFeeBps + s.SpreadBps + applyBookTiers(&s, levels, mid) + + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s +} + +func edgexGet(client *http.Client, url string, out any) error { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpFees/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return json.Unmarshal(body, out) +} diff --git a/harnesses/perp-fees/cmd/script/gains.go b/harnesses/perp-fees/cmd/script/gains.go index 2d9d1f7f..141b2476 100644 --- a/harnesses/perp-fees/cmd/script/gains.go +++ b/harnesses/perp-fees/cmd/script/gains.go @@ -225,8 +225,13 @@ func findGainsPair(client *http.Client, asset string) (*gainsPair, error) { } gainsCacheMu.Unlock() + // Gains v8 has grown well past 60 pairs; a narrow scan silently misses + // USD-quoted pairs (SOL/USD sits past the first crypto majors) and lets + // a same-symbol non-USD pair win the cache slot. 200 is enough headroom + // for the current listed universe with the 3-consecutive-errors early + // exit still handling the tail. consecutiveErrors := 0 - for i := 0; i < 60; i++ { + for i := 0; i < 200; i++ { p, err := gainsReadPair(client, i) if err != nil { fmt.Printf("[PERP][gains] pair scan idx %d: %v\n", i, err) @@ -240,12 +245,21 @@ func findGainsPair(client *http.Client, asset string) (*gainsPair, error) { if p.From == "" { continue } + // Only cache USD-quoted pairs. Gains lists cross pairs like SOL/BTC + // alongside SOL/USD, and keying the cache by From alone would let a + // non-USD pair (with a different feeIndex/spreadP) win the slot for + // an asset that also has a USD pair. That was the source of the + // uniform-4.333-bps SOL bug: SOL's non-USD pair pinned the cache to + // a crypto-major feeIndex before SOL/USD was reached in the scan. + if !strings.EqualFold(p.To, "USD") { + continue + } gainsCacheMu.Lock() gainsPairCache[strings.ToUpper(p.From)] = p gainsPairIdxCache[strings.ToUpper(p.From)] = i gainsPairAt[strings.ToUpper(p.From)] = time.Now() gainsCacheMu.Unlock() - if strings.EqualFold(p.From, asset) && strings.EqualFold(p.To, "USD") { + if strings.EqualFold(p.From, asset) { return p, nil } time.Sleep(150 * time.Millisecond) diff --git a/harnesses/perp-fees/cmd/script/gmx.go b/harnesses/perp-fees/cmd/script/gmx.go index 6c2b8fb7..5bd6637b 100644 --- a/harnesses/perp-fees/cmd/script/gmx.go +++ b/harnesses/perp-fees/cmd/script/gmx.go @@ -38,11 +38,18 @@ type gmxGqlReq struct { type gmxMarketInfo struct { Data struct { MarketInfos []struct { - ID string `json:"id"` - PositionFeeFactorForPositiveImpact string `json:"positionFeeFactorForPositiveImpact"` - PositionFeeFactorForNegativeImpact string `json:"positionFeeFactorForNegativeImpact"` + ID string `json:"id"` + PositionFeeFactorForPositiveImpact string `json:"positionFeeFactorForPositiveImpact"` + PositionFeeFactorForNegativeImpact string `json:"positionFeeFactorForNegativeImpact"` + // Some Subsquid deployments expose an unsuffixed field name. + // We fall back to it when the impact-branch fields are absent + // so a schema rename does not silently zero the fee. + PositionFeeFactor string `json:"positionFeeFactor"` } `json:"marketInfos"` } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` } type gmxMarketsRESTItem struct { @@ -73,7 +80,7 @@ func fetchGMX(v VenueConfig) PerpSample { // 1) Position fee from Subsquid (live on-chain factor) q := gmxGqlReq{ Query: `query { marketInfos(where: {id_eq: "` + market + `"}) { - id positionFeeFactorForPositiveImpact positionFeeFactorForNegativeImpact + id positionFeeFactorForPositiveImpact positionFeeFactorForNegativeImpact positionFeeFactor }}`, } bodyBytes, _ := json.Marshal(q) @@ -81,32 +88,58 @@ func fetchGMX(v VenueConfig) PerpSample { req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { - s.Err = fmt.Sprintf("gql: %v", err) + s.Err = fmt.Sprintf("subsquid_failed: %v", err) s.FetchLatencyMs = time.Since(start).Milliseconds() return s } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != 200 { - s.Err = fmt.Sprintf("gql_status_%d: %s", resp.StatusCode, truncate(string(respBody), 200)) + s.Err = fmt.Sprintf("subsquid_failed: status_%d: %s", resp.StatusCode, truncate(string(respBody), 200)) s.FetchLatencyMs = time.Since(start).Milliseconds() return s } var info gmxMarketInfo if err := json.Unmarshal(respBody, &info); err != nil { - s.Err = fmt.Sprintf("gql_parse: %v", err) + s.Err = fmt.Sprintf("subsquid_failed: parse: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + if len(info.Errors) > 0 { + s.Err = fmt.Sprintf("subsquid_failed: gql_error: %s", info.Errors[0].Message) s.FetchLatencyMs = time.Since(start).Milliseconds() return s } if len(info.Data.MarketInfos) == 0 { - s.Err = "market_not_found" + s.Err = "subsquid_failed: market_not_found" s.FetchLatencyMs = time.Since(start).Milliseconds() return s } m := info.Data.MarketInfos[0] // FLOAT_PRECISION = 1e30. positionFeeFactor = factor * 1e30. // e.g. "600000000000000000000000000" = 0.0006 = 6 bps. - negativeFee := factor1e30ToBps(m.PositionFeeFactorForNegativeImpact) + // + // Branch choice: report the NEGATIVE-impact factor. Opening a long + // against a bid-heavy book (or a short against an ask-heavy book) pays + // the negative-impact branch, which is the worst-case opening cost and + // matches the "all-in fee at open" methodology used for the CEXes here. + // If the schema is on an older/unsuffixed version we fall back to + // positionFeeFactor so a rename does not silently zero the reported fee. + rawFactor := m.PositionFeeFactorForNegativeImpact + if rawFactor == "" { + rawFactor = m.PositionFeeFactor + } + if rawFactor == "" { + s.Err = "subsquid_failed: missing positionFeeFactor fields" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + negativeFee := factor1e30ToBps(rawFactor) + if negativeFee == 0 { + s.Err = fmt.Sprintf("subsquid_failed: zero fee factor for market %s", market) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } s.TakerFeeBps = negativeFee // On GMX there's no orderbook spread — slippage = priceImpact, which // is ~0 for $1k notional vs. multi-million pool TVL. diff --git a/harnesses/perp-fees/cmd/script/main.go b/harnesses/perp-fees/cmd/script/main.go index 6026812d..9cbbf998 100644 --- a/harnesses/perp-fees/cmd/script/main.go +++ b/harnesses/perp-fees/cmd/script/main.go @@ -105,6 +105,10 @@ func fetchOne(v VenueConfig, cfg *Config) PerpSample { return fetchParadex(v) case "extended": return fetchExtended(v) + case "aster": + return fetchAster(v) + case "edgex": + return fetchEdgex(v) default: return PerpSample{Venue: v.Slug, Asset: v.Asset, Err: "unsupported_venue"} } diff --git a/harnesses/perp-fees/cmd/script/paradex.go b/harnesses/perp-fees/cmd/script/paradex.go index 5a03984d..25d8fe17 100644 --- a/harnesses/perp-fees/cmd/script/paradex.go +++ b/harnesses/perp-fees/cmd/script/paradex.go @@ -83,7 +83,13 @@ func fetchParadex(v VenueConfig) PerpSample { sz, _ := strconv.ParseFloat(a[1], 64) levels = append(levels, bookLevel{Px: px, Sz: sz}) } - effective, err := walkBookForNotional(levels, v.NotionalUSD) + // Paradex REST caps depth at 100 levels. When the walker eats through + // more than ~90% of the visible book the "effective price" is dominated + // by the tail levels and stops being a real quote (methodology: skip + // when depth thins out). Cap the fill ratio to skip such tiers rather + // than publish an inflated bps. + const paradexMaxFillRatio = 0.9 + effective, err := walkBookForNotionalCapped(levels, v.NotionalUSD, paradexMaxFillRatio) if err != nil { s.Err = fmt.Sprintf("walk: %v", err) s.FetchLatencyMs = time.Since(start).Milliseconds() @@ -91,7 +97,7 @@ func fetchParadex(v VenueConfig) PerpSample { } s.SpreadBps = (effective - mid) / mid * 10000 s.AllInBps = s.TakerFeeBps + s.SpreadBps - applyBookTiers(&s, levels, mid) + applyBookTiersCapped(&s, levels, mid, paradexMaxFillRatio) // 3) Funding: per 8h period, normalize to per hour. var fund paradexFunding diff --git a/harnesses/perp-fees/cmd/script/walk.go b/harnesses/perp-fees/cmd/script/walk.go index 2ce71976..6f707661 100644 --- a/harnesses/perp-fees/cmd/script/walk.go +++ b/harnesses/perp-fees/cmd/script/walk.go @@ -50,6 +50,33 @@ func walkBookForNotional(levels []bookLevel, notional float64) (float64, error) return notional / totalQty, nil } +// totalBookNotional sums price*size across every level, giving the total +// visible USD depth on this side of the book. +func totalBookNotional(levels []bookLevel) float64 { + total := 0.0 + for _, l := range levels { + if l.Px <= 0 || l.Sz <= 0 { + continue + } + total += l.Px * l.Sz + } + return total +} + +// walkBookForNotionalCapped walks like walkBookForNotional but returns an +// error when the walk would consume more than maxFillRatio of the total +// visible book depth. Depth-capped venues (Paradex depth=100) can otherwise +// publish an eaten-through effective price when the tier notional is close +// to or larger than the visible book. Use this for venues where the fetched +// depth is a hard ceiling on visible liquidity. +func walkBookForNotionalCapped(levels []bookLevel, notional, maxFillRatio float64) (float64, error) { + total := totalBookNotional(levels) + if total > 0 && notional > total*maxFillRatio { + return 0, fmt.Errorf("book_too_thin: %.2f of visible %.2f > %.0f%%", notional, total, maxFillRatio*100) + } + return walkBookForNotional(levels, notional) +} + // applyBookTiers walks the already-fetched ask levels once per tier notional // and fills s.Tiers with taker fee + tier spread. Call it after s.TakerFeeBps // is known. Costs no extra API calls: the book was fetched for the headline @@ -76,6 +103,27 @@ func applyBookTiers(s *PerpSample, levels []bookLevel, mid float64) { } } +// applyBookTiersCapped is like applyBookTiers but skips any tier whose +// notional would consume more than maxFillRatio of the visible book depth. +// Use this on venues that expose a hard depth cap (e.g. Paradex depth=100) +// where the top of a shallow book gives a misleading "effective price" when +// the walk eats through the entire visible side. +func applyBookTiersCapped(s *PerpSample, levels []bookLevel, mid, maxFillRatio float64) { + for _, n := range tierNotionals { + effective, err := walkBookForNotionalCapped(levels, n, maxFillRatio) + if err != nil { + s.SkippedTiers = append(s.SkippedTiers, notionalLabel(n)) + continue + } + spread := (effective - mid) / mid * 10000 + s.Tiers = append(s.Tiers, TierSample{ + Notional: notionalLabel(n), + SpreadBps: spread, + AllInBps: s.TakerFeeBps + spread, + }) + } +} + // applyFlatTiers publishes the same all-in figure at every tier. Used by the // oracle-priced venues (GMX v2, gains.trade) where the harness-read cost is // a fixed fraction of position size, so the bps figure does not change with diff --git a/harnesses/rpc-capabilities/cmd/script/archive.go b/harnesses/rpc-capabilities/cmd/script/archive.go index 8362f56b..1c9e74e6 100644 --- a/harnesses/rpc-capabilities/cmd/script/archive.go +++ b/harnesses/rpc-capabilities/cmd/script/archive.go @@ -43,9 +43,12 @@ var depthBuckets = []uint64{300, 7200, 216000, 1296000, 5000000} func StartArchiveLoop(ctx context.Context) { for _, c := range chains() { c := c - if c.Kind == "solana" { - // eth_getBalance at historical heights has no Solana - // equivalent on public endpoints; skip the archive loop. + if c.Kind == "solana" || c.Kind == "polkadot" { + // eth_getBalance at historical heights has no Solana or + // Substrate equivalent on public endpoints (Polkadot state + // is accessed via state_getStorage keyed by a Blake2 + // hashed storage key, no chain-agnostic depth analog); + // skip the archive loop. continue } for _, p := range c.Providers { diff --git a/harnesses/rpc-capabilities/cmd/script/config.go b/harnesses/rpc-capabilities/cmd/script/config.go index c759b0b5..927612b8 100644 --- a/harnesses/rpc-capabilities/cmd/script/config.go +++ b/harnesses/rpc-capabilities/cmd/script/config.go @@ -29,9 +29,26 @@ type Chain struct { Slug string Name string Providers []Provider - // Kind selects the probe path: "" (EVM, eth_getBlockByNumber) or - // "solana" (getSlot at processed commitment, slot-based staleness, - // no archive-depth loop). + // Kind selects the probe path: + // "" or "evm": eth_getBlockByNumber("latest", false), block-based + // staleness, archive-depth loop. + // "solana": getSlot at processed commitment, slot-based staleness, + // no archive-depth loop. + // "polkadot": chain_getHeader against Substrate JSON-RPC, + // block-based staleness (Polkadot relay produces one block every + // ~6 s so staleBlockGap needs a Polkadot-specific override, see + // polkadotStaleBlockGap), no archive-depth loop (Polkadot's + // state model does not map onto the eth_getBalance-by-depth + // probe cleanly). + // "cosmos": Tendermint / CometBFT `status` method against the + // Cosmos JSON-RPC, block-based staleness (Osmosis ~6 s blocks, + // see cosmosStaleBlockGap), no archive-depth loop. Cosmos chains + // access historical state via ABCI queries keyed on module + + // KV-store, which does not map onto the flat eth_getBalance + // probe. Consensus quorum is opt-out for now because + // latest_block_hash on Tendermint changes on every block just + // like EVM, but a first pass ships without it to keep the + // initial Osmosis add-on isolated to latency + reliability. Kind string } @@ -57,8 +74,60 @@ type Chain struct { // (drpc caches eth_blockNumber → only 2 clean providers), opBNB // (1rpc 429s at probe cadence, only 3 solid providers), Mode // (3 providers), Zora / Abstract / HyperEVM (≤2 keyless providers). +// +// Local-run filter: OCB_CHAINS=ethereum,base restricts the matrix to +// the listed slugs (unset or unmatched = full matrix). Used for local +// smoke runs; never set in production. func chains() []Chain { - return []Chain{ + all := []Chain{ + // ─── Polkadot relay chain — first non-EVM, non-Solana chain + // added to the cohort. Substrate JSON-RPC via chain_getHeader + // (returns hex block number, staleness by relay-block gap). + // Providers verified keyless with 4 consecutive probes on + // 2026-07-15: rpc.polkadot.io (Parity official), OnFinality + // public gateway, and PublicNode (Allnodes). Excluded by that + // sweep: 1RPC (chain_getHeader filtered as "Not Allowed" on + // their privacy relay), Dwellir (503 Service Unavailable + // during audit, retry when their gateway stabilises), Ankr + // (paid Polkadot tier only), Chainstack (key-gated), + // RadiumBlock (empty response without referral header), Grove/ + // Thirdweb (invalid-chain or key-gated). + { + Slug: "polkadot", + Name: "Polkadot", + Kind: "polkadot", + Providers: []Provider{ + {Slug: "polkadot-official", Name: "Parity", URL: envDefault("RPC_URL_POLKADOT_OFFICIAL", "https://rpc.polkadot.io")}, + {Slug: "onfinality", Name: "OnFinality", URL: envDefault("RPC_URL_POLKADOT_ONFINALITY", "https://polkadot.api.onfinality.io/public")}, + {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_POLKADOT_PUBLICNODE", "https://polkadot-rpc.publicnode.com")}, + }, + }, + // ─── Osmosis (Cosmos SDK, CometBFT / Tendermint) — first + // Cosmos chain in the cluster. Probed via Tendermint JSON-RPC + // `status` (returns sync_info.latest_block_height + + // latest_block_hash). Providers verified keyless with a live + // `status` POST returning a parsable decimal height on + // 2026-07-23. Excluded by that sweep: Lava + // (osmosis.tendermintrpc.lava.build 403 without a key despite + // the "public" branding), OnFinality (public osmosis endpoint + // timing out at probe cadence, unlike their stable Polkadot + // gateway), Numia (401 without key), AutoStake (404, path may + // have moved), BlockApsis + WhisperNode + Enigma-Validator + + // StakeTown + reece.sh (curl-side connect errors, likely IPv6- + // only or geo-gated). Ankr and Chainstack require paid Cosmos + // tiers. + { + Slug: "osmosis", + Name: "Osmosis", + Kind: "cosmos", + Providers: []Provider{ + {Slug: "osmosis-official", Name: "Osmosis Foundation", URL: envDefault("RPC_URL_OSMOSIS_OFFICIAL", "https://rpc.osmosis.zone")}, + {Slug: "polkachu", Name: "Polkachu", URL: envDefault("RPC_URL_OSMOSIS_POLKACHU", "https://osmosis-rpc.polkachu.com")}, + {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_OSMOSIS_PUBLICNODE", "https://osmosis-rpc.publicnode.com")}, + {Slug: "imperator", Name: "Imperator", URL: envDefault("RPC_URL_OSMOSIS_IMPERATOR", "https://rpc-osmosis.imperator.co")}, + {Slug: "lavenderfive", Name: "LavenderFive", URL: envDefault("RPC_URL_OSMOSIS_LAVENDERFIVE", "https://rpc.lavenderfive.com:443/osmosis")}, + }, + }, // ─── Solana mainnet — added 2026-07-12, all 5 endpoints keyless // and live-verified (getSlot + getLatestBlockhash + getVersion, // mutually consistent advancing slots). Excluded by that sweep: @@ -73,7 +142,7 @@ func chains() []Chain { Name: "Solana", Kind: "solana", Providers: []Provider{ - {Slug: "solana-official", Name: "Solana Labs", URL: envDefault("RPC_URL_SOLANA_OFFICIAL", "https://api.mainnet-beta.solana.com")}, + {Slug: "solana", Name: "Solana", URL: envDefault("RPC_URL_SOLANA_OFFICIAL", "https://api.mainnet-beta.solana.com")}, {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_SOLANA_PUBLICNODE", "https://solana-rpc.publicnode.com")}, {Slug: "lava", Name: "Lava", URL: envDefault("RPC_URL_SOLANA_LAVA", "https://solana.lava.build")}, {Slug: "leorpc", Name: "LeoRPC", URL: envDefault("RPC_URL_SOLANA_LEORPC", "https://solana.leorpc.com/?api_key=FREE")}, @@ -120,6 +189,9 @@ func chains() []Chain { {Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_ETHEREUM_TENDERLY", "https://gateway.tenderly.co/public/mainnet")}, {Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_ETHEREUM_NODIES", "https://eth-pokt.nodies.app")}, {Slug: "lava", Name: "Lava Network", URL: envDefault("RPC_URL_ETHEREUM_LAVA", "https://eth1.lava.build")}, + {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_ETHEREUM_BLASTAPI", "https://eth-mainnet.public.blastapi.io")}, + {Slug: "gatewayfm", Name: "Gateway.fm", URL: envDefault("RPC_URL_ETHEREUM_GATEWAYFM", "https://rpc.eth.gateway.fm")}, + {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_ETHEREUM_BLOXROUTE", "https://eth.rpc.blxrbdn.com")}, }, }, // ─── Polygon PoS (5 providers) ────────────────────────────── @@ -145,6 +217,7 @@ func chains() []Chain { {Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_ARBITRUM_NODIES", "https://arb-pokt.nodies.app")}, {Slug: "lava", Name: "Lava Network", URL: envDefault("RPC_URL_ARBITRUM_LAVA", "https://arb1.lava.build")}, {Slug: "arbitrum-official", Name: "Arbitrum Official", URL: envDefault("RPC_URL_ARBITRUM_OFFICIAL", "https://arb1.arbitrum.io/rpc")}, + {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_ARBITRUM_BLASTAPI", "https://arbitrum-one.public.blastapi.io")}, }, }, // ─── Optimism (6 providers) ───────────────────────────────── @@ -169,6 +242,8 @@ func chains() []Chain { {Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_BASE_TENDERLY", "https://gateway.tenderly.co/public/base")}, {Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_BASE_NODIES", "https://base-pokt.nodies.app")}, {Slug: "base-official", Name: "Base Official", URL: envDefault("RPC_URL_BASE_OFFICIAL", "https://mainnet.base.org")}, + {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_BASE_BLASTAPI", "https://base-mainnet.public.blastapi.io")}, + {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_BASE_BLOXROUTE", "https://base.rpc.blxrbdn.com")}, }, }, // ─── BNB Chain (5 providers) ──────────────────────────────── @@ -180,6 +255,8 @@ func chains() []Chain { {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_BNB_DRPC", "https://bsc.drpc.org")}, {Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_BNB_NODIES", "https://bsc-pokt.nodies.app")}, {Slug: "binance", Name: "Binance Official", URL: envDefault("RPC_URL_BNB_OFFICIAL", "https://bsc-dataseed1.binance.org")}, + {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_BNB_BLASTAPI", "https://bsc-mainnet.public.blastapi.io")}, + {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_BNB_BLOXROUTE", "https://bsc.rpc.blxrbdn.com")}, }, }, // ─── Avalanche C-Chain (6 providers) ──────────────────────── @@ -363,7 +440,73 @@ func chains() []Chain { {Slug: "soneium-official", Name: "Soneium Official", URL: envDefault("RPC_URL_SONEIUM_OFFICIAL", "https://rpc.soneium.org")}, }, }, + // ─── Hyperliquid HyperEVM (chain 999) — added 2026-07-24. Standard + // EVM JSON-RPC surface bolted onto the HyperCore perps engine. + // Providers live-verified keyless via eth_blockNumber during launch + // audit: hyperliquid-official (rpc.hyperliquid.xyz/evm), dRPC, + // Stakely, Purroof Group, Hypurrscan. Excluded by that sweep: + // Alchemy demo (rate-limited dead), thirdweb (`Invalid chain` + // error on HyperEVM), Grove/Pocket public LB (needs app id at + // public LB path), AllThatNode + Blast API + Chainstack + Gelato + // + Imperator HyperEVM guesses (DNS-fail or 401 without key), + // PublicNode (no HyperEVM subdomain yet). + { + Slug: "hyperliquid", + Name: "Hyperliquid", + Providers: []Provider{ + {Slug: "hyperliquid-official", Name: "Hyperliquid Labs", URL: envDefault("RPC_URL_HYPERLIQUID_OFFICIAL", "https://rpc.hyperliquid.xyz/evm")}, + {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_HYPERLIQUID_DRPC", "https://hyperliquid.drpc.org")}, + {Slug: "stakely", Name: "Stakely", URL: envDefault("RPC_URL_HYPERLIQUID_STAKELY", "https://hyperliquid-json-rpc.stakely.io")}, + {Slug: "purroofgroup", Name: "Purroof Group", URL: envDefault("RPC_URL_HYPERLIQUID_PURROOF", "https://rpc.purroofgroup.com")}, + {Slug: "hypurrscan", Name: "Hypurrscan", URL: envDefault("RPC_URL_HYPERLIQUID_HYPURRSCAN", "https://rpc.hypurrscan.io")}, + }, + }, + // ─── TRON (JSON-RPC compat surface only) — added 2026-07-24. + // TRON exposes both a native REST API (wallet/getnowblock) and + // an EVM-compatible JSON-RPC surface at /jsonrpc. We probe the + // JSON-RPC surface because that is the path every cross-chain + // wallet + TronWeb/EVM bridge integrates against. Providers + // live-verified keyless via eth_blockNumber during launch + // audit: TronGrid (api.trongrid.io/jsonrpc — Tron Foundation + // official), dRPC and PublicNode/Allnodes. Excluded by that + // sweep: Ankr (API key required despite public branding), + // Chainstack + NOWNodes + GetBlock + Tatum + BlockPI (all + // require API key on TRON JSON-RPC path), OnFinality + Blast + // API + AllThatNode (no public TRON JSON-RPC gateway). The + // TRON JSON-RPC provider market is materially smaller than + // EVM — most TRON infra vendors expose only the native TRON + // REST API keyless. Native REST API surface is out of scope + // for this cluster; a `tron-rest` bench would need a + // chain-specific probe. + { + Slug: "tron", + Name: "TRON", + Providers: []Provider{ + {Slug: "trongrid", Name: "TronGrid", URL: envDefault("RPC_URL_TRON_TRONGRID", "https://api.trongrid.io/jsonrpc")}, + {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_TRON_DRPC", "https://tron.drpc.org")}, + {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_TRON_PUBLICNODE", "https://tron.publicnode.com/jsonrpc")}, + }, + }, + } + + filter := strings.TrimSpace(os.Getenv("OCB_CHAINS")) + if filter == "" { + return all + } + keep := make(map[string]bool) + for _, s := range strings.Split(filter, ",") { + keep[strings.TrimSpace(s)] = true + } + var out []Chain + for _, c := range all { + if keep[c.Slug] { + out = append(out, c) + } + } + if len(out) == 0 { + return all } + return out } func envDefault(key, def string) string { diff --git a/harnesses/rpc-capabilities/cmd/script/consensus.go b/harnesses/rpc-capabilities/cmd/script/consensus.go new file mode 100644 index 00000000..695567e5 --- /dev/null +++ b/harnesses/rpc-capabilities/cmd/script/consensus.go @@ -0,0 +1,154 @@ +package main + +import "sync" + +// consensus.go — bench 083 rpc-reliability, cross-provider head +// agreement. The latency probe already fetches the full latest header +// (anti-cache design) and used to throw the hash away; here every +// successful probe feeds a per-chain height→hash quorum map so we can +// publish two things the latency bench cannot see: +// +// rpc_consensus_lag_blocks — how far this provider's head sits +// behind the highest head any probed provider reported for the +// same chain (the chainTips rolling max). Zero for a provider at +// the shared tip; 1-2 for a gateway one block behind (observed +// live on 1rpc/tenderly); tens for something wedged. +// +// rpc_hash_mismatch_total — the provider's hash at height H +// disagrees with the hash a >=2-provider strict plurality agreed +// on at H. Same-height hash divergence is the serious signal +// (serving a non-canonical or fabricated block), so it is a +// counter, incremented at most once per (provider, height). +// +// Reorg honesty: during a real reorg two providers can legitimately +// sit on different hashes at the same height. quorumHash requires a +// strict plurality (>=2 votes AND strictly more than any competing +// hash), so a 2-2 split counts nobody. Only a provider outvoted by an +// established majority is flagged. + +const ( + // quorumMinProviders: minimum providers agreeing on one hash + // before that hash is treated as canonical at its height. + quorumMinProviders = 2 + // consensusPruneDepth: heights this far below the chain tip are + // dropped from the vote map so memory stays bounded (~2 blocks/s + // chains would otherwise grow forever). + consensusPruneDepth uint64 = 128 +) + +type heightVotes struct { + hash map[string]string // provider -> reported hash + flagged map[string]bool // providers already counted at this height +} + +type chainConsensus struct { + heights map[uint64]*heightVotes +} + +type consensusTracker struct { + mu sync.Mutex + chains map[string]*chainConsensus +} + +func newConsensusTracker() *consensusTracker { + return &consensusTracker{chains: make(map[string]*chainConsensus)} +} + +var consensus = newConsensusTracker() + +// observe records one valid head probe. Called from probeOne for +// results classified ok or stale (a stale block is still a valid +// (height, hash) observation — its lag is exactly the point). hash is +// "" on the Solana path (getSlot carries no hash): lag is emitted, +// quorum voting is skipped. +func (t *consensusTracker) observe(chain, provider string, height uint64, hash string) { + // Lag against the cross-provider rolling max the staleness check + // already maintains. The caller updates tips before observing, so + // a provider that IS the tip reads 0. Noise note: tips is updated + // asynchronously by per-provider goroutines staggered across the + // probe interval, so ±1 block of jitter on fast chains is expected + // and averages out in the 24h quantiles. + tip := tips.get(chain) + lag := 0.0 + if tip > height { + lag = float64(tip - height) + } + rpcConsensusLag.WithLabelValues(provider, chain, currentRegion).Set(lag) + + if hash == "" { + return + } + + t.mu.Lock() + defer t.mu.Unlock() + + cc := t.chains[chain] + if cc == nil { + cc = &chainConsensus{heights: make(map[uint64]*heightVotes)} + t.chains[chain] = cc + } + hv := cc.heights[height] + if hv == nil { + hv = &heightVotes{hash: make(map[string]string), flagged: make(map[string]bool)} + cc.heights[height] = hv + } + hv.hash[provider] = hash + + if q := quorumHash(hv.hash); q != "" { + for p, h := range hv.hash { + if h != q && !hv.flagged[p] { + hv.flagged[p] = true + rpcHashMismatch.WithLabelValues(p, chain, currentRegion).Inc() + } + } + } + + for h := range cc.heights { + if h+consensusPruneDepth < tip { + delete(cc.heights, h) + } + } +} + +// quorumHash returns the hash backed by >= quorumMinProviders votes +// AND strictly more votes than any competing hash. Ties (the 2-2 reorg +// split) return "" so no side is punished without a real majority. +func quorumHash(votes map[string]string) string { + counts := make(map[string]int, len(votes)) + for _, h := range votes { + counts[h]++ + } + best, bestN, secondN := "", 0, 0 + for h, n := range counts { + if n > bestN { + best, secondN, bestN = h, bestN, n + } else if n > secondN { + secondN = n + } + } + if bestN >= quorumMinProviders && bestN > secondN { + return best + } + return "" +} + +// initConsensusMetrics zero-initializes the mismatch counters for the +// full (provider × chain) matrix so `increase()` in the bench's +// incident queries resolves to 0 instead of an absent series for +// providers that never misbehave (which is, hopefully, most of them). +func initConsensusMetrics() { + for _, c := range chains() { + if c.Kind == "solana" || c.Kind == "polkadot" { + // solana: getSlot returns a number only, no hash to vote on. + // polkadot: chain_getHeader returns parentHash (N-1) but no + // current block hash; deriving it needs Blake2 over the + // SCALE-encoded header, which is not worth wiring for a + // bench 083 cross-provider quorum check that already has + // value on 20+ EVM chains. + continue + } + for _, p := range c.Providers { + rpcHashMismatch.WithLabelValues(p.Slug, c.Slug, currentRegion).Add(0) + } + } +} diff --git a/harnesses/rpc-capabilities/cmd/script/consensus_test.go b/harnesses/rpc-capabilities/cmd/script/consensus_test.go new file mode 100644 index 00000000..27e36d65 --- /dev/null +++ b/harnesses/rpc-capabilities/cmd/script/consensus_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestQuorumHash(t *testing.T) { + cases := []struct { + name string + votes map[string]string + want string + }{ + {"empty", map[string]string{}, ""}, + {"single vote no quorum", map[string]string{"a": "0xaa"}, ""}, + {"two agree", map[string]string{"a": "0xaa", "b": "0xaa"}, "0xaa"}, + {"majority beats minority", map[string]string{"a": "0xaa", "b": "0xaa", "c": "0xbb"}, "0xaa"}, + {"reorg 2-2 split counts nobody", map[string]string{"a": "0xaa", "b": "0xaa", "c": "0xbb", "d": "0xbb"}, ""}, + {"3-2 split resolves", map[string]string{"a": "0xaa", "b": "0xaa", "c": "0xaa", "d": "0xbb", "e": "0xbb"}, "0xaa"}, + {"all distinct", map[string]string{"a": "0xaa", "b": "0xbb", "c": "0xcc"}, ""}, + } + for _, tc := range cases { + if got := quorumHash(tc.votes); got != tc.want { + t.Errorf("%s: quorumHash = %q, want %q", tc.name, got, tc.want) + } + } +} + +func TestObserveFlagsMinorityOnce(t *testing.T) { + tr := newConsensusTracker() + chain := "testchain-mismatch" + tips.update(chain, 100) + + mismatches := func(p string) float64 { + return testutil.ToFloat64(rpcHashMismatch.WithLabelValues(p, chain, currentRegion)) + } + + // First vote: no quorum yet, nobody flagged. + tr.observe(chain, "alpha", 100, "0xaa") + if got := mismatches("alpha"); got != 0 { + t.Fatalf("alpha flagged before any quorum existed: %v", got) + } + + // Quorum forms on 0xaa; gamma disagrees and is flagged exactly once. + tr.observe(chain, "beta", 100, "0xaa") + tr.observe(chain, "gamma", 100, "0xbb") + if got := mismatches("gamma"); got != 1 { + t.Fatalf("gamma mismatch count = %v, want 1", got) + } + if mismatches("alpha") != 0 || mismatches("beta") != 0 { + t.Fatalf("quorum members were flagged") + } + + // Re-reporting the same bad hash at the same height must not + // double-count: 60s probes revisit heights on slow chains. + tr.observe(chain, "gamma", 100, "0xbb") + if got := mismatches("gamma"); got != 1 { + t.Fatalf("gamma double-counted at same height: %v", got) + } + + // A different height is a new incident. + tr.observe(chain, "alpha", 101, "0xcc") + tr.observe(chain, "beta", 101, "0xcc") + tr.observe(chain, "gamma", 101, "0xdd") + if got := mismatches("gamma"); got != 2 { + t.Fatalf("gamma mismatch count after 2nd height = %v, want 2", got) + } +} + +func TestObserveReorgSplitCountsNobody(t *testing.T) { + tr := newConsensusTracker() + chain := "testchain-reorg" + tips.update(chain, 50) + + tr.observe(chain, "a", 50, "0xaa") + tr.observe(chain, "b", 50, "0xaa") + tr.observe(chain, "c", 50, "0xbb") + tr.observe(chain, "d", 50, "0xbb") + // c was flagged while 0xaa held a 2-1 plurality; d's vote made it + // 2-2, and from that point no NEW flags may appear. + before := testutil.ToFloat64(rpcHashMismatch.WithLabelValues("d", chain, currentRegion)) + if before != 0 { + t.Fatalf("d flagged on a 2-2 split: %v", before) + } +} + +func TestObserveConsensusLagGauge(t *testing.T) { + tr := newConsensusTracker() + chain := "testchain-lag" + tips.update(chain, 200) + + tr.observe(chain, "laggy", 197, "0xaa") + got := testutil.ToFloat64(rpcConsensusLag.WithLabelValues("laggy", chain, currentRegion)) + if got != 3 { + t.Fatalf("lag gauge = %v, want 3", got) + } + + // Provider at (or ahead of) the recorded tip reads 0. + tr.observe(chain, "fresh", 200, "0xaa") + if got := testutil.ToFloat64(rpcConsensusLag.WithLabelValues("fresh", chain, currentRegion)); got != 0 { + t.Fatalf("fresh lag gauge = %v, want 0", got) + } + + // Solana path: empty hash emits lag but never votes. + tr.observe(chain, "solananode", 195, "") + if got := testutil.ToFloat64(rpcConsensusLag.WithLabelValues("solananode", chain, currentRegion)); got != 5 { + t.Fatalf("solana lag gauge = %v, want 5", got) + } + if got := testutil.ToFloat64(rpcHashMismatch.WithLabelValues("solananode", chain, currentRegion)); got != 0 { + t.Fatalf("hashless observation voted: %v", got) + } +} + +func TestConsensusPruning(t *testing.T) { + tr := newConsensusTracker() + chain := "testchain-prune" + tips.update(chain, 10) + + tr.observe(chain, "a", 10, "0xaa") + tips.update(chain, 10+consensusPruneDepth+5) + tr.observe(chain, "a", 10+consensusPruneDepth+5, "0xbb") + + tr.mu.Lock() + defer tr.mu.Unlock() + if _, ok := tr.chains[chain].heights[10]; ok { + t.Fatalf("height 10 not pruned at tip %d", 10+consensusPruneDepth+5) + } +} + +func TestMajority(t *testing.T) { + if v, ok := majority(map[int]int{1371: 3, 1370: 1}); !ok || v != 1371 { + t.Fatalf("majority(3-1) = %v %v", v, ok) + } + if _, ok := majority(map[int]int{1371: 2, 1370: 2}); ok { + t.Fatalf("tie must not resolve") + } + if _, ok := majority(map[int]int{1371: 1}); ok { + t.Fatalf("single answer must not resolve") + } + if v, ok := majority(map[string]int{"0x1": 2}); !ok || v != "0x1" { + t.Fatalf("majority(2-0) = %v %v", v, ok) + } +} diff --git a/harnesses/rpc-capabilities/cmd/script/integrity.go b/harnesses/rpc-capabilities/cmd/script/integrity.go new file mode 100644 index 00000000..e8663ed4 --- /dev/null +++ b/harnesses/rpc-capabilities/cmd/script/integrity.go @@ -0,0 +1,304 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// integrity.go — bench 083 rpc-reliability, fixed-vector correctness +// checks. Every 5 minutes ONE chain (rotating through the set below) +// gets the same two questions asked of every provider serving it: +// +// logs — `eth_getLogs` for the chain's canonical USDC contract +// over a 10-block window ending at tip-N. Completeness: +// a provider silently dropping logs (or blocking the +// method, or gating the depth behind a paid tier) shows +// up against the cross-provider majority count. +// balance — `eth_getBalance` of a fixed well-known address at the +// same tip-N block. Consistency: the hex answer must be +// byte-identical across providers; the comparison works +// whether or not the balance is non-zero. +// +// Anti-gaming: N rotates daily over {20, 30, 40, 50, 60} blocks so a +// provider cannot special-case a fixed range, and every request id +// rotates (same edge-cache defeat as the latency probe). All depths +// stay far inside non-archive territory so pruned-but-honest nodes +// are never penalized; a provider that gates even 60-blocks-deep data +// behind a key (observed live: publicnode -32602 "Archive requests +// require a personal token") is emitting exactly the signal this +// bench exists to record. +// +// Errors ARE signal: result="error" on rpc_integrity_check_total is +// counted as an incident by the spec, because "method blocked" and +// "depth gated" are reliability failures from the caller's seat. + +const ( + integrityInterval = 5 * time.Minute + integrityTimeout = 15 * time.Second + // integrityCallSpacing: sequential per-provider spacing; meowrpc + // 429s on rapid bursts (same lesson as archive.go). + integrityCallSpacing = 1500 * time.Millisecond + // integrityLogsSpan: width of the getLogs window, in blocks. + integrityLogsSpan uint64 = 10 + // integrityBalanceAddr: same well-known address the archive loop + // uses (Vitalik). The check compares answers across providers + // byte-for-byte, so it is divergence we measure, not the value. + integrityBalanceAddr = archiveTestAddr +) + +// integrityVectors maps chain slug -> canonical USDC contract used as +// the fixed eth_getLogs vector. Only chains with a heavily traded +// canonical USDC participate (a quiet contract would return 0 logs +// everywhere and the completeness check would be vacuous). Native +// Circle deployments except BNB (Binance-peg, still the busiest +// USDC-family contract there). +var integrityVectors = map[string]string{ + "ethereum": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "arbitrum": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "optimism": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + "base": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "polygon": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "bnb": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", + "avalanche": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", +} + +// integrityOffset returns today's tip-N base depth: 20 + 10*(day%5), +// i.e. {20, 30, 40, 50, 60}, rotating at UTC midnight. +func integrityOffset() uint64 { + day := uint64(time.Now().UTC().Unix() / 86400) + return 20 + 10*(day%5) +} + +// StartIntegrityLoop rotates through the vector-equipped chains, one +// chain per 5-minute tick. Non-blocking; spawns its own goroutine. +func StartIntegrityLoop(ctx context.Context) { + var targets []Chain + for _, c := range chains() { + if c.Kind == "" && integrityVectors[c.Slug] != "" { + targets = append(targets, c) + } + } + if len(targets) == 0 { + return + } + initIntegrityMetrics(targets) + + go func() { + // Let the latency loop populate chainTips first: the vector + // block range is derived from the cross-provider tip. + select { + case <-ctx.Done(): + return + case <-time.After(90 * time.Second): + } + i := 0 + integrityTick(ctx, targets[i%len(targets)]) + i++ + t := time.NewTicker(integrityInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + integrityTick(ctx, targets[i%len(targets)]) + i++ + } + } + }() +} + +type integrityObs struct { + p Provider + logsN int + logsErr error + balHex string + balErr error +} + +func integrityTick(ctx context.Context, c Chain) { + tip := tips.get(c.Slug) + off := integrityOffset() + if tip <= off+integrityLogsSpan { + fmt.Printf("[integrity/%s] no tip yet (tip=%d), skipping round\n", c.Slug, tip) + return + } + to := tip - off + from := to - (integrityLogsSpan - 1) + + obs := make([]integrityObs, 0, len(c.Providers)) + for _, p := range c.Providers { + o := integrityObs{p: p} + o.logsN, o.logsErr = fetchLogsCount(ctx, p.URL, integrityVectors[c.Slug], from, to) + if !integritySleep(ctx) { + return + } + o.balHex, o.balErr = fetchBalanceHex(ctx, p.URL, integrityBalanceAddr, to) + obs = append(obs, o) + if !integritySleep(ctx) { + return + } + } + + // Logs completeness: strict majority of the successful counts. + logsCounts := make(map[int]int) + for _, o := range obs { + if o.logsErr == nil { + logsCounts[o.logsN]++ + } + } + majLogs, logsQuorum := majority(logsCounts) + for _, o := range obs { + switch { + case o.logsErr != nil: + rpcLogsCount.DeleteLabelValues(o.p.Slug, c.Slug, currentRegion) + rpcIntegrityCheck.WithLabelValues(o.p.Slug, c.Slug, currentRegion, "logs", "error").Inc() + fmt.Printf("[integrity/%s/%s] logs range=%d-%d err=%v\n", c.Slug, o.p.Slug, from, to, o.logsErr) + case logsQuorum && o.logsN != majLogs: + rpcLogsCount.WithLabelValues(o.p.Slug, c.Slug, currentRegion).Set(float64(o.logsN)) + rpcLogsDisagreement.WithLabelValues(o.p.Slug, c.Slug, currentRegion).Inc() + rpcIntegrityCheck.WithLabelValues(o.p.Slug, c.Slug, currentRegion, "logs", "disagree").Inc() + fmt.Printf("[integrity/%s/%s] logs DISAGREE got=%d majority=%d range=%d-%d\n", c.Slug, o.p.Slug, o.logsN, majLogs, from, to) + default: + // No >=2 quorum this round (too few successes) also lands + // here: a lone answer is unverifiable, not wrong. + rpcLogsCount.WithLabelValues(o.p.Slug, c.Slug, currentRegion).Set(float64(o.logsN)) + rpcIntegrityCheck.WithLabelValues(o.p.Slug, c.Slug, currentRegion, "logs", "ok").Inc() + fmt.Printf("[integrity/%s/%s] logs ok count=%d range=%d-%d\n", c.Slug, o.p.Slug, o.logsN, from, to) + } + } + + // State consistency: strict majority of the raw hex answers. + balCounts := make(map[string]int) + for _, o := range obs { + if o.balErr == nil { + balCounts[o.balHex]++ + } + } + majBal, balQuorum := majority(balCounts) + for _, o := range obs { + switch { + case o.balErr != nil: + rpcIntegrityCheck.WithLabelValues(o.p.Slug, c.Slug, currentRegion, "balance", "error").Inc() + fmt.Printf("[integrity/%s/%s] balance block=%d err=%v\n", c.Slug, o.p.Slug, to, o.balErr) + case balQuorum && o.balHex != majBal: + rpcStateDisagreement.WithLabelValues(o.p.Slug, c.Slug, currentRegion).Inc() + rpcIntegrityCheck.WithLabelValues(o.p.Slug, c.Slug, currentRegion, "balance", "disagree").Inc() + fmt.Printf("[integrity/%s/%s] balance DISAGREE got=%s majority=%s block=%d\n", c.Slug, o.p.Slug, o.balHex, majBal, to) + default: + rpcIntegrityCheck.WithLabelValues(o.p.Slug, c.Slug, currentRegion, "balance", "ok").Inc() + fmt.Printf("[integrity/%s/%s] balance ok block=%d\n", c.Slug, o.p.Slug, to) + } + } +} + +func integritySleep(ctx context.Context) bool { + select { + case <-ctx.Done(): + return false + case <-time.After(integrityCallSpacing): + return true + } +} + +// majority returns the value backed by >=2 votes and strictly more +// than any competing value; ok=false when no such strict majority +// exists (all-distinct answers, or a tie). +func majority[T comparable](counts map[T]int) (T, bool) { + var best T + bestN, secondN := 0, 0 + for v, n := range counts { + if n > bestN { + best, secondN, bestN = v, bestN, n + } else if n > secondN { + secondN = n + } + } + return best, bestN >= 2 && bestN > secondN +} + +func fetchLogsCount(ctx context.Context, url, addr string, from, to uint64) (int, error) { + body := fmt.Sprintf( + `{"jsonrpc":"2.0","method":"eth_getLogs","params":[{"fromBlock":"0x%x","toBlock":"0x%x","address":"%s"}],"id":%d}`, + from, to, addr, time.Now().UnixNano(), + ) + raw, err := integrityPost(ctx, url, body) + if err != nil { + return 0, err + } + var r rpcBlockEnvelope + if err := json.Unmarshal(raw, &r); err != nil { + return 0, err + } + if r.Error != nil { + return 0, fmt.Errorf("rpc %d: %s", r.Error.Code, r.Error.Message) + } + var logs []json.RawMessage + if err := json.Unmarshal(r.Result, &logs); err != nil { + return 0, fmt.Errorf("non-array result") + } + return len(logs), nil +} + +func fetchBalanceHex(ctx context.Context, url, addr string, block uint64) (string, error) { + body := fmt.Sprintf( + `{"jsonrpc":"2.0","method":"eth_getBalance","params":["%s","0x%x"],"id":%d}`, + addr, block, time.Now().UnixNano(), + ) + raw, err := integrityPost(ctx, url, body) + if err != nil { + return "", err + } + var r rpcEnvelope + if err := json.Unmarshal(raw, &r); err != nil { + return "", err + } + if r.Error != nil { + return "", fmt.Errorf("rpc %d: %s", r.Error.Code, r.Error.Message) + } + if r.Result == "" { + return "", fmt.Errorf("empty result") + } + return r.Result, nil +} + +func integrityPost(ctx context.Context, url, body string) ([]byte, error) { + c, cancel := context.WithTimeout(ctx, integrityTimeout) + defer cancel() + req, _ := http.NewRequestWithContext(c, "POST", url, bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + client := &http.Client{Timeout: integrityTimeout} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + _, _ = io.Copy(io.Discard, resp.Body) + return nil, fmt.Errorf("http %d", resp.StatusCode) + } + return io.ReadAll(resp.Body) +} + +// initIntegrityMetrics zero-initializes every counter in the vector +// matrix so the bench's `increase()` queries read 0, not absent, for +// clean providers. +func initIntegrityMetrics(targets []Chain) { + for _, c := range targets { + for _, p := range c.Providers { + for _, check := range []string{"logs", "balance"} { + for _, result := range []string{"ok", "error", "disagree"} { + rpcIntegrityCheck.WithLabelValues(p.Slug, c.Slug, currentRegion, check, result).Add(0) + } + } + rpcLogsDisagreement.WithLabelValues(p.Slug, c.Slug, currentRegion).Add(0) + rpcStateDisagreement.WithLabelValues(p.Slug, c.Slug, currentRegion).Add(0) + } + } +} diff --git a/harnesses/rpc-capabilities/cmd/script/main.go b/harnesses/rpc-capabilities/cmd/script/main.go index 9bea8647..9b18b764 100644 --- a/harnesses/rpc-capabilities/cmd/script/main.go +++ b/harnesses/rpc-capabilities/cmd/script/main.go @@ -73,11 +73,16 @@ func main() { } } fmt.Println() - fmt.Println("Metrics server: :2112/metrics") + + // OCB_METRICS_ADDR: explicit local-run override only (e.g. a dev + // box where :2112 is taken). We still ignore Railway's injected + // $PORT on purpose; Prometheus scrapes :2112 in prod. + addr := envDefault("OCB_METRICS_ADDR", ":2112") + fmt.Printf("Metrics server: %s/metrics\n", addr) fmt.Println() go func() { - if err := StartMetricsServer(":2112"); err != nil { + if err := StartMetricsServer(addr); err != nil { fmt.Printf("[fatal] metrics server: %v\n", err) os.Exit(1) } @@ -88,6 +93,7 @@ func main() { StartProbeLoop(ctx) StartArchiveLoop(ctx) + StartIntegrityLoop(ctx) sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) diff --git a/harnesses/rpc-capabilities/cmd/script/metrics.go b/harnesses/rpc-capabilities/cmd/script/metrics.go index df15637f..304c860e 100644 --- a/harnesses/rpc-capabilities/cmd/script/metrics.go +++ b/harnesses/rpc-capabilities/cmd/script/metrics.go @@ -57,6 +57,57 @@ var ( }, []string{"provider", "chain", "region", "depth"}, ) + + // ─── Bench 083 rpc-reliability: correctness / integrity metrics ──── + // Emitted from the same probe matrix; consensus.go + integrity.go. + + rpcConsensusLag = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "rpc_consensus_lag_blocks", + Help: "Blocks (slots on Solana) between this provider's reported head and the highest head any probed provider reported for the same chain. Set on every valid head probe (ok or stale); deleted on failure so a dead endpoint ages out instead of freezing at its last lag.", + }, + []string{"provider", "chain", "region"}, + ) + + rpcHashMismatch = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "rpc_hash_mismatch_total", + Help: "Times a provider reported a block hash at height H that disagrees with the hash at least 2 other-or-same providers agreed on at H (strict plurality). Incremented at most once per (provider, height); a 2-2 reorg split yields no quorum and nobody is counted.", + }, + []string{"provider", "chain", "region"}, + ) + + rpcLogsCount = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "rpc_logs_count", + Help: "Number of logs the provider returned for the fixed-vector `eth_getLogs` check (canonical USDC contract, 10-block range at a daily-rotating depth behind tip). Deleted when the call errors so a blocked method doesn't freeze a stale count.", + }, + []string{"provider", "chain", "region"}, + ) + + rpcLogsDisagreement = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "rpc_logs_disagreement_total", + Help: "Times a provider's `eth_getLogs` count for the fixed vector deviated from the strict cross-provider majority count in the same round.", + }, + []string{"provider", "chain", "region"}, + ) + + rpcStateDisagreement = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "rpc_state_disagreement_total", + Help: "Times a provider's `eth_getBalance` hex at the fixed recent block was not byte-identical to the strict cross-provider majority answer in the same round.", + }, + []string{"provider", "chain", "region"}, + ) + + rpcIntegrityCheck = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "rpc_integrity_check_total", + Help: "Fixed-vector integrity check outcomes. check is `logs` (eth_getLogs USDC 10-block window) or `balance` (eth_getBalance at a fixed recent block). result is ok (matches majority), error (method blocked, archive gated, transport failure: errors are signal), or disagree (diverged from the >=2-provider majority).", + }, + []string{"provider", "chain", "region", "check", "result"}, + ) ) // StartMetricsServer binds /metrics + /health on addr. Blocking call — diff --git a/harnesses/rpc-capabilities/cmd/script/probe.go b/harnesses/rpc-capabilities/cmd/script/probe.go index d19f1925..3856911e 100644 --- a/harnesses/rpc-capabilities/cmd/script/probe.go +++ b/harnesses/rpc-capabilities/cmd/script/probe.go @@ -30,6 +30,18 @@ const ( // ~2 minutes behind the cross-provider tip - the same order of // tolerance the EVM gap gives a 12s-block chain. solanaStaleSlotGap uint64 = 300 + // polkadotStaleBlockGap: Polkadot relay produces one block every + // ~6 s, so 40 blocks ≈ 4 min. Same order of tolerance the EVM + // gap gives a 12s-block chain, scaled for the faster relay + // cadence. + polkadotStaleBlockGap uint64 = 40 + // cosmosStaleBlockGap: Osmosis (CometBFT) commits one block every + // ~6 s (same order as Polkadot's relay), so 40 blocks ≈ 4 min + // gives the same reliability tolerance the EVM cluster gets on + // 12 s-block chains. Kept as its own constant so a future Cosmos + // chain with a faster block time (e.g. Injective ~0.65 s) can + // override without touching the Polkadot reader. + cosmosStaleBlockGap uint64 = 40 ) // chainTips tracks the highest block seen for each chain across all @@ -83,6 +95,7 @@ type rpcBlockEnvelope struct { type blockHeader struct { Number string `json:"number"` + Hash string `json:"hash"` } // callLatestBlock issues `eth_getBlockByNumber("latest", false)` and @@ -95,8 +108,9 @@ type blockHeader struct { // node, which let cache-fronted gateways top the latency leaderboard // on cache hits rather than real RPC work. Fetching the full latest // header with a rotating request id defeats body-keyed edge caches; -// the header's `number` field keeps the staleness check intact. -func callLatestBlock(ctx context.Context, url string) (block uint64, result string, latencyMs float64, err error) { +// the header's `number` field keeps the staleness check intact and its +// `hash` feeds the bench-083 cross-provider quorum map (consensus.go). +func callLatestBlock(ctx context.Context, url string) (block uint64, hash string, result string, latencyMs float64, err error) { body := []byte(fmt.Sprintf( `{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["latest",false],"id":%d}`, time.Now().UnixNano(), @@ -112,48 +126,49 @@ func callLatestBlock(ctx context.Context, url string) (block uint64, result stri if err != nil { if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") { - return 0, "timeout", latencyMs, err + return 0, "", "timeout", latencyMs, err } - return 0, "http_err", latencyMs, err + return 0, "", "http_err", latencyMs, err } defer resp.Body.Close() if resp.StatusCode != 200 { _, _ = io.Copy(io.Discard, resp.Body) - return 0, "http_err", latencyMs, fmt.Errorf("status %d", resp.StatusCode) + return 0, "", "http_err", latencyMs, fmt.Errorf("status %d", resp.StatusCode) } raw, err := io.ReadAll(resp.Body) if err != nil { - return 0, "http_err", latencyMs, err + return 0, "", "http_err", latencyMs, err } var r rpcBlockEnvelope if err := json.Unmarshal(raw, &r); err != nil { - return 0, "http_err", latencyMs, err + return 0, "", "http_err", latencyMs, err } if r.Error != nil { - return 0, "jsonrpc_err", latencyMs, fmt.Errorf("rpc -%d: %s", r.Error.Code, r.Error.Message) + return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("rpc -%d: %s", r.Error.Code, r.Error.Message) } if len(r.Result) == 0 || string(r.Result) == "null" { - return 0, "jsonrpc_err", latencyMs, fmt.Errorf("empty result") + return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("empty result") } var hdr blockHeader if err := json.Unmarshal(r.Result, &hdr); err != nil { - return 0, "jsonrpc_err", latencyMs, err + return 0, "", "jsonrpc_err", latencyMs, err } if hdr.Number == "" { - return 0, "jsonrpc_err", latencyMs, fmt.Errorf("header missing number") + return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("header missing number") } n, err := strconv.ParseUint(strings.TrimPrefix(hdr.Number, "0x"), 16, 64) if err != nil { - return 0, "jsonrpc_err", latencyMs, err + return 0, "", "jsonrpc_err", latencyMs, err } - return n, "ok", latencyMs, nil + return n, hdr.Hash, "ok", latencyMs, nil } // StartProbeLoop spawns one goroutine per (chain × provider). Each // goroutine runs forever, ticking every probeInterval. func StartProbeLoop(ctx context.Context) { + initConsensusMetrics() for _, c := range chains() { c := c for _, p := range c.Providers { @@ -180,26 +195,59 @@ func probeOne(ctx context.Context, c Chain, p Provider) { probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() var block uint64 + var hash string var result string var latency float64 var err error - if c.Kind == "solana" { + switch c.Kind { + case "solana": block, result, latency, err = callLatestSlot(probeCtx, p.URL) - } else { - block, result, latency, err = callLatestBlock(probeCtx, p.URL) + case "polkadot": + block, hash, result, latency, err = callSubstrateHeader(probeCtx, p.URL) + case "cosmos": + block, hash, result, latency, err = callCosmosStatus(probeCtx, p.URL) + default: + block, hash, result, latency, err = callLatestBlock(probeCtx, p.URL) } if result == "ok" { tips.update(c.Slug, block) tip := tips.get(c.Slug) gap := staleBlockGap - if c.Kind == "solana" { + switch c.Kind { + case "solana": gap = solanaStaleSlotGap + case "polkadot": + gap = polkadotStaleBlockGap + case "cosmos": + gap = cosmosStaleBlockGap } if tip > 0 && block+gap < tip { result = "stale" } } + // Bench 083: valid observations (fresh or stale, both carry a + // real height + hash) feed the consensus lag gauge and the + // height→hash quorum map; anything else deletes the lag series + // so a dead endpoint ages out instead of freezing. Skipped on + // chains whose probe cannot return the current-block hash + // (Solana: getSlot returns a number only; Polkadot: header + // carries parentHash, not the current block hash; Cosmos: + // Tendermint `status` returns `latest_block_hash` on the same + // block the height is reported for, so quorum is technically + // feasible but the v1 Osmosis add-on ships without it to keep + // the reliability change surface small — revisit once the + // hash normalisation across Cosmos chains is validated). + switch c.Kind { + case "solana", "polkadot", "cosmos": + // no consensus participation + default: + if result == "ok" || result == "stale" { + consensus.observe(c.Slug, p.Slug, block, hash) + } else { + rpcConsensusLag.DeleteLabelValues(p.Slug, c.Slug, currentRegion) + } + } rpcCallTotal.WithLabelValues(p.Slug, c.Slug, currentRegion, result).Inc() if result == "ok" { // Latency is recorded ONLY for fresh, valid responses. Error @@ -296,3 +344,150 @@ func callLatestSlot(ctx context.Context, url string) (slot uint64, result string } return n, "ok", latencyMs, nil } + +// substrateHeader is the shape of the `chain_getHeader` result on +// Polkadot / Kusama and every Substrate-based relay chain. The block +// number is hex-encoded (`0x` prefix) matching the EVM header convention, +// so the parse path reuses the same strconv rule. `parentHash` is here +// so the consensus.go quorum map can key on it identically to EVM +// (bench 083 cross-provider height-hash agreement). +type substrateHeader struct { + Number string `json:"number"` + ParentHash string `json:"parentHash"` +} + +// callSubstrateHeader is the Polkadot probe path: chain_getHeader with a +// rotating request id (same anti-cache rule as the EVM header fetch). +// Returns the block number and parent hash so the caller can plug the +// probe result into the same tips / consensus machinery the EVM chain +// path uses. The returned "block" is the relay-chain block height, not +// a chain-agnostic slot: staleness classification uses +// polkadotStaleBlockGap in probeOne. +func callSubstrateHeader(ctx context.Context, url string) (block uint64, hash string, result string, latencyMs float64, err error) { + body := []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":"chain_getHeader","params":[],"id":%d}`, + time.Now().UnixNano(), + )) + req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + client := &http.Client{Timeout: probeTimeout} + + start := time.Now() + resp, err := client.Do(req) + latencyMs = float64(time.Since(start).Milliseconds()) + + if err != nil { + if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") { + return 0, "", "timeout", latencyMs, err + } + return 0, "", "http_err", latencyMs, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + _, _ = io.Copy(io.Discard, resp.Body) + return 0, "", "http_err", latencyMs, fmt.Errorf("status %d", resp.StatusCode) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return 0, "", "http_err", latencyMs, err + } + var r rpcBlockEnvelope + if err := json.Unmarshal(raw, &r); err != nil { + return 0, "", "http_err", latencyMs, err + } + if r.Error != nil { + return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("rpc -%d: %s", r.Error.Code, r.Error.Message) + } + if len(r.Result) == 0 || string(r.Result) == "null" { + return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("empty result") + } + var hdr substrateHeader + if err := json.Unmarshal(r.Result, &hdr); err != nil { + return 0, "", "jsonrpc_err", latencyMs, err + } + if hdr.Number == "" { + return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("substrate header missing number") + } + n, err := strconv.ParseUint(strings.TrimPrefix(hdr.Number, "0x"), 16, 64) + if err != nil { + return 0, "", "jsonrpc_err", latencyMs, err + } + return n, hdr.ParentHash, "ok", latencyMs, nil +} + +// tendermintStatus is the shape returned by CometBFT / Tendermint +// `status` on every Cosmos SDK chain. Only `sync_info` is parsed here; +// `node_info` + `validator_info` are ignored because they carry +// operator-scoped metadata that doesn't feed the reliability signal. +// `latest_block_height` is a decimal string (unlike EVM / Substrate +// which hex-encode). `latest_block_hash` is hex (no `0x` prefix) so it +// slots into the same consensus.observe path if ever wired. +type tendermintStatus struct { + SyncInfo struct { + LatestBlockHeight string `json:"latest_block_height"` + LatestBlockHash string `json:"latest_block_hash"` + } `json:"sync_info"` +} + +// callCosmosStatus is the Cosmos SDK probe path: Tendermint `status` +// via JSON-RPC POST with a rotating request id (same anti-cache rule as +// the EVM header fetch). Returns the current block height + hash so the +// caller plugs the probe result into the shared tips machinery. +// Consensus participation is opted out in probeOne for v1. +func callCosmosStatus(ctx context.Context, url string) (block uint64, hash string, result string, latencyMs float64, err error) { + body := []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":"status","params":[],"id":%d}`, + time.Now().UnixNano(), + )) + req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + client := &http.Client{Timeout: probeTimeout} + + start := time.Now() + resp, err := client.Do(req) + latencyMs = float64(time.Since(start).Milliseconds()) + + if err != nil { + if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") { + return 0, "", "timeout", latencyMs, err + } + return 0, "", "http_err", latencyMs, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + _, _ = io.Copy(io.Discard, resp.Body) + return 0, "", "http_err", latencyMs, fmt.Errorf("status %d", resp.StatusCode) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return 0, "", "http_err", latencyMs, err + } + var r rpcBlockEnvelope + if err := json.Unmarshal(raw, &r); err != nil { + return 0, "", "http_err", latencyMs, err + } + if r.Error != nil { + return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("rpc -%d: %s", r.Error.Code, r.Error.Message) + } + if len(r.Result) == 0 || string(r.Result) == "null" { + return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("empty result") + } + var st tendermintStatus + if err := json.Unmarshal(r.Result, &st); err != nil { + return 0, "", "jsonrpc_err", latencyMs, err + } + if st.SyncInfo.LatestBlockHeight == "" { + return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("tendermint status missing latest_block_height") + } + n, err := strconv.ParseUint(st.SyncInfo.LatestBlockHeight, 10, 64) + if err != nil { + return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("non-numeric latest_block_height: %q", st.SyncInfo.LatestBlockHeight) + } + return n, st.SyncInfo.LatestBlockHash, "ok", latencyMs, nil +} diff --git a/harnesses/rpc-capabilities/go.mod b/harnesses/rpc-capabilities/go.mod index 74e2e1fb..3c5d672c 100644 --- a/harnesses/rpc-capabilities/go.mod +++ b/harnesses/rpc-capabilities/go.mod @@ -8,6 +8,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/kr/text v0.2.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect diff --git a/harnesses/token-trade-coverage/.env.example b/harnesses/token-trade-coverage/.env.example new file mode 100644 index 00000000..a613cf9a --- /dev/null +++ b/harnesses/token-trade-coverage/.env.example @@ -0,0 +1,41 @@ +# token-trade-coverage harness. Fill in and rename to `.env`. + +# Provider keys (harness tolerates missing keys per-provider; missing +# key sets probe_ok=0 for that provider only, others keep going). +MOBULA_API_KEY= +BITQUERY_API_KEY= +CODEX_API_KEY= + +# ─── Cadence ────────────────────────────────────────────────────────── +# Base sweep cadence. 3600 = 1 hour. Every SWEEP_SEC the harness iterates +# every (provider, chain, token) tuple, respecting the per-provider +# EveryN sub-sampling below. +SWEEP_SEC=3600 + +# ─── Per-provider sub-sampling (respects free-tier quotas) ──────────── +# A provider runs only when `iteration % EveryN == 0`. Iteration is the +# 0-indexed sweep counter (boot = 0, first tick = 1, ...). +# +# Default free-tier budget math (at SWEEP_SEC=3600, 8 EVM+Solana tokens, +# 2 Stellar tokens Mobula-only): +# Mobula MOBULA_EVERY_N=1 → 240 calls / day = free (own API) +# Bitquery BITQUERY_EVERY_N=6 → 8 tokens × 4 sweeps/day × 30 = 960 +# calls / month at ~1 pt each = ~960 pts (Free plan cap 1000) +# Codex CODEX_EVERY_N=1 → 192 calls / day = fits Codex rate limits +# +# On a paid Bitquery plan (Developer $99/mo = 500k pts), set BITQUERY_EVERY_N=1. +MOBULA_EVERY_N=1 +BITQUERY_EVERY_N=6 +CODEX_EVERY_N=1 + +# ─── Query caps (prevent runaway pagination on heavy tokens) ────────── +# Mobula: cursor pages, 5000 rows each → MAX_PAGES=20 caps at 100k trades / token / sweep. +# Bitquery: single-shot query, no cursor → MAX_ROWS is the `limit` in the GraphQL. +# Codex: cursor pages, 200 rows each → MAX_PAGES=10 caps at 2000 events / token / sweep. +MOBULA_MAX_PAGES=20 +BITQUERY_MAX_ROWS=10000 +CODEX_MAX_PAGES=10 + +# ─── Runtime knobs ──────────────────────────────────────────────────── +METRICS_PORT=2112 +HTTP_TIMEOUT_SEC=30 diff --git a/harnesses/token-trade-coverage/.gitignore b/harnesses/token-trade-coverage/.gitignore new file mode 100644 index 00000000..a0113a2f --- /dev/null +++ b/harnesses/token-trade-coverage/.gitignore @@ -0,0 +1,2 @@ +.env +/scanner diff --git a/harnesses/token-trade-coverage/Dockerfile b/harnesses/token-trade-coverage/Dockerfile new file mode 100644 index 00000000..0b6e7282 --- /dev/null +++ b/harnesses/token-trade-coverage/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/scanner ./cmd/scanner + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates && update-ca-certificates +COPY --from=build /out/scanner /usr/local/bin/scanner +EXPOSE 2112 +ENTRYPOINT ["/usr/local/bin/scanner"] diff --git a/harnesses/token-trade-coverage/README.md b/harnesses/token-trade-coverage/README.md new file mode 100644 index 00000000..d32dc009 --- /dev/null +++ b/harnesses/token-trade-coverage/README.md @@ -0,0 +1,98 @@ +# Harness · token-trade-coverage + +> Source for bench № 090 · Most complete onchain trade data API. Measures, for each reference token per chain, how many trades each provider (Mobula, Bitquery, Codex, Moralis) returns in a fixed 60-minute window, then publishes the capture rate vs the union baseline. + +## What ships in this directory + +- `cmd/scanner/main.go` — measurement loop. Every `SWEEP_SEC` (default 1800 = 30 min) it iterates every (provider, chain, token) tuple, fetches trades in the same 60-minute window, computes the union baseline as `max(counts across providers)` and emits per-provider capture rate to Prometheus. +- `cmd/scanner/mobula.go`, `bitquery.go`, `codex.go`, `moralis.go` — one file per provider. Each exposes a single `fetchTrades(ctx, chain, tokenAddress, windowStart, windowEnd) (int, error)` function that returns the number of distinct trades. Not the full trade objects — we only need the count for capture-rate computation, so the harness never materializes hundreds of MB of trade JSON in memory. +- `cmd/scanner/config.go` — reference token list per chain, env parsing, provider capability matrix (which provider supports which chain). +- `cmd/scanner/metrics.go` — Prom metric definitions (`ocb_token_trade_capture_pct`, `ocb_token_trade_probe_ok`, `ocb_token_trade_query_latency_ms`, `ocb_token_trade_dex_count`). + +## Providers + +| Provider | Endpoint | Auth | Chains | +| --- | --- | --- | --- | +| Mobula | `GET /api/2/trades/filters` | `Authorization: ` | Solana, Ethereum, BSC, Base, Stellar | +| Bitquery | `POST /graphql` (streaming.bitquery.io) | `X-API-KEY` header | Solana, Ethereum, BSC, Base | +| Codex | `POST /graphql` (graph.codex.io) | `Authorization: ` | Solana, Ethereum, BSC, Base | +| Moralis | `GET /token/mainnet/{addr}/swaps` (Solana) or `GET /erc20/{addr}/swaps` (EVM) | `X-API-Key` | Solana, Ethereum, BSC, Base | + +Stellar is measured for Mobula only (the other three do not ship a public Stellar trades endpoint at time of writing). The bench renders Stellar coverage as a per-chain view where non-supporting providers are absent from the row, not counted as zero. + +## Reference tokens + +Chosen for meaningful trade activity in the measurement window so a coverage gap is visible above sampling noise. See `config.go` for the current list. Rotated periodically to avoid a single token going illiquid and dragging every provider's absolute count to zero. + +## Cadence + +`SWEEP_SEC=1800` (30 min). One full sweep does 4 providers × 5 chains × 2 tokens = up to 40 API calls (fewer when a provider does not support a chain). Each call is bounded by `HTTP_TIMEOUT_SEC=30`. A full sweep completes well inside 30 minutes so `SWEEP_SEC` cadence and `avg_over_time(...[24h])` on the spec queries stay honest. + +## Env vars + +Required: + +- `MOBULA_API_KEY` — Mobula API key. Contact mobula.io if you don't have one. +- `BITQUERY_API_KEY` — Bitquery streaming.bitquery.io key. +- `CODEX_API_KEY` — Codex (Defined) API key. NOTE: this bench does NOT use the cookie-based JWT flow from `aggregator-head-lag` because the query volume is high (batch historical, not live subscribe). Fresh dedicated key recommended. +- `MORALIS_API_KEY` — Moralis Web3 Data API key. + +Optional: + +- `SWEEP_SEC` (default 1800) +- `METRICS_PORT` (default 2112) +- `HTTP_TIMEOUT_SEC` (default 30) +- `LOG_LEVEL` (default info; set debug to log every provider call) + +## Metrics produced + +| Metric | Description | +| --- | --- | +| `ocb_token_trade_capture_pct{provider, chain, token}` | Capture rate percent (0-100). `provider_count / max_provider_count * 100`. Union baseline is per (chain, token) per cycle. | +| `ocb_token_trade_absolute_count{provider, chain, token}` | Raw trade count returned by that provider in the measurement window. | +| `ocb_token_trade_query_latency_ms{provider, chain, token}` | Wall-clock latency of the provider call, including pagination. | +| `ocb_token_trade_dex_count{provider, chain, token}` | Distinct DEX venues represented in the returned trade set. Companion metric — coverage breadth vs pure count. | +| `ocb_token_trade_probe_ok{provider, chain, token}` | 1 on successful fetch, 0 on error/timeout. Consumed by the spec's `success` query. | + +The spec at `benchmarks/token-trade-coverage.yml` aggregates these across (chain, token) into a per-provider p50 for the headline leaderboard. + +## Run locally + +```bash +cp .env.example .env +# Fill in the four API keys +go run ./cmd/scanner/ +``` + +`/metrics` at `http://localhost:2112/metrics`. + +## Run in the OCB VPS stack + +Add a service block in `/opt/ocb/docker-compose.yml`: + +```yaml + token-trade-coverage: + build: + context: /opt/ocb/harnesses/token-trade-coverage + dockerfile: Dockerfile + container_name: ocb-token-trade-coverage + restart: unless-stopped + env_file: /run/ocb/.env.token-trade-coverage + expose: ["2112"] + networks: [web] + mem_limit: 512m + cpus: "0.3" +``` + +Prometheus scrape config additions in `/opt/ocb/prometheus.yml`: + +```yaml + - job_name: token-trade-coverage + scrape_interval: 60s + static_configs: + - targets: ["token-trade-coverage:2112"] +``` + +## Reference implementation + +The initial TypeScript reference lives at https://github.com/Flotapponnier/token-trade-benchmark-. This harness is a Go port with Prometheus emission and OCB conventions. Any semantic drift between the two (which trades count, how the window is bounded) is corrected here first; the reference repo is a design document, not a source of truth. diff --git a/harnesses/token-trade-coverage/cmd/scanner/bitquery.go b/harnesses/token-trade-coverage/cmd/scanner/bitquery.go new file mode 100644 index 00000000..2d2005cf --- /dev/null +++ b/harnesses/token-trade-coverage/cmd/scanner/bitquery.go @@ -0,0 +1,191 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const bitqueryURL = "https://streaming.bitquery.io/graphql" + +// bitqueryEVMResp / bitquerySolanaResp keep the response tight: only +// what we need to count trades and derive DEX venues. +type bitqueryEVMResp struct { + Data struct { + EVM struct { + DEXTrades []struct { + Transaction struct { + Hash string `json:"Hash"` + } `json:"Transaction"` + Trade struct { + Dex struct { + ProtocolName string `json:"ProtocolName"` + } `json:"Dex"` + } `json:"Trade"` + } `json:"DEXTrades"` + } `json:"EVM"` + } `json:"data"` +} + +type bitquerySolanaResp struct { + Data struct { + Solana struct { + DEXTradeByTokens []struct { + Transaction struct { + Signature string `json:"Signature"` + } `json:"Transaction"` + Trade struct { + Dex struct { + ProtocolName string `json:"ProtocolName"` + } `json:"Dex"` + } `json:"Trade"` + } `json:"DEXTradeByTokens"` + } `json:"Solana"` + } `json:"data"` +} + +// fetchBitquery returns (trade count, distinct DEXs, error). Bitquery +// caps a single query at 10000 rows and has no cursor pagination on +// DEXTradeByTokens; on tokens whose true trade count exceeds 10k the +// bench under-reports Bitquery vs a paginated provider. The findings +// section of the spec calls this out honestly rather than pretending +// Bitquery is worse than it is. +func fetchBitquery( + ctx context.Context, + client *http.Client, + apiKey string, + tok Token, + windowStart, windowEnd int64, + maxRows int, +) (int, int, error) { + if apiKey == "" { + return 0, 0, fmt.Errorf("BITQUERY_API_KEY not set") + } + if maxRows <= 0 { + maxRows = 10000 + } + + sinceISO := time.Unix(windowStart/1000, 0).UTC().Format(time.RFC3339) + tillISO := time.Unix(windowEnd/1000, 0).UTC().Format(time.RFC3339) + + var query string + if tok.Chain == "solana" { + query = solanaTradesGQL(tok.Address, sinceISO, tillISO, maxRows) + } else { + network := bitqueryEVMNetwork(tok.Chain) + if network == "" { + return 0, 0, fmt.Errorf("bitquery: unsupported chain %s", tok.Chain) + } + query = evmTradesGQL(network, tok.Address, sinceISO, tillISO, maxRows) + } + + body, _ := json.Marshal(map[string]string{"query": query}) + req, err := http.NewRequestWithContext(ctx, "POST", bitqueryURL, bytes.NewReader(body)) + if err != nil { + return 0, 0, err + } + // Bitquery's new OAuth-style keys (ory_at_*) require Bearer auth on + // streaming.bitquery.io/graphql. The legacy X-API-KEY header on that + // endpoint returns HTTP 402 "No active billing period" even when the + // account has an active free plan — the migration was silent and the + // docs still show the old header on some pages. + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return 0, 0, err + } + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return 0, 0, err + } + if resp.StatusCode >= 300 { + return 0, 0, fmt.Errorf("bitquery http %d: %s", resp.StatusCode, truncate(string(respBody), 200)) + } + + dexSet := map[string]struct{}{} + hashSet := map[string]struct{}{} + + if tok.Chain == "solana" { + var r bitquerySolanaResp + if err := json.Unmarshal(respBody, &r); err != nil { + return 0, 0, fmt.Errorf("bitquery parse: %w", err) + } + for _, t := range r.Data.Solana.DEXTradeByTokens { + if _, seen := hashSet[t.Transaction.Signature]; seen { + continue + } + hashSet[t.Transaction.Signature] = struct{}{} + if t.Trade.Dex.ProtocolName != "" { + dexSet[t.Trade.Dex.ProtocolName] = struct{}{} + } + } + } else { + var r bitqueryEVMResp + if err := json.Unmarshal(respBody, &r); err != nil { + return 0, 0, fmt.Errorf("bitquery parse: %w", err) + } + for _, t := range r.Data.EVM.DEXTrades { + if _, seen := hashSet[t.Transaction.Hash]; seen { + continue + } + hashSet[t.Transaction.Hash] = struct{}{} + if t.Trade.Dex.ProtocolName != "" { + dexSet[t.Trade.Dex.ProtocolName] = struct{}{} + } + } + } + return len(hashSet), len(dexSet), nil +} + +func bitqueryEVMNetwork(chain string) string { + switch chain { + case "ethereum": + return "eth" + case "bsc": + return "bsc" + case "base": + return "base" + } + return "" +} + +func solanaTradesGQL(mintAddress, since, till string, maxRows int) string { + return fmt.Sprintf(`{ + Solana(dataset: realtime) { + DEXTradeByTokens( + limit: {count: %d} + where: { + Block: {Time: {since: "%s", till: "%s"}} + Trade: {Currency: {MintAddress: {is: "%s"}}} + } + ) { + Transaction { Signature } + Trade { Dex { ProtocolName } } + } + } +}`, maxRows, since, till, mintAddress) +} + +func evmTradesGQL(network, tokenAddress, since, till string, maxRows int) string { + return fmt.Sprintf(`{ + EVM(dataset: realtime, network: %s) { + DEXTrades( + limit: {count: %d} + where: { + Block: {Time: {since: "%s", till: "%s"}} + Trade: {Buy: {Currency: {SmartContract: {is: "%s"}}}} + } + ) { + Transaction { Hash } + Trade { Dex { ProtocolName } } + } + } +}`, network, maxRows, since, till, tokenAddress) +} diff --git a/harnesses/token-trade-coverage/cmd/scanner/codex.go b/harnesses/token-trade-coverage/cmd/scanner/codex.go new file mode 100644 index 00000000..9b19b224 --- /dev/null +++ b/harnesses/token-trade-coverage/cmd/scanner/codex.go @@ -0,0 +1,155 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const codexURL = "https://graph.codex.io/graphql" + +// codexResp: minimal envelope. Codex's `getTokenEvents` is cursor-paginated +// via the `cursor` string returned in each response. +// +// Fields kept minimal on purpose. `exchangeAddress` was dropped from +// the query because it's not a valid field on Codex's `Event` type +// (GraphQL 400: `Cannot query field exchangeAddress on type Event`). +// Codex's DEX identity lives one hop away on the `Pair.exchange` type, +// which would require joining `Pair` records per event and inflates +// the query volume for a companion metric that we already publish +// honestly (0) for a provider that doesn't expose it inline. +type codexResp struct { + Data struct { + GetTokenEvents struct { + Items []struct { + TransactionHash string `json:"transactionHash"` + EventDisplayType string `json:"eventDisplayType"` + } `json:"items"` + Cursor string `json:"cursor"` + } `json:"getTokenEvents"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +// codexNetworkID maps OCB chain slug to Codex's numeric networkId. +// Same table as in the reference TS impl. +func codexNetworkID(chain string) int { + switch chain { + case "solana": + return 1399811149 + case "ethereum": + return 1 + case "bsc": + return 56 + case "base": + return 8453 + } + return 0 +} + +// fetchCodex returns (trade count, distinct exchanges, error). Uses +// standard API-key auth via `Authorization` header — the cookie-based +// JWT flow that lives in aggregator-head-lag is not used here because +// query volume is batch-historical, not live-subscribe. +func fetchCodex( + ctx context.Context, + client *http.Client, + apiKey string, + tok Token, + windowStart, windowEnd int64, + maxPages int, +) (int, int, error) { + if apiKey == "" { + return 0, 0, fmt.Errorf("CODEX_API_KEY not set") + } + if maxPages <= 0 { + maxPages = 10 + } + networkID := codexNetworkID(tok.Chain) + if networkID == 0 { + return 0, 0, fmt.Errorf("codex: unsupported chain %s", tok.Chain) + } + + fromSec := windowStart / 1000 + toSec := windowEnd / 1000 + + var ( + total int + cursor string + exchSet = map[string]struct{}{} + hashSet = map[string]struct{}{} + ) + + for page := 0; page < maxPages; page++ { + var cursorClause string + if cursor != "" { + cursorClause = fmt.Sprintf(`cursor: %q`, cursor) + } + query := fmt.Sprintf(` +query GetEvents { + getTokenEvents( + query: { + address: "%s" + networkId: %d + timestamp: {from: %d, to: %d} + eventType: Swap + } + limit: 200 + %s + ) { + items { + transactionHash + eventDisplayType + } + cursor + } +}`, tok.Address, networkID, fromSec, toSec, cursorClause) + + body, _ := json.Marshal(map[string]string{"query": query}) + req, err := http.NewRequestWithContext(ctx, "POST", codexURL, bytes.NewReader(body)) + if err != nil { + return total, len(exchSet), err + } + req.Header.Set("Authorization", apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return total, len(exchSet), err + } + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return total, len(exchSet), err + } + if resp.StatusCode >= 300 { + return total, len(exchSet), fmt.Errorf("codex http %d: %s", resp.StatusCode, truncate(string(respBody), 200)) + } + var r codexResp + if err := json.Unmarshal(respBody, &r); err != nil { + return total, len(exchSet), fmt.Errorf("codex parse: %w", err) + } + if len(r.Errors) > 0 { + return total, len(exchSet), fmt.Errorf("codex graphql: %s", r.Errors[0].Message) + } + for _, it := range r.Data.GetTokenEvents.Items { + if _, seen := hashSet[it.TransactionHash]; seen { + continue + } + hashSet[it.TransactionHash] = struct{}{} + total++ + // Codex Event doesn't expose DEX inline; leave dexCount=0 + // rather than fabricating a synthetic venue. + } + if r.Data.GetTokenEvents.Cursor == "" || len(r.Data.GetTokenEvents.Items) == 0 { + break + } + cursor = r.Data.GetTokenEvents.Cursor + } + return total, len(exchSet), nil +} diff --git a/harnesses/token-trade-coverage/cmd/scanner/config.go b/harnesses/token-trade-coverage/cmd/scanner/config.go new file mode 100644 index 00000000..68717aca --- /dev/null +++ b/harnesses/token-trade-coverage/cmd/scanner/config.go @@ -0,0 +1,169 @@ +package main + +import ( + "os" + "strconv" + "time" +) + +// Token is a reference token watched for coverage measurement. +type Token struct { + Chain string // "solana", "ethereum", "bsc", "base", "stellar" + Address string // native address on that chain + Symbol string // short label for logs +} + +// ProviderCapability declares which chains a provider is measured on. +// A provider is EXCLUDED from a chain's row if the chain is not listed +// here; that keeps the leaderboard honest instead of counting a +// non-integration as a zero-score defeat. +type ProviderCapability map[string]bool + +// Config is populated once at startup and read-only afterwards. +type Config struct { + SweepSec int + MetricsPort string + HTTPTimeoutSec int + MobulaKey string + BitqueryKey string + CodexKey string + Tokens []Token + Capabilities map[string]ProviderCapability // provider -> {chain -> supported} + MeasurementWinMs int64 // rolling window per measurement + + // Per-provider sub-sampling (respects free-tier quotas). + // A provider only runs when `iteration % everyN == 0`. Default 1 + // means every sweep. Higher = sparser samples but preserves free- + // tier point budget on providers with tight limits (Bitquery Free + // gives 1000 points / month, so on the default 60-min cadence with + // EveryN=6 we spend ≤ 960 points / month across 8 supported tokens). + MobulaEveryN int + BitqueryEveryN int + CodexEveryN int + + // Page caps: bound the max pagination pages per provider per token + // per sweep. Prevents runaway cursor loops on a single very-heavy + // token from burning a whole sweep's quota. + MobulaMaxPages int + BitqueryMaxRows int // Bitquery has no cursor — this is the per-query row cap + CodexMaxPages int +} + +// LoadConfig reads env, defaults + hardcoded reference token list. +// Reference tokens are chosen for meaningful trade activity so a +// coverage gap becomes visible; rotate them here when a listed token +// goes illiquid. +func LoadConfig() *Config { + return &Config{ + // Base cadence: 60 min. Doubled from the initial 30-min value + // after quota math on Bitquery's 1000-pt free plan (see + // BitqueryEveryN below). 24 sweeps / day gives >= 12 samples per + // (chain, token) per day for providers that run every sweep — + // plenty for a stable p50 over 24h. + SweepSec: envInt("SWEEP_SEC", 3600), + MetricsPort: envStr("METRICS_PORT", "2112"), + HTTPTimeoutSec: envInt("HTTP_TIMEOUT_SEC", 30), + MeasurementWinMs: int64(60*60) * 1000, // 60 min rolling window + MobulaKey: os.Getenv("MOBULA_API_KEY"), + BitqueryKey: os.Getenv("BITQUERY_API_KEY"), + CodexKey: os.Getenv("CODEX_API_KEY"), + + // Mobula: unlimited (own infra). Run every sweep. + MobulaEveryN: envInt("MOBULA_EVERY_N", 1), + // Bitquery Free: 1000 pts / month. At ~1-2 pts per + // DEXTradeByTokens call and 8 supported tokens per sweep, + // running every 6th sweep = 4 sweeps / day × 8 tokens × 30 + // days = 960 calls / month, well inside the free budget. + // Bump to 1 if on the Developer plan ($99/mo, 500k pts). + BitqueryEveryN: envInt("BITQUERY_EVERY_N", 6), + // Codex Free: generous rate-based limits (no monthly point + // cap on the current tier). Run every sweep. + CodexEveryN: envInt("CODEX_EVERY_N", 1), + + // Page caps. A high-volume token that fires cursor-paginated + // requests indefinitely can drain a monthly budget in one + // sweep. These bound the worst case per token per sweep. + MobulaMaxPages: envInt("MOBULA_MAX_PAGES", 20), + BitqueryMaxRows: envInt("BITQUERY_MAX_ROWS", 10000), + CodexMaxPages: envInt("CODEX_MAX_PAGES", 10), + Tokens: []Token{ + // Reference tokens selected 2026-07-23 via a Mobula + // `/api/2/trades/filters` sweep over the last hour, picking + // mid-liquidity actives (not pure blue chips, so provider + // coverage differences show; not pump.fun day-olds, so the + // bench doesn't die if a specific token's activity dries up + // tomorrow). All 10 verified > 50 trades / 1 h at pick time. + // Rotate here when a listed token's volume drops off. + // + // Solana. BONK and WIF are both well past the pump.fun window + // and trade across every Solana DEX indexed by the three + // providers, which is what surfaces real coverage differences. + {Chain: "solana", Address: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", Symbol: "BONK"}, + {Chain: "solana", Address: "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm", Symbol: "WIF"}, + // Ethereum. PEPE covers the top of the meme distribution; + // MOG sits mid-liquidity and often reveals per-DEX gaps that + // PEPE's wall-to-wall coverage masks. + {Chain: "ethereum", Address: "0x6982508145454Ce325dDbE47a25d4ec3d2311933", Symbol: "PEPE"}, + {Chain: "ethereum", Address: "0xaaeE1A9723aaDB7afA2810263653A34bA2C21C7a", Symbol: "MOG"}, + // BSC. CAKE is the anchor (own PancakeSwap listings); FLOKI + // trades across PancakeSwap + long-tail BEP-20 AMMs so the + // per-DEX split shows. + {Chain: "bsc", Address: "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82", Symbol: "CAKE"}, + {Chain: "bsc", Address: "0xfb5B838b6cfEEdC2873aB27866079AC55363D37E", Symbol: "FLOKI"}, + // Base. BRETT and DEGEN — top two active memes on Aerodrome + // + Uniswap v3, wide enough coverage on Base that they're + // stable picks across weeks. + {Chain: "base", Address: "0x532f27101965dd16442E59d40670FaF5eBB142E4", Symbol: "BRETT"}, + {Chain: "base", Address: "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed", Symbol: "DEGEN"}, + // Stellar. yXLM (wrapped XLM) and SHX (Stronghold) are both + // high-activity Stellar-native assets. Address format is + // Stellar's canonical `:` string — Mobula + // accepts that directly as `tokenAddress`. + {Chain: "stellar", Address: "yXLM:GARDNV3Q7YGT4AKSDF25LT32YSCCW4EV22Y2TV3I2PU2MMXJTEDL5T55", Symbol: "yXLM"}, + {Chain: "stellar", Address: "SHX:GDSTRSHXHGJ7ZIVRBXEYE5Q74XUVCUSEKEBR7UCHEUUEK72N7I7KJ6JH", Symbol: "SHX"}, + }, + Capabilities: map[string]ProviderCapability{ + "mobula": { + "solana": true, "ethereum": true, "bsc": true, "base": true, "stellar": true, + }, + "bitquery": { + "solana": true, "ethereum": true, "bsc": true, "base": true, + }, + "codex": { + "solana": true, "ethereum": true, "bsc": true, "base": true, + }, + }, + } +} + +func (c *Config) HTTPTimeout() time.Duration { + return time.Duration(c.HTTPTimeoutSec) * time.Second +} + +// Supports reports whether the (provider, chain) pair is measured. +func (c *Config) Supports(provider, chain string) bool { + caps, ok := c.Capabilities[provider] + if !ok { + return false + } + return caps[chain] +} + +func envInt(k string, def int) int { + v := os.Getenv(k) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} + +func envStr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} diff --git a/harnesses/token-trade-coverage/cmd/scanner/main.go b/harnesses/token-trade-coverage/cmd/scanner/main.go new file mode 100644 index 00000000..017d4560 --- /dev/null +++ b/harnesses/token-trade-coverage/cmd/scanner/main.go @@ -0,0 +1,185 @@ +// Materialize the token-trade-coverage bench (№ 090). +// +// Loop: every SWEEP_SEC, iterate every (provider, chain, token) tuple, +// fetch trades in the same rolling 60-minute window, compute the union +// baseline per (chain, token) as max(counts) across providers and +// publish capture rate + companion metrics to Prometheus. +// +// The design keeps memory bounded: providers return counts, not trade +// arrays, so a token with 50k trades in the window costs O(1) here. + +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var providers = []string{"mobula", "bitquery", "codex"} + +func main() { + cfg := LoadConfig() + log.Printf("[boot] sweep=%ds providers=%v tokens=%d", cfg.SweepSec, providers, len(cfg.Tokens)) + + // Expose /metrics before the first sweep so Prom starts scraping + // immediately; capture_pct just stays 0 until the loop populates it. + http.Handle("/metrics", promhttp.Handler()) + go func() { + addr := ":" + cfg.MetricsPort + log.Printf("[boot] metrics on %s/metrics", addr) + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatalf("http: %v", err) + } + }() + + client := &http.Client{Timeout: cfg.HTTPTimeout()} + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + ticker := time.NewTicker(time.Duration(cfg.SweepSec) * time.Second) + defer ticker.Stop() + + // First sweep runs immediately at boot so Prom sees data within + // SWEEP_SEC + measurement time rather than waiting a full cycle. + iteration := 0 + sweep(ctx, cfg, client, iteration) + for { + select { + case <-ctx.Done(): + log.Printf("[shutdown] signal received") + return + case <-ticker.C: + iteration++ + sweep(ctx, cfg, client, iteration) + } + } +} + +// providerEnabled reports whether the provider should run on this +// iteration given its sub-sampling cadence (see Config.*EveryN). +func providerEnabled(cfg *Config, provider string, iteration int) bool { + var everyN int + switch provider { + case "mobula": + everyN = cfg.MobulaEveryN + case "bitquery": + everyN = cfg.BitqueryEveryN + case "codex": + everyN = cfg.CodexEveryN + default: + return false + } + if everyN <= 1 { + return true + } + return iteration%everyN == 0 +} + +// sweep runs one full measurement cycle. For each token, we fan out +// across every supported provider in parallel, wait for all to finish +// (or fail), compute the union baseline as max(counts) and emit. +// +// `iteration` is the 0-indexed sweep counter — used for per-provider +// sub-sampling (see providerEnabled). Skipped providers keep their +// previous capture_pct value in Prom, which `avg_over_time([24h])` +// then averages across the sparse sample. +func sweep(ctx context.Context, cfg *Config, client *http.Client, iteration int) { + sweepStart := time.Now() + windowEnd := sweepStart.UnixMilli() + windowStart := windowEnd - cfg.MeasurementWinMs + + activeProviders := make([]string, 0, len(providers)) + for _, p := range providers { + if providerEnabled(cfg, p, iteration) { + activeProviders = append(activeProviders, p) + } + } + log.Printf("[sweep] iter=%d active=%v", iteration, activeProviders) + + for _, tok := range cfg.Tokens { + if tok.Address == "" { + // Placeholder row (Stellar addresses TBD). Skip silently + // so the container doesn't spam warnings until they land. + continue + } + results := make([]Result, 0, len(activeProviders)) + var mu sync.Mutex + var wg sync.WaitGroup + + for _, p := range activeProviders { + if !cfg.Supports(p, tok.Chain) { + continue + } + wg.Add(1) + go func(p string) { + defer wg.Done() + start := time.Now() + count, dex, err := fetchOne(ctx, client, cfg, p, tok, windowStart, windowEnd) + latMs := float64(time.Since(start).Milliseconds()) + r := Result{ + Provider: p, + Chain: tok.Chain, + Token: tok.Symbol, + Count: count, + DexCount: dex, + LatencyMs: latMs, + OK: err == nil, + } + if err != nil { + log.Printf("[%s][%s/%s] err: %v", p, tok.Chain, tok.Symbol, err) + } else { + log.Printf("[%s][%s/%s] count=%d dex=%d %.0fms", p, tok.Chain, tok.Symbol, count, dex, latMs) + } + mu.Lock() + results = append(results, r) + mu.Unlock() + }(p) + } + wg.Wait() + + // Union baseline: the largest count observed across providers + // that succeeded. If every provider failed the baseline is 0 + // and capture rate stays 0 across the board (spec `success` + // query then flags the (chain, token) as unresponsive). + unionMax := 0 + for _, r := range results { + if r.OK && r.Count > unionMax { + unionMax = r.Count + } + } + emitCycle(results, unionMax) + } + log.Printf("[sweep] done in %.1fs", time.Since(sweepStart).Seconds()) +} + +// fetchOne dispatches to the provider-specific fetcher, threading each +// provider's page/row cap so a runaway pagination loop can never drain +// a monthly quota in a single sweep. +func fetchOne( + ctx context.Context, + client *http.Client, + cfg *Config, + provider string, + tok Token, + windowStart, windowEnd int64, +) (int, int, error) { + switch provider { + case "mobula": + return fetchMobula(ctx, client, cfg.MobulaKey, tok, windowStart, windowEnd, cfg.MobulaMaxPages) + case "bitquery": + return fetchBitquery(ctx, client, cfg.BitqueryKey, tok, windowStart, windowEnd, cfg.BitqueryMaxRows) + case "codex": + return fetchCodex(ctx, client, cfg.CodexKey, tok, windowStart, windowEnd, cfg.CodexMaxPages) + } + return 0, 0, fmt.Errorf("unknown provider %s", provider) +} diff --git a/harnesses/token-trade-coverage/cmd/scanner/metrics.go b/harnesses/token-trade-coverage/cmd/scanner/metrics.go new file mode 100644 index 00000000..3481c072 --- /dev/null +++ b/harnesses/token-trade-coverage/cmd/scanner/metrics.go @@ -0,0 +1,85 @@ +package main + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// Metric surface. Labels are (provider, chain, token). Kept exactly +// three labels so PromQL aggregation stays simple and the spec's +// `series` query at the bench level can group by any one of them. +var ( + capturePct = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "ocb_token_trade_capture_pct", + Help: "Percent of the union-baseline trade count returned by this provider for (chain, token) in the last measurement window.", + }, []string{"provider", "chain", "token"}) + + absoluteCount = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "ocb_token_trade_absolute_count", + Help: "Raw trade count returned by this provider for (chain, token) in the last measurement window.", + }, []string{"provider", "chain", "token"}) + + queryLatency = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "ocb_token_trade_query_latency_ms", + Help: "Wall-clock latency of the provider's trade fetch call including pagination, in milliseconds.", + }, []string{"provider", "chain", "token"}) + + dexCount = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "ocb_token_trade_dex_count", + Help: "Distinct DEX venues represented in the trade set returned by this provider for (chain, token).", + }, []string{"provider", "chain", "token"}) + + probeOK = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "ocb_token_trade_probe_ok", + Help: "1 on successful fetch, 0 on error or timeout. Consumed by the bench spec's `success` query.", + }, []string{"provider", "chain", "token"}) + + // Cumulative API-call counter for quota observability. Counted at + // the fetchOne granularity — one increment per (provider, token) + // tuple in a sweep, regardless of how many paginated sub-requests + // fired underneath — because that's what maps 1:1 to the provider's + // monthly point/credit budget as billed. `increase(...[30d])` per + // provider gives the running monthly consumption. + apiCalls = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "ocb_token_trade_api_calls_total", + Help: "Total fetchOne invocations per provider since worker start. Query with increase()[30d] for monthly consumption vs free-tier budget.", + }, []string{"provider"}) +) + +// Result is the per-call outcome of one provider fetch. +type Result struct { + Provider string + Chain string + Token string + Count int + DexCount int + LatencyMs float64 + OK bool +} + +// emitCycle publishes one full cycle's worth of results. Called once +// per (chain, token) after every provider has been queried and the +// union baseline is known. +func emitCycle(results []Result, unionMax int) { + for _, r := range results { + lbl := prometheus.Labels{ + "provider": r.Provider, + "chain": r.Chain, + "token": r.Token, + } + absoluteCount.With(lbl).Set(float64(r.Count)) + queryLatency.With(lbl).Set(r.LatencyMs) + dexCount.With(lbl).Set(float64(r.DexCount)) + if r.OK { + probeOK.With(lbl).Set(1) + } else { + probeOK.With(lbl).Set(0) + } + if unionMax > 0 && r.OK { + capturePct.With(lbl).Set(float64(r.Count) / float64(unionMax) * 100) + } else { + capturePct.With(lbl).Set(0) + } + apiCalls.WithLabelValues(r.Provider).Inc() + } +} diff --git a/harnesses/token-trade-coverage/cmd/scanner/mobula.go b/harnesses/token-trade-coverage/cmd/scanner/mobula.go new file mode 100644 index 00000000..ff32cd68 --- /dev/null +++ b/harnesses/token-trade-coverage/cmd/scanner/mobula.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +const mobulaBase = "https://api.mobula.io/api/2/trades/filters" + +// mobulaResp is the minimal envelope we care about: we only need the +// count (via len(data)) and the DEX per row for coverage-breadth. Full +// trade objects are 30+ fields we intentionally ignore. +// +// Field names track Mobula's `/api/2/trades/filters` response as of +// 2026-07-23. `transactionHash` doubles as the dedup key across +// paginated pages. DEX identity comes from `platform.name` when the +// venue is a mapped protocol (Raydium, Uniswap v3, …); otherwise the +// pool address in `marketAddress` acts as a fallback venue proxy. +type mobulaTrade struct { + TransactionHash string `json:"transactionHash"` + Platform *struct { + Name string `json:"name"` + } `json:"platform"` + MarketAddress string `json:"marketAddress"` +} + +type mobulaResp struct { + Data []mobulaTrade `json:"data"` + Pagination struct { + HasMore bool `json:"hasMore"` + NextCursor string `json:"nextCursor"` + } `json:"pagination"` +} + +// fetchMobula returns (trade count, distinct DEXs, error). Paginates +// via cursor until either exhausted or the safety cap is hit. +func fetchMobula( + ctx context.Context, + client *http.Client, + apiKey string, + tok Token, + windowStart, windowEnd int64, + maxPages int, +) (int, int, error) { + if apiKey == "" { + return 0, 0, fmt.Errorf("MOBULA_API_KEY not set") + } + if maxPages <= 0 { + maxPages = 20 + } + var ( + total int + cursor string + dexSet = map[string]struct{}{} + hashSet = map[string]struct{}{} + ) + for page := 0; page < maxPages; page++ { + u, _ := url.Parse(mobulaBase) + q := u.Query() + q.Set("blockchain", mobulaChainName(tok.Chain)) + q.Set("tokenAddress", tok.Address) + q.Set("from", strconv.FormatInt(windowStart, 10)) + q.Set("to", strconv.FormatInt(windowEnd, 10)) + q.Set("limit", "5000") + q.Set("sortOrder", "asc") + if cursor != "" { + q.Set("cursor", cursor) + } + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) + if err != nil { + return total, len(dexSet), err + } + req.Header.Set("Authorization", apiKey) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return total, len(dexSet), err + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return total, len(dexSet), err + } + if resp.StatusCode >= 300 { + return total, len(dexSet), fmt.Errorf("mobula http %d: %s", resp.StatusCode, truncate(string(body), 200)) + } + var r mobulaResp + if err := json.Unmarshal(body, &r); err != nil { + return total, len(dexSet), fmt.Errorf("mobula parse: %w", err) + } + for _, t := range r.Data { + // Dedupe by tx hash across pages. Providers occasionally + // return the same hash on consecutive pages when a cursor + // resets under the hood. + if _, seen := hashSet[t.TransactionHash]; seen { + continue + } + hashSet[t.TransactionHash] = struct{}{} + total++ + // DEX identity: prefer `platform.name` (mapped protocol), + // fall back to `marketAddress` (raw pool address) so a + // venue with no mapped platform still contributes one + // distinct venue to the coverage-breadth metric instead + // of being dropped silently. + if t.Platform != nil && t.Platform.Name != "" { + dexSet[t.Platform.Name] = struct{}{} + } else if t.MarketAddress != "" { + dexSet[t.MarketAddress] = struct{}{} + } + } + if !r.Pagination.HasMore || r.Pagination.NextCursor == "" || len(r.Data) == 0 { + break + } + cursor = r.Pagination.NextCursor + // Cursor pages back-to-back can trip provider rate limits; a + // short breather keeps the fetch inside its budget without + // starving the cadence. + time.Sleep(50 * time.Millisecond) + } + return total, len(dexSet), nil +} + +// mobulaChainName maps OCB canonical chain slug → Mobula chain param +// value. Kept centralised so the mapping is auditable in one place. +func mobulaChainName(chain string) string { + switch chain { + case "solana": + return "Solana" + case "ethereum": + return "Ethereum" + case "bsc": + return "BNB Smart Chain (BEP20)" + case "base": + return "Base" + case "stellar": + return "Stellar" + default: + return chain + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/harnesses/token-trade-coverage/go.mod b/harnesses/token-trade-coverage/go.mod new file mode 100644 index 00000000..000366ca --- /dev/null +++ b/harnesses/token-trade-coverage/go.mod @@ -0,0 +1,17 @@ +module github.com/ChainBench/OpenChainBench/harnesses/token-trade-coverage + +go 1.23 + +require github.com/prometheus/client_golang v1.20.5 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + golang.org/x/sys v0.28.0 // indirect + google.golang.org/protobuf v1.36.1 // indirect +) diff --git a/harnesses/token-trade-coverage/go.sum b/harnesses/token-trade-coverage/go.sum new file mode 100644 index 00000000..c231b510 --- /dev/null +++ b/harnesses/token-trade-coverage/go.sum @@ -0,0 +1,32 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/tokenized-stock-peg/Dockerfile b/harnesses/tokenized-stock-peg/Dockerfile new file mode 100644 index 00000000..63108cfc --- /dev/null +++ b/harnesses/tokenized-stock-peg/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/tokenized-stock-peg/cmd/script/config.go b/harnesses/tokenized-stock-peg/cmd/script/config.go new file mode 100644 index 00000000..5f96da1e --- /dev/null +++ b/harnesses/tokenized-stock-peg/cmd/script/config.go @@ -0,0 +1,77 @@ +package main + +import ( + "os" + "strings" + "time" +) + +// Tokenized-stock-peg harness: onchain price of Robinhood Chain +// tokenized equities (Uniswap v4 pools vs USDG) against the real +// Nasdaq/NYSE price from Yahoo Finance, deviation in basis points, +// labeled by market session state. +// +// Cohort: the 11 official " • Robinhood Token" equities whose +// USDG pool has real liquidity and swap activity (verified 2026-07-13 +// via Blockscout + PoolManager extsload sweep). Excluded and why: +// SPCX (SpaceX is not listed, no reference price exists), SNDK + QQQ +// (pool depth under $2k, pure noise), CRCL (pool has zero liquidity), +// the ~80 other official tokens (issuer-seeded placeholder pools at +// 90-95% fee, zero swaps), HOOD (never issued onchain, only spam). +// +// Orientation: Uniswap v4 orders currencies by address; USDGIsC0 says +// whether USDG (6 decimals) is currency0 in that pool. Stocks are 18 +// decimals, so the raw sqrtPriceX96 price converts with a 1e12 factor +// whose direction depends on the ordering. + +const ( + rpcDefault = "https://rpc.mainnet.chain.robinhood.com" + stateView = "0xF3334192D15450CdD385c8B70e03f9A6bD9E673b" + getSlot0Sel = "0xc815641c" // StateView.getSlot0(bytes32) — live-verified + pollInterval = 60 * time.Second + httpTimeout = 15 * time.Second + // A tokenized stock more than this far from its reference during + // regular hours is displayed but flagged; used only for logging. + logThresholdBps = 100.0 +) + +type Asset struct { + Symbol string // Yahoo ticker == display slug (lowercased for labels) + Token string + PoolID string + FeePPM int + USDGIsC0 bool +} + +// Illiquid tickers dropped 2026-07-16: GOOGL, TSLA, MSFT, AMZN, SPY. +// Their Uniswap v4 pools on Robinhood Chain have no arb activity; +// the pool spot price stays frozen at the last swap value (repeats +// tick after tick) while the Yahoo reference moves normally. Ref +// triggers fire but the pool never converges within the settle +// window, so events roll over as still_open with zero latency +// samples. The 5 dropped names surfaced as "unresponsive" on the +// bench page with no headline number. Cohort restricted to the 6 +// tickers with observable arb activity: NVDA, AAPL, PLTR, META, +// AMD, MU. Re-add when a pool's swap volume picks up. +var assets = []Asset{ + {Symbol: "NVDA", Token: "0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC", PoolID: "0x3bb34a44f1b2b5f32c034c38a53065a521a47b199700fa9bd19d60985ff24bf1", FeePPM: 3000, USDGIsC0: true}, + {Symbol: "AAPL", Token: "0xaF3D76f1834A1d425780943C99Ea8A608f8a93f9", PoolID: "0xda4116b5894ee7479e64eae9276e1a2944ef0e5ce863a299d296a15618deee01", FeePPM: 10000, USDGIsC0: true}, + {Symbol: "PLTR", Token: "0x894E1EC2D74FFE5AEF8Dc8A9e84686acCB964F2A", PoolID: "0xee430ee1003e1985e1828a01b9a20dad67ad4302994fe2abb4a173de4ac54623", FeePPM: 10000, USDGIsC0: true}, + {Symbol: "META", Token: "0xc0D6457C16Cc70d6790Dd43521C899C87ce02f35", PoolID: "0x5875d407a42965b0e768c8925cea290e06fa50603ef34fc99eb92a1050e6ae36", FeePPM: 3000, USDGIsC0: true}, + {Symbol: "AMD", Token: "0x86923f96303D656E4aa86D9d42D1e57ad2023fdC", PoolID: "0xde9f85fdd9e05a943a52f2c69ffafe3064a3287df03d02c9b431bc92d4781274", FeePPM: 10000, USDGIsC0: true}, + {Symbol: "MU", Token: "0xfF080c8ce2E5feadaCa0Da81314Ae59D232d4afD", PoolID: "0x6fa3ee0048e78bf0a513eb0ab56f482944a767c21db990fcf555605e69f05659", FeePPM: 10000, USDGIsC0: true}, +} + +func rpcURL() string { + if v := strings.TrimSpace(os.Getenv("TSP_RPC_URL")); v != "" { + return v + } + return rpcDefault +} + +func listenAddr() string { + if v := strings.TrimSpace(os.Getenv("LISTEN_ADDR")); v != "" { + return v + } + return ":2112" +} diff --git a/harnesses/tokenized-stock-peg/cmd/script/loghub.go b/harnesses/tokenized-stock-peg/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/tokenized-stock-peg/cmd/script/loghub.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a +// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token +// matching the LOGS_TOKEN env var. +// +// Keep in sync across miniapps (was previously the shared/loghub package; we +// inline because Railway's per-harness Docker build context can't reach a +// sibling shared module via go.mod replace). + +const logRingMax = 5000 + +type logRing struct { + mu sync.Mutex + lines []string + max int +} + +var globalLogRing = &logRing{max: logRingMax} + +func (b *logRing) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + if len(b.lines) >= b.max { + b.lines = append(b.lines[1:], entry) + } else { + b.lines = append(b.lines, entry) + } + b.mu.Unlock() +} + +func (b *logRing) snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + if tail <= 0 || tail >= len(b.lines) { + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out + } + start := len(b.lines) - tail + out := make([]string, tail) + copy(out, b.lines[start:]) + return out +} + +var logSetupOnce sync.Once + +// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a +// pipe, then spawns a goroutine that fan-outs every line to the original +// stdout AND the in-memory ring buffer. Call exactly once, very early in +// main(). +func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) } + +func doInstallLogCapture() { + originalStdout := os.Stdout + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err) + return + } + os.Stdout = w + os.Stderr = w + + go func() { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(originalStdout, line) + globalLogRing.push(line) + } + _, _ = io.Copy(originalStdout, r) + _ = originalStderr + }() +} + +// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header +// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset. +func logsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := os.Getenv("LOGS_TOKEN") + if expected == "" { + http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden) + return + } + if r.Header.Get("X-Logs-Token") != expected { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 500 + if t := r.URL.Query().Get("tail"); t != "" { + if n, err := strconv.Atoi(t); err == nil && n > 0 { + tail = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, l := range globalLogRing.snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/tokenized-stock-peg/cmd/script/main.go b/harnesses/tokenized-stock-peg/cmd/script/main.go new file mode 100644 index 00000000..a12e3e9d --- /dev/null +++ b/harnesses/tokenized-stock-peg/cmd/script/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "math" + "net/http" + "strings" + "time" +) + +func main() { + installLogCapture() + fmt.Println("=== Tokenized Stock Peg Harness ===") + fmt.Println("OpenChainBench — Robinhood Chain tokenized equities vs Nasdaq reference.") + fmt.Printf("Cohort: %d assets | poll: %s | RPC: %s\n", len(assets), pollInterval, rpcURL()) + for _, a := range assets { + fmt.Printf(" - %-6s token=%s pool=%s… fee=%.2f%%\n", a.Symbol, a.Token[:10]+"…", a.PoolID[:14], float64(a.FeePPM)/10000) + } + + go func() { + if err := startMetricsServer(listenAddr()); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + } + }() + + client := &http.Client{Timeout: httpTimeout} + var periods *tradingPeriods + + tick := func() { + now := time.Now() + if periods == nil || now.Sub(periods.FetchedAt) > 30*time.Minute { + if tp := fetchTradingPeriods(client); tp != nil { + periods = tp + } + } + state := periods.state(now) + for _, s := range []string{"pre", "regular", "post", "closed", "unknown"} { + v := 0.0 + if s == state { + v = 1.0 + } + tspMarketState.WithLabelValues(s).Set(v) + } + + refs := fetchReferencePrices(client) + onchain := fetchOnchainPrices(client) + + for _, a := range assets { + sym := strings.ToLower(a.Symbol) + ref, hasRef := refs[sym] + pool, hasPool := onchain[sym] + if hasRef { + tspPriceReference.WithLabelValues(sym).Set(ref.Price) + if ref.AsOfSec > 0 { + tspRefAge.WithLabelValues(sym).Set(float64(now.Unix() - ref.AsOfSec)) + } + } + if hasPool { + tspPriceOnchain.WithLabelValues(sym, "robinhood").Set(pool) + } + if hasRef && hasPool && ref.Price > 0 { + dev := math.Abs(pool-ref.Price) / ref.Price * 10000 + tspDeviationBps.WithLabelValues(sym, state, "robinhood").Set(dev) + tspHealth.WithLabelValues(sym).Set(1) + flag := "" + if dev > logThresholdBps && state == "regular" { + flag = " <-- wide" + } + fmt.Printf("[%s][%s] pool=%.2f ref=%.2f dev=%.1fbps%s\n", sym, state, pool, ref.Price, dev, flag) + } else { + tspHealth.WithLabelValues(sym).Set(0) + } + } + } + + tick() + t := time.NewTicker(pollInterval) + defer t.Stop() + for range t.C { + tick() + } +} diff --git a/harnesses/tokenized-stock-peg/cmd/script/metrics.go b/harnesses/tokenized-stock-peg/cmd/script/metrics.go new file mode 100644 index 00000000..82072a68 --- /dev/null +++ b/harnesses/tokenized-stock-peg/cmd/script/metrics.go @@ -0,0 +1,67 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + // Headline: absolute deviation of the onchain pool price from the + // Yahoo reference, in basis points, labeled with the market session + // the sample was taken in. The bench pins its ranking to + // market_state="regular"; the closed-state series is the weekend / + // overnight drift panel. + tspDeviationBps = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_deviation_bps", + Help: "Absolute onchain vs reference price deviation per tokenized stock, in bps, labeled by market session state.", + }, []string{"asset", "market_state", "issuer"}) + + tspPriceOnchain = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_price_onchain_usdg", + Help: "Venue spot price of the tokenized stock, in the venue quote stable.", + }, []string{"asset", "issuer"}) + + tspPriceReference = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_price_reference_usd", + Help: "Reference equity price from Yahoo Finance (regularMarketPrice; last close when the market is closed).", + }, []string{"asset"}) + + tspRefAge = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_reference_age_seconds", + Help: "Age of the reference price sample (now minus regularMarketTime). Large outside regular hours by design.", + }, []string{"asset"}) + + tspMarketState = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_market_session", + Help: "1 for the currently active market session label, 0 otherwise.", + }, []string{"market_state"}) + + tspSourceLatency = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_source_latency_milliseconds", + Help: "Round-trip latency of the last fetch per source.", + }, []string{"source"}) + + tspSourceCall = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "tsp_source_call_total", + Help: "Fetch outcomes per source (onchain batch, yahoo spark, yahoo chart).", + }, []string{"source", "result"}) + + tspHealth = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_health", + Help: "1 when the last tick produced a deviation sample for the asset, 0 otherwise.", + }, []string{"asset"}) +) + +func startMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/tokenized-stock-peg/cmd/script/onchain.go b/harnesses/tokenized-stock-peg/cmd/script/onchain.go new file mode 100644 index 00000000..206b1a6f --- /dev/null +++ b/harnesses/tokenized-stock-peg/cmd/script/onchain.go @@ -0,0 +1,129 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "strings" + "time" +) + +// Onchain leg: one JSON-RPC batch per tick, one eth_call per asset to +// StateView.getSlot0(poolId) on the Uniswap v4 singleton's view +// contract. The response's first 32-byte word is sqrtPriceX96. +// +// Price math (v4 = v3 semantics): raw = (sqrtPriceX96 / 2^96)^2 is the +// amount of currency1 base units per 1 base unit of currency0. With +// USDG at 6 decimals and stocks at 18: +// USDG is currency0 → USDG per stock = 1e12 / raw +// USDG is currency1 → USDG per stock = raw × 1e12 +// (verified against the live AAPL pool: 319.42 USDG on 2026-07-13). + +type batchReq struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params []any `json:"params"` +} + +type batchResp struct { + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +var q96 = new(big.Float).SetPrec(200).SetInt(new(big.Int).Lsh(big.NewInt(1), 96)) + +func fetchOnchainPrices(client *http.Client) map[string]float64 { + reqs := make([]batchReq, 0, len(assets)) + for i, a := range assets { + data := getSlot0Sel + strings.TrimPrefix(strings.ToLower(a.PoolID), "0x") + reqs = append(reqs, batchReq{ + JSONRPC: "2.0", ID: i, Method: "eth_call", + Params: []any{map[string]string{"to": stateView, "data": data}, "latest"}, + }) + } + body, _ := json.Marshal(reqs) + req, err := http.NewRequest("POST", rpcURL(), bytes.NewReader(body)) + if err != nil { + return nil + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + + start := time.Now() + resp, err := client.Do(req) + if err != nil { + tspSourceCall.WithLabelValues("onchain", "network").Inc() + return nil + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<22)) + if err != nil || resp.StatusCode != 200 { + tspSourceCall.WithLabelValues("onchain", fmt.Sprintf("http_%d", resp.StatusCode)).Inc() + return nil + } + tspSourceLatency.WithLabelValues("onchain").Set(float64(time.Since(start).Milliseconds())) + + var out []batchResp + if err := json.Unmarshal(raw, &out); err != nil { + tspSourceCall.WithLabelValues("onchain", "parse").Inc() + return nil + } + prices := make(map[string]float64, len(assets)) + for _, r := range out { + if r.ID < 0 || r.ID >= len(assets) { + continue + } + a := assets[r.ID] + sym := strings.ToLower(a.Symbol) + if r.Error != nil { + // Issuer pause/block or pool state error: skip the sample so + // Prom staleness ages the series out instead of freezing it. + tspSourceCall.WithLabelValues("onchain", "rpc_error").Inc() + fmt.Printf("[%s] onchain error: %s\n", sym, r.Error.Message) + continue + } + p, ok := slot0ToUSDG(string(r.Result), a.USDGIsC0) + if !ok || p <= 0 { + tspSourceCall.WithLabelValues("onchain", "decode").Inc() + continue + } + prices[sym] = p + tspSourceCall.WithLabelValues("onchain", "ok").Inc() + } + return prices +} + +// slot0ToUSDG parses the getSlot0 return blob (sqrtPriceX96 is the +// first word) and converts to USDG per stock respecting pool ordering. +func slot0ToUSDG(resultJSON string, usdgIsC0 bool) (float64, bool) { + hexStr := strings.Trim(resultJSON, `"`) + hexStr = strings.TrimPrefix(hexStr, "0x") + if len(hexStr) < 64 { + return 0, false + } + sqrtInt, ok := new(big.Int).SetString(hexStr[:64], 16) + if !ok || sqrtInt.Sign() == 0 { + return 0, false + } + sqrtF := new(big.Float).SetPrec(200).SetInt(sqrtInt) + ratio := new(big.Float).SetPrec(200).Quo(sqrtF, q96) + raw := new(big.Float).SetPrec(200).Mul(ratio, ratio) + + e12 := new(big.Float).SetPrec(200).SetFloat64(1e12) + var price *big.Float + if usdgIsC0 { + price = new(big.Float).SetPrec(200).Quo(e12, raw) + } else { + price = new(big.Float).SetPrec(200).Mul(raw, e12) + } + f, _ := price.Float64() + return f, true +} diff --git a/harnesses/tokenized-stock-peg/cmd/script/reference.go b/harnesses/tokenized-stock-peg/cmd/script/reference.go new file mode 100644 index 00000000..3ebc5c3b --- /dev/null +++ b/harnesses/tokenized-stock-peg/cmd/script/reference.go @@ -0,0 +1,207 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Reference leg: Yahoo Finance, keyless. One spark batch call fetches +// the latest price for every symbol; one chart call (AAPL as the +// bellwether) fetches the day's exact session windows, which Yahoo +// publishes holiday-aware so the harness never maintains an NYSE +// calendar. Verified 2026-07-13 from the harness host: clean 200s with +// a browser User-Agent (datacenter IP), 70-130ms. +// +// Market state is derived from currentTradingPeriod epochs: +// pre / regular / post / closed. Deviation samples carry the state as +// a label so the bench can pin its headline to regular hours and read +// the weekend drift from the closed-state series. + +const ( + sparkHost = "https://query1.finance.yahoo.com/v8/finance/spark" + chartHost = "https://query1.finance.yahoo.com/v8/finance/chart/AAPL" + yahooUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" +) + +type refQuote struct { + Price float64 + AsOfSec int64 +} + +type tradingPeriods struct { + PreStart, PreEnd int64 + RegularStart, RegularEnd int64 + PostStart, PostEnd int64 + FetchedAt time.Time +} + +// fetchReferencePrices returns the freshest Yahoo price per symbol +// (lowercased) from one spark batch call. When the market is closed +// the spark meta still carries regularMarketPrice = last close, which +// is exactly the weekend reference we want. +func fetchReferencePrices(client *http.Client) map[string]refQuote { + syms := make([]string, 0, len(assets)) + for _, a := range assets { + syms = append(syms, a.Symbol) + } + url := sparkHost + "?symbols=" + strings.Join(syms, ",") + "&range=1d&interval=1m" + raw, status := yahooGet(client, url) + if raw == nil { + tspSourceCall.WithLabelValues("yahoo", status).Inc() + // Rotate to query2 once before giving up this tick. + raw, status = yahooGet(client, strings.Replace(url, "query1", "query2", 1)) + if raw == nil { + tspSourceCall.WithLabelValues("yahoo", status).Inc() + return nil + } + } + tspSourceCall.WithLabelValues("yahoo", "ok").Inc() + + // Spark response: {"spark":{"result":[{"symbol":"AAPL","response":[{"meta":{...}}]}]}} + // or the flatter {"AAPL":{...}} shape depending on edge; handle both. + prices := make(map[string]refQuote, len(syms)) + var envel struct { + Spark struct { + Result []struct { + Symbol string `json:"symbol"` + Response []struct { + Meta struct { + RegularMarketPrice float64 `json:"regularMarketPrice"` + RegularMarketTime int64 `json:"regularMarketTime"` + } `json:"meta"` + } `json:"response"` + } `json:"result"` + } `json:"spark"` + } + if err := json.Unmarshal(raw, &envel); err == nil { + for _, r := range envel.Spark.Result { + if len(r.Response) == 0 || r.Response[0].Meta.RegularMarketPrice <= 0 { + continue + } + prices[strings.ToLower(r.Symbol)] = refQuote{ + Price: r.Response[0].Meta.RegularMarketPrice, + AsOfSec: r.Response[0].Meta.RegularMarketTime, + } + } + } + if len(prices) == 0 { + // Flat spark shape (observed live 2026-07-13): + // {"MSFT":{"timestamp":[...],"close":[...],"previousClose":X},...} + // Price = last non-null close; previousClose is the fallback when + // the close array is empty (market closed all day). + var flat map[string]struct { + Timestamp []int64 `json:"timestamp"` + Close []*float64 `json:"close"` + PreviousClose *float64 `json:"previousClose"` + } + if err := json.Unmarshal(raw, &flat); err == nil { + for sym, v := range flat { + q := refQuote{} + for i := len(v.Close) - 1; i >= 0; i-- { + if v.Close[i] != nil && *v.Close[i] > 0 { + q.Price = *v.Close[i] + if i < len(v.Timestamp) { + q.AsOfSec = v.Timestamp[i] + } + break + } + } + if q.Price == 0 && v.PreviousClose != nil && *v.PreviousClose > 0 { + q.Price = *v.PreviousClose + } + if q.Price > 0 { + prices[strings.ToLower(sym)] = q + } + } + } + } + if len(prices) == 0 { + tspSourceCall.WithLabelValues("yahoo", "parse_empty").Inc() + head := string(raw) + if len(head) > 400 { + head = head[:400] + } + fmt.Printf("[yahoo] spark yielded no symbols; body head: %s\n", head) + return nil + } + return prices +} + +// fetchTradingPeriods reads currentTradingPeriod from one chart call. +// Refreshed every 30 minutes; between refreshes marketState() reuses +// the cached windows. +func fetchTradingPeriods(client *http.Client) *tradingPeriods { + raw, status := yahooGet(client, chartHost+"?range=1d&interval=5m") + if raw == nil { + tspSourceCall.WithLabelValues("yahoo_chart", status).Inc() + return nil + } + var envel struct { + Chart struct { + Result []struct { + Meta struct { + CurrentTradingPeriod struct { + Pre struct{ Start, End int64 } `json:"pre"` + Regular struct{ Start, End int64 } `json:"regular"` + Post struct{ Start, End int64 } `json:"post"` + } `json:"currentTradingPeriod"` + } `json:"meta"` + } `json:"result"` + } `json:"chart"` + } + if err := json.Unmarshal(raw, &envel); err != nil || len(envel.Chart.Result) == 0 { + tspSourceCall.WithLabelValues("yahoo_chart", "parse").Inc() + return nil + } + m := envel.Chart.Result[0].Meta.CurrentTradingPeriod + tspSourceCall.WithLabelValues("yahoo_chart", "ok").Inc() + return &tradingPeriods{ + PreStart: m.Pre.Start, PreEnd: m.Pre.End, + RegularStart: m.Regular.Start, RegularEnd: m.Regular.End, + PostStart: m.Post.Start, PostEnd: m.Post.End, + FetchedAt: time.Now(), + } +} + +func (tp *tradingPeriods) state(now time.Time) string { + if tp == nil { + return "unknown" + } + u := now.Unix() + switch { + case u >= tp.RegularStart && u < tp.RegularEnd: + return "regular" + case u >= tp.PreStart && u < tp.PreEnd: + return "pre" + case u >= tp.PostStart && u < tp.PostEnd: + return "post" + default: + return "closed" + } +} + +func yahooGet(client *http.Client, url string) ([]byte, string) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, "request_build" + } + req.Header.Set("User-Agent", yahooUA) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, "network" + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<22)) + if err != nil { + return nil, "read" + } + if resp.StatusCode != 200 { + return nil, fmt.Sprintf("http_%d", resp.StatusCode) + } + return raw, "ok" +} diff --git a/harnesses/tokenized-stock-peg/go.mod b/harnesses/tokenized-stock-peg/go.mod new file mode 100644 index 00000000..510f4f14 --- /dev/null +++ b/harnesses/tokenized-stock-peg/go.mod @@ -0,0 +1,18 @@ +module tokenized-stock-peg + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/tokenized-stock-peg/go.sum b/harnesses/tokenized-stock-peg/go.sum new file mode 100644 index 00000000..d6b8ca98 --- /dev/null +++ b/harnesses/tokenized-stock-peg/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/transaction-fee/cmd/script/config.go b/harnesses/transaction-fee/cmd/script/config.go index 24286094..15a20bb0 100644 --- a/harnesses/transaction-fee/cmd/script/config.go +++ b/harnesses/transaction-fee/cmd/script/config.go @@ -136,6 +136,15 @@ func loadConfig() *Config { HasPriorityMarket: true, Layer: "l2", }, + { + Slug: "robinhood", + Name: "Robinhood Chain", + Kind: KindEVM, + RPCURL: getenvDefault("RPC_ROBINHOOD", "https://rpc.mainnet.chain.robinhood.com"), + MobulaSlug: "ethereum", + HasPriorityMarket: true, + Layer: "l2", + }, { Slug: "base", Name: "Base", diff --git a/harnesses/usdy-nav-basis/Dockerfile b/harnesses/usdy-nav-basis/Dockerfile new file mode 100644 index 00000000..63108cfc --- /dev/null +++ b/harnesses/usdy-nav-basis/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/usdy-nav-basis/cmd/script/loghub.go b/harnesses/usdy-nav-basis/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/usdy-nav-basis/cmd/script/loghub.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a +// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token +// matching the LOGS_TOKEN env var. +// +// Keep in sync across miniapps (was previously the shared/loghub package; we +// inline because Railway's per-harness Docker build context can't reach a +// sibling shared module via go.mod replace). + +const logRingMax = 5000 + +type logRing struct { + mu sync.Mutex + lines []string + max int +} + +var globalLogRing = &logRing{max: logRingMax} + +func (b *logRing) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + if len(b.lines) >= b.max { + b.lines = append(b.lines[1:], entry) + } else { + b.lines = append(b.lines, entry) + } + b.mu.Unlock() +} + +func (b *logRing) snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + if tail <= 0 || tail >= len(b.lines) { + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out + } + start := len(b.lines) - tail + out := make([]string, tail) + copy(out, b.lines[start:]) + return out +} + +var logSetupOnce sync.Once + +// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a +// pipe, then spawns a goroutine that fan-outs every line to the original +// stdout AND the in-memory ring buffer. Call exactly once, very early in +// main(). +func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) } + +func doInstallLogCapture() { + originalStdout := os.Stdout + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err) + return + } + os.Stdout = w + os.Stderr = w + + go func() { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(originalStdout, line) + globalLogRing.push(line) + } + _, _ = io.Copy(originalStdout, r) + _ = originalStderr + }() +} + +// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header +// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset. +func logsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := os.Getenv("LOGS_TOKEN") + if expected == "" { + http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden) + return + } + if r.Header.Get("X-Logs-Token") != expected { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 500 + if t := r.URL.Query().Get("tail"); t != "" { + if n, err := strconv.Atoi(t); err == nil && n > 0 { + tail = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, l := range globalLogRing.snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/usdy-nav-basis/cmd/script/main.go b/harnesses/usdy-nav-basis/cmd/script/main.go new file mode 100644 index 00000000..099fb63a --- /dev/null +++ b/harnesses/usdy-nav-basis/cmd/script/main.go @@ -0,0 +1,246 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "math" + "math/big" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// usdy-nav-basis: live basis between USDY's (Ondo tokenized treasury) +// onchain market price and its official redemption rate (NAV). +// +// NAV leg: Pyth Hermes "Crypto.USDY/USD.RR" redemption-rate feed. The +// same Hermes call also carries the Pyth USDY/USD market composite, +// which doubles as a cross-check venue row. Market leg: the Orca +// whirlpool USDY/USDC on Solana (deepest genuine pool, ~$2.9M), +// decoded straight from getAccountInfo (sqrtPrice u128 LE at bytes +// 65..81, both mints 6 decimals so no scale factor). +// +// Excluded, verified 2026-07-13: the Arbitrum Camelot pool holds 232 +// USDY against 7M USDC (drained), its price sits ~345 bps off NAV and +// moves nothing; kept out of the ranking, documented in the spec as +// the cautionary example of why pool depth gates peg quality. +// +// All legs keyless. One Hermes GET + one Solana RPC POST per tick. + +const ( + navFeedID = "e3d1723999820435ebab53003a542ff26847720692af92523eea613a9a28d500" + marketFeedID = "e393449f6aff8a4b6d3e1165a7c9ebec103685f3b41e60db4277b5b6d10e7326" + orcaPool = "AGXrswVDRoUf62UX9voTXv6TCGw6fBUEwDpyUd9YdZfD" + + pollInterval = 60 * time.Second + httpTimeout = 15 * time.Second +) + +var ( + basisBps = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "usdy_basis_bps", + Help: "Signed basis of the venue's USDY price vs the official redemption rate, in bps (positive = premium over NAV).", + }, []string{"venue"}) + + navGauge = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "usdy_nav_usd", + Help: "USDY official redemption rate from the Pyth RR feed.", + }) + + marketPrice = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "usdy_market_price_usd", + Help: "USDY market price per venue.", + }, []string{"venue"}) + + sourceCall = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "usdy_source_call_total", + Help: "Fetch outcomes per source.", + }, []string{"source", "result"}) + + healthGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "usdy_health", + Help: "1 when the venue produced a basis sample on the last tick.", + }, []string{"venue"}) +) + +func envDefault(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +func main() { + installLogCapture() + fmt.Println("=== USDY NAV Basis Harness ===") + fmt.Println("OpenChainBench — USDY market price vs official redemption rate, keyless.") + + go func() { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + if err := http.ListenAndServe(envDefault("LISTEN_ADDR", ":2112"), mux); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + } + }() + + client := &http.Client{Timeout: httpTimeout} + tick := func() { + nav, market := fetchHermes(client) + orca := fetchOrca(client) + + if nav <= 0 { + healthGauge.WithLabelValues("orca-solana").Set(0) + healthGauge.WithLabelValues("pyth-market").Set(0) + return + } + navGauge.Set(nav) + + emit := func(venue string, price float64) { + if price <= 0 { + healthGauge.WithLabelValues(venue).Set(0) + return + } + marketPrice.WithLabelValues(venue).Set(price) + bps := (price - nav) / nav * 10000 + basisBps.WithLabelValues(venue).Set(bps) + healthGauge.WithLabelValues(venue).Set(1) + fmt.Printf("[%s] price=%.6f nav=%.6f basis=%+.1fbps\n", venue, price, nav, bps) + } + emit("orca-solana", orca) + emit("pyth-market", market) + } + + tick() + t := time.NewTicker(pollInterval) + defer t.Stop() + for range t.C { + tick() + } +} + +// fetchHermes returns (nav, market) from one Hermes call. +func fetchHermes(client *http.Client) (float64, float64) { + url := "https://hermes.pyth.network/v2/updates/price/latest?ids[]=0x" + navFeedID + "&ids[]=0x" + marketFeedID + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + resp, err := client.Do(req) + if err != nil { + sourceCall.WithLabelValues("hermes", "network").Inc() + return 0, 0 + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != 200 { + sourceCall.WithLabelValues("hermes", fmt.Sprintf("http_%d", resp.StatusCode)).Inc() + return 0, 0 + } + var envel struct { + Parsed []struct { + ID string `json:"id"` + Price struct { + Price string `json:"price"` + Expo int `json:"expo"` + } `json:"price"` + } `json:"parsed"` + } + if err := json.Unmarshal(raw, &envel); err != nil { + sourceCall.WithLabelValues("hermes", "parse").Inc() + return 0, 0 + } + nav, market := 0.0, 0.0 + for _, p := range envel.Parsed { + v, err := strconv.ParseFloat(p.Price.Price, 64) + if err != nil { + continue + } + val := v * math.Pow10(p.Price.Expo) + switch strings.TrimPrefix(strings.ToLower(p.ID), "0x") { + case navFeedID: + nav = val + case marketFeedID: + market = val + } + } + sourceCall.WithLabelValues("hermes", "ok").Inc() + return nav, market +} + +// fetchOrca decodes the whirlpool sqrtPrice (u128 LE at bytes 65..81); +// USDY and USDC are both 6 decimals so price = (sqrtPrice/2^64)^2. +// publicnode.com burst-rate-limits us to ~40% getAccountInfo success on +// the VPS; 2 retries with backoff take that to >95% without changing RPCs. +func fetchOrca(client *http.Client) float64 { + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * 500 * time.Millisecond) + } + if p := fetchOrcaOnce(client); p > 0 { + return p + } + } + return 0 +} + +func fetchOrcaOnce(client *http.Client) float64 { + body := []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","id":%d,"method":"getAccountInfo","params":["%s",{"encoding":"base64","commitment":"confirmed"}]}`, + time.Now().UnixNano(), orcaPool, + )) + req, _ := http.NewRequest("POST", envDefault("USDY_SOLANA_RPC", "https://solana-rpc.publicnode.com"), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + resp, err := client.Do(req) + if err != nil { + sourceCall.WithLabelValues("orca", "network").Inc() + return 0 + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != 200 { + sourceCall.WithLabelValues("orca", fmt.Sprintf("http_%d", resp.StatusCode)).Inc() + return 0 + } + var envel struct { + Result struct { + Value struct { + Data []string `json:"data"` + } `json:"value"` + } `json:"result"` + } + if err := json.Unmarshal(raw, &envel); err != nil || len(envel.Result.Value.Data) == 0 { + sourceCall.WithLabelValues("orca", "parse").Inc() + return 0 + } + acct, err := base64.StdEncoding.DecodeString(envel.Result.Value.Data[0]) + if err != nil || len(acct) < 81 { + sourceCall.WithLabelValues("orca", "decode").Inc() + return 0 + } + lo := binary.LittleEndian.Uint64(acct[65:73]) + hi := binary.LittleEndian.Uint64(acct[73:81]) + sqrtPrice := new(big.Float).SetPrec(200).SetInt(new(big.Int).Add( + new(big.Int).Lsh(new(big.Int).SetUint64(hi), 64), + new(big.Int).SetUint64(lo), + )) + q64 := new(big.Float).SetPrec(200).SetInt(new(big.Int).Lsh(big.NewInt(1), 64)) + ratio := new(big.Float).Quo(sqrtPrice, q64) + price := new(big.Float).Mul(ratio, ratio) + f, _ := price.Float64() + sourceCall.WithLabelValues("orca", "ok").Inc() + return f +} diff --git a/harnesses/usdy-nav-basis/go.mod b/harnesses/usdy-nav-basis/go.mod new file mode 100644 index 00000000..1284070e --- /dev/null +++ b/harnesses/usdy-nav-basis/go.mod @@ -0,0 +1,18 @@ +module usdy-nav-basis + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/usdy-nav-basis/go.sum b/harnesses/usdy-nav-basis/go.sum new file mode 100644 index 00000000..d6b8ca98 --- /dev/null +++ b/harnesses/usdy-nav-basis/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/validator-yield/cmd/script/avalanche.go b/harnesses/validator-yield/cmd/script/avalanche.go new file mode 100644 index 00000000..80812d4b --- /dev/null +++ b/harnesses/validator-yield/cmd/script/avalanche.go @@ -0,0 +1,269 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "time" +) + +const ( + avalancheChain = "avalanche" + avalancheValidatorsTag = "avax_platform_getCurrentValidators" + coingeckoAvaxSourceTag = "coingecko_avax" + + // Cap: Avalanche has ~1500 primary-network validators. Top 100 by + // stake covers the vast majority of delegated AVAX and keeps + // Prometheus cardinality bounded. + avalancheTopN = 100 + + // Avalanche P-Chain base staking reward is a documented ~8.5% APY + // for a validator delegating for a full year at 100% uptime. The + // P-Chain API doesn't publish a per-validator realised APR — the + // number is deterministic given (stake, duration, uptime), and + // duration doesn't materially move for long-running delegators. + // v1 shortcut: use the documented base and adjust per-validator by + // uptime × (1 - delegationFee/100). Real per-validator numbers vary + // by ±20 bps due to duration-bonus differences; acceptable for a + // network-comparison bench. + avalancheBaseAPR = 0.085 +) + +var ( + avalanchePChainURL = envDefault("VAL_ECON_AVAX_PCHAIN_URL", "https://api.avax.network/ext/bc/P") + coingeckoAvaxURL = envDefault("VAL_ECON_COINGECKO_AVAX_URL", "https://api.coingecko.com/api/v3/simple/price?ids=avalanche-2&vs_currencies=usd") +) + +// avaxRPCEnvelope wraps the JSON-RPC 2.0 response used by the P-Chain +// `platform` API. +type avaxRPCEnvelope struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// avaxValidator mirrors the shape returned by +// platform.getCurrentValidators. Numeric fields come back as strings. +// +// - stakeAmount: nAVAX (1 AVAX = 1e9 nAVAX) +// - delegationFee: percentage string, e.g. "2.0000" = 2% +// - uptime: fractional string in [0,1] on newer avalanchego, +// older nodes returned percent [0,100] — normalise both. +type avaxValidator struct { + NodeID string `json:"nodeID"` + StakeAmount string `json:"stakeAmount"` + Weight string `json:"weight"` // some responses use weight instead of stakeAmount + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + DelegationFee string `json:"delegationFee"` + Uptime string `json:"uptime"` + Connected bool `json:"connected"` + Signer *struct { + PublicKey string `json:"publicKey"` + } `json:"signer"` + Delegators any `json:"delegators"` // ignored — variable shape, blobs cause parse fights +} + +type avaxCurrentValidatorsResult struct { + Validators []avaxValidator `json:"validators"` +} + +func avaxPChainRPC(ctx context.Context, client *http.Client, method string, params any, out any) error { + if params == nil { + params = map[string]any{} + } + payload := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, avalanchePChainURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + return fmt.Errorf("avax pchain %s http %d: %s", method, resp.StatusCode, string(raw)) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + var env avaxRPCEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return fmt.Errorf("avax pchain %s envelope parse: %w", method, err) + } + if env.Error != nil { + return fmt.Errorf("avax pchain %s error %d: %s", method, env.Error.Code, env.Error.Message) + } + if err := json.Unmarshal(env.Result, out); err != nil { + return fmt.Errorf("avax pchain %s result parse: %w", method, err) + } + return nil +} + +func fetchAvalancheValidators(ctx context.Context, client *http.Client) ([]avaxValidator, error) { + var out avaxCurrentValidatorsResult + // Empty params returns primary network validators, all subnets excluded. + err := avaxPChainRPC(ctx, client, "platform.getCurrentValidators", map[string]any{}, &out) + return out.Validators, err +} + +func fetchAvalanchePrice(ctx context.Context, client *http.Client) (float64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, coingeckoAvaxURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0") + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("coingecko avax http %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + var payload map[string]map[string]float64 + if err := json.Unmarshal(body, &payload); err != nil { + return 0, fmt.Errorf("coingecko avax parse: %w", err) + } + v, ok := payload["avalanche-2"] + if !ok { + return 0, fmt.Errorf("coingecko missing avalanche-2 key") + } + price := v["usd"] + if price <= 0 { + return 0, fmt.Errorf("coingecko invalid avax price %f", price) + } + return price, nil +} + +// parseAvaxUptime normalises the uptime field returned by +// platform.getCurrentValidators. Newer nodes report it as a fractional +// value in [0,1] ("0.998"); older nodes returned a percent ("99.8"). +// If the parsed value is >1 we treat it as a percent, else a fraction. +func parseAvaxUptime(s string) float64 { + if s == "" { + return 100.0 + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 100.0 + } + if v <= 1.0 { + v *= 100 + } + if v < 0 { + v = 0 + } + if v > 100 { + v = 100 + } + return v +} + +// scrapeAvalanche publishes per-validator gauges for the Avalanche +// primary network (P-Chain). Yield is computed from the documented base +// APR adjusted by per-validator uptime and delegation fee. +// +// stakeAmount / weight — we prefer stakeAmount but some node builds +// return weight instead; fall back if needed. +func scrapeAvalanche(ctx context.Context, client *http.Client) { + validators, err := fetchAvalancheValidators(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(avalancheChain, avalancheValidatorsTag).Inc() + fmt.Printf("[avalanche] getCurrentValidators err: %v\n", err) + return + } + + avaxPrice, err := fetchAvalanchePrice(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(avalancheChain, coingeckoAvaxSourceTag).Inc() + fmt.Printf("[avalanche] coingecko err (stake_usd will be skipped): %v\n", err) + avaxPrice = 0 + } + + // Sort by stake desc, cap top-N. + stakeOf := func(v avaxValidator) float64 { + if s := parseFloat(v.StakeAmount); s > 0 { + return s + } + return parseFloat(v.Weight) + } + sort.Slice(validators, func(i, j int) bool { + return stakeOf(validators[i]) > stakeOf(validators[j]) + }) + if len(validators) > avalancheTopN { + validators = validators[:avalancheTopN] + } + + const nAvaxPerAvax = 1e9 + grossBps := pctToBps(avalancheBaseAPR) + + netYields := make([]float64, 0, len(validators)) + for _, v := range validators { + id := v.NodeID + // Avalanche doesn't include a human name in the P-Chain + // response — the nodeID is the operator identity. + name := shortenName(id) + + uptimePct := parseAvaxUptime(v.Uptime) + delegationFeePct := parseFloat(v.DelegationFee) // 0..100 + + commissionBps := delegationFeePct * 100 + if commissionBps > 10000 { + commissionBps = 10000 + } + + netBps := grossBps * (uptimePct / 100) * (1 - delegationFeePct/100) + + stakeNAvax := stakeOf(v) + stakeAvax := stakeNAvax / nAvaxPerAvax + stakeUSD := stakeAvax * avaxPrice + + validatorNetYieldBps.WithLabelValues(avalancheChain, id, name).Set(netBps) + validatorGrossYieldBps.WithLabelValues(avalancheChain, id, name).Set(grossBps) + validatorMevShareBps.WithLabelValues(avalancheChain, id, name).Set(0) + validatorCommissionBps.WithLabelValues(avalancheChain, id, name).Set(commissionBps) + validatorUptimePct.WithLabelValues(avalancheChain, id, name).Set(uptimePct) + if avaxPrice > 0 { + validatorStakeUSD.WithLabelValues(avalancheChain, id, name).Set(stakeUSD) + } + validatorJailed.WithLabelValues(avalancheChain, id, name).Set(0) + + netYields = append(netYields, netBps) + } + + chainTotalValidators.WithLabelValues(avalancheChain).Set(float64(len(netYields))) + chainMedianNetYieldBps.WithLabelValues(avalancheChain).Set(medianBps(netYields)) + lastScrapeTimestamp.WithLabelValues(avalancheChain).Set(float64(time.Now().Unix())) + + fmt.Printf("[avalanche] published %d validators, median net yield %.0f bps, AVAX=$%.4f\n", + len(netYields), medianBps(netYields), avaxPrice) +} diff --git a/harnesses/validator-yield/cmd/script/cardano.go b/harnesses/validator-yield/cmd/script/cardano.go new file mode 100644 index 00000000..ec36aebb --- /dev/null +++ b/harnesses/validator-yield/cmd/script/cardano.go @@ -0,0 +1,345 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "time" +) + +const ( + cardanoChain = "cardano" + koiosPoolListTag = "koios_pool_list" + koiosPoolInfoTag = "koios_pool_info" + koiosEpochParamsTag = "koios_epoch_params" + coingeckoAdaSourceTag = "coingecko_ada" + + // Top-N cap: Cardano has ~3000 registered stake pools but the tail + // past position 50 holds a rounding-error share of total stake. Same + // rationale as the Solana top-200 cap — bounds Prometheus label + // cardinality without losing ranking information for this bench. + cardanoTopN = 50 +) + +// koiosPoolListEntry mirrors GET /pool_list. The bech32 pool ID is the +// stable identifier used as the label; ticker is nullable when the pool +// hasn't registered SMASH metadata. +type koiosPoolListEntry struct { + PoolIDBech32 string `json:"pool_id_bech32"` + PoolIDHex string `json:"pool_id_hex"` + Ticker *string `json:"ticker"` + MetaURL *string `json:"meta_url"` + PoolStatus string `json:"pool_status"` + ActiveStake *string `json:"active_stake"` // lovelace, stringified +} + +// koiosPoolInfo mirrors the POST /pool_info response. Only the fields +// we consume are declared. `margin` is Cardano's commission fraction +// [0,1]; `active_stake` and `live_pledge` are stringified lovelace +// (1 ADA = 1e6 lovelace). +type koiosPoolInfo struct { + PoolIDBech32 string `json:"pool_id_bech32"` + Ticker *string `json:"meta_json_ticker"` // some deployments nest under meta_json + Margin float64 `json:"margin"` + FixedCost string `json:"fixed_cost"` + Pledge string `json:"pledge"` + LivePledge string `json:"live_pledge"` + ActiveStake string `json:"active_stake"` + MetaJSON *struct { + Ticker string `json:"ticker"` + Name string `json:"name"` + } `json:"meta_json"` +} + +// koiosEpochParams — we only need monetary_expansion (rho) and +// treasury_growth_rate (tau) to approximate the network-wide gross APR +// pre-commission. +type koiosEpochParams struct { + EpochNo int64 `json:"epoch_no"` + MonetaryExpansion float64 `json:"monetary_expansion"` + TreasuryGrowthRate float64 `json:"treasury_growth_rate"` +} + +var ( + koiosBaseURL = envDefault("VAL_ECON_KOIOS_URL", "https://api.koios.rest/api/v1") + coingeckoCardanoURL = envDefault("VAL_ECON_COINGECKO_ADA_URL", "https://api.coingecko.com/api/v3/simple/price?ids=cardano&vs_currencies=usd") +) + +// Cardano's real yield formula is famously nuanced (pledge influence +// factor a0, saturation curve k, block-production luck). For v1 we +// approximate: +// +// gross_network_apr ≈ rho × (1 - tau) +// gross_pool_apr ≈ gross_network_apr (all pools share the pot pro-rata) +// net_pool_apr ≈ gross_pool_apr × (1 - margin) +// +// This ignores the a0/k/saturation effects; empirically it lands +// within ~50-100 bps of Cardano's ADAPools/pooltool.io figures for +// non-saturated pools and is honest enough for a network-comparison +// bench. If Koios's epoch_params fetch fails we fall back to +// cardanoFallbackAPR (a documented approximation of ~5%). +const cardanoFallbackAPR = 0.05 + +func fetchKoiosPoolList(ctx context.Context, client *http.Client) ([]koiosPoolListEntry, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, koiosBaseURL+"/pool_list?pool_status=eq.registered", nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("koios pool_list http %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var out []koiosPoolListEntry + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("koios pool_list parse: %w", err) + } + return out, nil +} + +func fetchKoiosPoolInfo(ctx context.Context, client *http.Client, ids []string) ([]koiosPoolInfo, error) { + body, err := json.Marshal(map[string]any{"_pool_bech32_ids": ids}) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, koiosBaseURL+"/pool_info", bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("koios pool_info http %d", resp.StatusCode) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var out []koiosPoolInfo + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("koios pool_info parse: %w", err) + } + return out, nil +} + +func fetchKoiosEpochParams(ctx context.Context, client *http.Client) (koiosEpochParams, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, koiosBaseURL+"/epoch_params?limit=1", nil) + if err != nil { + return koiosEpochParams{}, err + } + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return koiosEpochParams{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return koiosEpochParams{}, fmt.Errorf("koios epoch_params http %d", resp.StatusCode) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return koiosEpochParams{}, err + } + var out []koiosEpochParams + if err := json.Unmarshal(raw, &out); err != nil { + return koiosEpochParams{}, fmt.Errorf("koios epoch_params parse: %w", err) + } + if len(out) == 0 { + return koiosEpochParams{}, fmt.Errorf("koios epoch_params: empty response") + } + return out[0], nil +} + +func fetchCardanoPrice(ctx context.Context, client *http.Client) (float64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, coingeckoCardanoURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0") + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("coingecko ada http %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + var payload map[string]map[string]float64 + if err := json.Unmarshal(body, &payload); err != nil { + return 0, fmt.Errorf("coingecko ada parse: %w", err) + } + v, ok := payload["cardano"] + if !ok { + return 0, fmt.Errorf("coingecko missing cardano key") + } + price := v["usd"] + if price <= 0 { + return 0, fmt.Errorf("coingecko invalid ada price %f", price) + } + return price, nil +} + +// scrapeCardano fetches the top-N stake pools from Koios, applies a +// simple rho×(1-tau)/pool_margin approximation, and publishes the +// standard OCB validator gauges under chain="cardano". +// +// Uptime is set to 100% for every pool: Cardano stake pools don't have +// a canonical uptime metric in the same sense as Solana/Hyperliquid +// (block-production luck varies by chance, not by operator downtime). +func scrapeCardano(ctx context.Context, client *http.Client) { + pools, err := fetchKoiosPoolList(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cardanoChain, koiosPoolListTag).Inc() + fmt.Printf("[cardano] koios pool_list err: %v\n", err) + return + } + + // Sort descending by active_stake (parsed as lovelace) and cap. + sort.Slice(pools, func(i, j int) bool { + var a, b float64 + if pools[i].ActiveStake != nil { + a = parseFloat(*pools[i].ActiveStake) + } + if pools[j].ActiveStake != nil { + b = parseFloat(*pools[j].ActiveStake) + } + return a > b + }) + if len(pools) > cardanoTopN { + pools = pools[:cardanoTopN] + } + + // Batch pool_info in a single POST — Koios accepts up to ~200 IDs. + ids := make([]string, 0, len(pools)) + for _, p := range pools { + ids = append(ids, p.PoolIDBech32) + } + infos, err := fetchKoiosPoolInfo(ctx, client, ids) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cardanoChain, koiosPoolInfoTag).Inc() + fmt.Printf("[cardano] koios pool_info err: %v\n", err) + return + } + infoIdx := make(map[string]koiosPoolInfo, len(infos)) + for _, info := range infos { + infoIdx[info.PoolIDBech32] = info + } + + // Network-wide gross APR from monetary params — fall back to a + // documented static approximation if Koios flakes here. + networkAPR := cardanoFallbackAPR + params, err := fetchKoiosEpochParams(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cardanoChain, koiosEpochParamsTag).Inc() + fmt.Printf("[cardano] koios epoch_params err (using %.1f%% fallback): %v\n", cardanoFallbackAPR*100, err) + } else if params.MonetaryExpansion > 0 { + // rho × (1 - tau). Cardano distributes rho of the reserve per + // epoch; tau goes to treasury before pools split the rest. + // Annualisation: rho is already per-epoch × 73 epochs/year ≈ + // per-year in Koios semantics for this field. + networkAPR = params.MonetaryExpansion * (1 - params.TreasuryGrowthRate) + } + + adaPrice, err := fetchCardanoPrice(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cardanoChain, coingeckoAdaSourceTag).Inc() + fmt.Printf("[cardano] coingecko err (stake_usd will be skipped): %v\n", err) + adaPrice = 0 + } + + const lovelacePerAda = 1e6 + + netYields := make([]float64, 0, len(pools)) + for _, p := range pools { + info, ok := infoIdx[p.PoolIDBech32] + if !ok { + continue + } + name := shortenName(cardanoPoolLabel(p, info)) + id := p.PoolIDBech32 + + grossBps := pctToBps(networkAPR) + commissionBps := info.Margin * 10000 + if commissionBps > 10000 { + commissionBps = 10000 + } + netAPR := networkAPR * (1 - info.Margin) + netBps := pctToBps(netAPR) + + // Uptime: no canonical Cardano equivalent — assume 100%. + uptimePct := 100.0 + + activeStakeLovelace := parseFloat(info.ActiveStake) + if activeStakeLovelace == 0 && p.ActiveStake != nil { + activeStakeLovelace = parseFloat(*p.ActiveStake) + } + stakeADA := activeStakeLovelace / lovelacePerAda + stakeUSD := stakeADA * adaPrice + + validatorNetYieldBps.WithLabelValues(cardanoChain, id, name).Set(netBps) + validatorGrossYieldBps.WithLabelValues(cardanoChain, id, name).Set(grossBps) + validatorMevShareBps.WithLabelValues(cardanoChain, id, name).Set(0) + validatorCommissionBps.WithLabelValues(cardanoChain, id, name).Set(commissionBps) + validatorUptimePct.WithLabelValues(cardanoChain, id, name).Set(uptimePct) + if adaPrice > 0 { + validatorStakeUSD.WithLabelValues(cardanoChain, id, name).Set(stakeUSD) + } + validatorJailed.WithLabelValues(cardanoChain, id, name).Set(0) + + netYields = append(netYields, netBps) + } + + chainTotalValidators.WithLabelValues(cardanoChain).Set(float64(len(netYields))) + chainMedianNetYieldBps.WithLabelValues(cardanoChain).Set(medianBps(netYields)) + lastScrapeTimestamp.WithLabelValues(cardanoChain).Set(float64(time.Now().Unix())) + + fmt.Printf("[cardano] published %d pools, median net yield %.0f bps, ADA=$%.4f\n", + len(netYields), medianBps(netYields), adaPrice) +} + +// cardanoPoolLabel picks the best human name we can find: meta_json +// ticker/name if present, else the list-endpoint ticker, else a +// truncated bech32. Never returns empty (shortenName upgrades that +// to "(unknown)"). +func cardanoPoolLabel(p koiosPoolListEntry, info koiosPoolInfo) string { + if info.MetaJSON != nil { + if info.MetaJSON.Ticker != "" { + return info.MetaJSON.Ticker + } + if info.MetaJSON.Name != "" { + return info.MetaJSON.Name + } + } + if info.Ticker != nil && *info.Ticker != "" { + return *info.Ticker + } + if p.Ticker != nil && *p.Ticker != "" { + return *p.Ticker + } + if len(p.PoolIDBech32) > 16 { + return p.PoolIDBech32[:16] + } + return p.PoolIDBech32 +} diff --git a/harnesses/validator-yield/cmd/script/cosmos.go b/harnesses/validator-yield/cmd/script/cosmos.go new file mode 100644 index 00000000..b7099333 --- /dev/null +++ b/harnesses/validator-yield/cmd/script/cosmos.go @@ -0,0 +1,270 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "time" +) + +const ( + cosmosChain = "cosmos-hub" + cosmosInflationTag = "cosmos_lcd_inflation" + cosmosPoolTag = "cosmos_lcd_pool" + cosmosValidatorsTag = "cosmos_lcd_validators" + coingeckoAtomSourceTag = "coingecko_atom" + + // Cosmos Hub community_tax has been ~2% for years; hard-coding + // avoids a third LCD round-trip per scrape. If governance changes + // it materially, update here (grep this constant). + cosmosCommunityTax = 0.02 + + // Cap: Cosmos Hub has ~180 active validators (top 180 in bond + // order). We publish all of them — the tail is small enough to + // not blow up cardinality. + cosmosTopN = 200 +) + +var ( + cosmosLCDBase = envDefault("VAL_ECON_COSMOS_LCD_URL", "https://cosmos-rest.publicnode.com") + coingeckoAtomURL = envDefault("VAL_ECON_COINGECKO_ATOM_URL", "https://api.coingecko.com/api/v3/simple/price?ids=cosmos&vs_currencies=usd") +) + +// cosmosInflationResponse mirrors /cosmos/mint/v1beta1/inflation. +// `inflation` is a decimal string in [0,1] (0.1 = 10%). +type cosmosInflationResponse struct { + Inflation string `json:"inflation"` +} + +// cosmosPoolResponse mirrors /cosmos/staking/v1beta1/pool. Token amounts +// are stringified integer uatom (1 ATOM = 1e6 uatom). +type cosmosPoolResponse struct { + Pool struct { + BondedTokens string `json:"bonded_tokens"` + NotBondedTokens string `json:"not_bonded_tokens"` + } `json:"pool"` +} + +// cosmosValidator mirrors items in /cosmos/staking/v1beta1/validators. +type cosmosValidator struct { + OperatorAddress string `json:"operator_address"` + Status string `json:"status"` + Tokens string `json:"tokens"` + Description struct { + Moniker string `json:"moniker"` + Identity string `json:"identity"` + } `json:"description"` + Commission struct { + CommissionRates struct { + Rate string `json:"rate"` + MaxRate string `json:"max_rate"` + MaxChangeRate string `json:"max_change_rate"` + } `json:"commission_rates"` + } `json:"commission"` + Jailed bool `json:"jailed"` +} + +type cosmosValidatorsResponse struct { + Validators []cosmosValidator `json:"validators"` + Pagination struct { + NextKey *string `json:"next_key"` + Total string `json:"total"` + } `json:"pagination"` +} + +func lcdGet(ctx context.Context, client *http.Client, url string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("lcd http %d: %s", resp.StatusCode, url) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(body, out) +} + +func fetchCosmosInflation(ctx context.Context, client *http.Client) (float64, error) { + var out cosmosInflationResponse + if err := lcdGet(ctx, client, cosmosLCDBase+"/cosmos/mint/v1beta1/inflation", &out); err != nil { + return 0, err + } + v := parseFloat(out.Inflation) + if v <= 0 || v > 1 { + return 0, fmt.Errorf("cosmos inflation implausible: %s", out.Inflation) + } + return v, nil +} + +func fetchCosmosPool(ctx context.Context, client *http.Client) (bonded, notBonded float64, err error) { + var out cosmosPoolResponse + if err = lcdGet(ctx, client, cosmosLCDBase+"/cosmos/staking/v1beta1/pool", &out); err != nil { + return 0, 0, err + } + bonded = parseFloat(out.Pool.BondedTokens) + notBonded = parseFloat(out.Pool.NotBondedTokens) + if bonded <= 0 { + return 0, 0, fmt.Errorf("cosmos pool: bonded_tokens=0") + } + return bonded, notBonded, nil +} + +func fetchCosmosValidators(ctx context.Context, client *http.Client) ([]cosmosValidator, error) { + url := cosmosLCDBase + "/cosmos/staking/v1beta1/validators?status=BOND_STATUS_BONDED&pagination.limit=250" + var out cosmosValidatorsResponse + if err := lcdGet(ctx, client, url, &out); err != nil { + return nil, err + } + return out.Validators, nil +} + +func fetchCosmosPrice(ctx context.Context, client *http.Client) (float64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, coingeckoAtomURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0") + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("coingecko atom http %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + var payload map[string]map[string]float64 + if err := json.Unmarshal(body, &payload); err != nil { + return 0, fmt.Errorf("coingecko atom parse: %w", err) + } + v, ok := payload["cosmos"] + if !ok { + return 0, fmt.Errorf("coingecko missing cosmos key") + } + price := v["usd"] + if price <= 0 { + return 0, fmt.Errorf("coingecko invalid atom price %f", price) + } + return price, nil +} + +// scrapeCosmos computes Cosmos Hub validator yield from LCD REST: +// +// network_apr = inflation × (1 - community_tax) / bonded_ratio +// net_apr = network_apr × (1 - commission_rate) +// +// Uptime is set to 100% — Cosmos Hub does surface missed_blocks via +// /cosmos/slashing/v1beta1/signing_infos but we skip that in v1 to +// keep the scrape to a single validators-pagination call. +func scrapeCosmos(ctx context.Context, client *http.Client) { + inflation, err := fetchCosmosInflation(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cosmosChain, cosmosInflationTag).Inc() + fmt.Printf("[cosmos-hub] inflation err: %v\n", err) + return + } + + bonded, notBonded, err := fetchCosmosPool(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cosmosChain, cosmosPoolTag).Inc() + fmt.Printf("[cosmos-hub] pool err: %v\n", err) + return + } + bondedRatio := bonded / (bonded + notBonded) + if bondedRatio <= 0 || bondedRatio > 1 { + fmt.Printf("[cosmos-hub] implausible bonded_ratio %.4f, aborting\n", bondedRatio) + return + } + + validators, err := fetchCosmosValidators(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cosmosChain, cosmosValidatorsTag).Inc() + fmt.Printf("[cosmos-hub] validators err: %v\n", err) + return + } + + atomPrice, err := fetchCosmosPrice(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cosmosChain, coingeckoAtomSourceTag).Inc() + fmt.Printf("[cosmos-hub] coingecko err (stake_usd will be skipped): %v\n", err) + atomPrice = 0 + } + + // Sort by tokens desc, cap top-N. + sort.Slice(validators, func(i, j int) bool { + return parseFloat(validators[i].Tokens) > parseFloat(validators[j].Tokens) + }) + if len(validators) > cosmosTopN { + validators = validators[:cosmosTopN] + } + + networkAPR := inflation * (1 - cosmosCommunityTax) / bondedRatio + grossBps := pctToBps(networkAPR) + + const uatomPerAtom = 1e6 + + netYields := make([]float64, 0, len(validators)) + for _, v := range validators { + name := shortenName(v.Description.Moniker) + id := v.OperatorAddress + + commissionRate := parseFloat(v.Commission.CommissionRates.Rate) // 0..1 + if commissionRate < 0 { + commissionRate = 0 + } + if commissionRate > 1 { + commissionRate = 1 + } + commissionBps := commissionRate * 10000 + + uptimePct := 100.0 + netBps := grossBps * (1 - commissionRate) * (uptimePct / 100) + if v.Jailed { + netBps = 0 + } + + stakeAtom := parseFloat(v.Tokens) / uatomPerAtom + stakeUSD := stakeAtom * atomPrice + + jailed := 0.0 + if v.Jailed { + jailed = 1 + } + + validatorNetYieldBps.WithLabelValues(cosmosChain, id, name).Set(netBps) + validatorGrossYieldBps.WithLabelValues(cosmosChain, id, name).Set(grossBps) + validatorMevShareBps.WithLabelValues(cosmosChain, id, name).Set(0) + validatorCommissionBps.WithLabelValues(cosmosChain, id, name).Set(commissionBps) + validatorUptimePct.WithLabelValues(cosmosChain, id, name).Set(uptimePct) + if atomPrice > 0 { + validatorStakeUSD.WithLabelValues(cosmosChain, id, name).Set(stakeUSD) + } + validatorJailed.WithLabelValues(cosmosChain, id, name).Set(jailed) + + netYields = append(netYields, netBps) + } + + chainTotalValidators.WithLabelValues(cosmosChain).Set(float64(len(netYields))) + chainMedianNetYieldBps.WithLabelValues(cosmosChain).Set(medianBps(netYields)) + lastScrapeTimestamp.WithLabelValues(cosmosChain).Set(float64(time.Now().Unix())) + + fmt.Printf("[cosmos-hub] published %d validators, median net yield %.0f bps, inflation=%.2f%%, bonded=%.1f%%, ATOM=$%.4f\n", + len(netYields), medianBps(netYields), inflation*100, bondedRatio*100, atomPrice) +} diff --git a/harnesses/validator-yield/cmd/script/main.go b/harnesses/validator-yield/cmd/script/main.go index 8f25c6c5..c056e1bf 100644 --- a/harnesses/validator-yield/cmd/script/main.go +++ b/harnesses/validator-yield/cmd/script/main.go @@ -19,7 +19,7 @@ func main() { installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== Validator Economics Harness ===") fmt.Println("OpenChainBench bench #026 — net yield = gross APR + MEV − downtime") - fmt.Println("Scope: Solana + Hyperliquid + Ethereum (consensus-layer, network avg)") + fmt.Println("Scope: Solana + Hyperliquid + Ethereum + Cardano + Sui + Cosmos Hub + Avalanche") fmt.Println() go func() { @@ -41,6 +41,10 @@ func main() { go runChainScraper(ctx, client, "solana", scrapeSolana) go runChainScraper(ctx, client, "hyperliquid", scrapeHyperliquid) go runChainScraper(ctx, client, "ethereum", scrapeEthereum) + go runChainScraper(ctx, client, "cardano", scrapeCardano) + go runChainScraper(ctx, client, "sui", scrapeSui) + go runChainScraper(ctx, client, "cosmos-hub", scrapeCosmos) + go runChainScraper(ctx, client, "avalanche", scrapeAvalanche) sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) diff --git a/harnesses/validator-yield/cmd/script/sui.go b/harnesses/validator-yield/cmd/script/sui.go new file mode 100644 index 00000000..6158e860 --- /dev/null +++ b/harnesses/validator-yield/cmd/script/sui.go @@ -0,0 +1,249 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +const ( + suiChain = "sui" + suiApysSourceTag = "sui_getValidatorsApy" + suiSystemSourceTag = "sui_getLatestSystemState" + coingeckoSuiSourceTag = "coingecko_sui" +) + +// Sui exposes a single JSON-RPC endpoint per fullnode. The public +// mainnet endpoint has no key requirement and no strict rate limit for +// low-QPS pollers. +var ( + suiRPCURL = envDefault("VAL_ECON_SUI_RPC_URL", "https://fullnode.mainnet.sui.io:443") + coingeckoSuiURL = envDefault("VAL_ECON_COINGECKO_SUI_URL", "https://api.coingecko.com/api/v3/simple/price?ids=sui&vs_currencies=usd") +) + +// suiRPCEnvelope is the standard JSON-RPC 2.0 wrapper. +type suiRPCEnvelope struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// suiValidatorApy is one entry in `suix_getValidatorsApy.result.apys`. +// `apy` is a decimal fraction (0.0234 = 2.34%) already net of the +// validator's commission per Sui docs. +type suiValidatorApy struct { + Address string `json:"address"` + APY float64 `json:"apy"` + Epoch int64 `json:"epoch"` +} + +type suiValidatorsApyResponse struct { + Epoch string `json:"epoch"` + APYs []suiValidatorApy `json:"apys"` +} + +// suiActiveValidator mirrors the fields we care about from the +// `activeValidators` array in `suix_getLatestSuiSystemState`. All +// numeric-like fields come back as strings. +type suiActiveValidator struct { + SuiAddress string `json:"suiAddress"` + Name string `json:"name"` + Description string `json:"description"` + CommissionRate string `json:"commissionRate"` // bps, e.g. "200" = 2% + StakingPoolSuiBal string `json:"stakingPoolSuiBalance"` // MIST (1 SUI = 1e9 MIST) + NextEpochStake string `json:"nextEpochStake"` +} + +type suiSystemStateResponse struct { + Epoch string `json:"epoch"` + ActiveValidators []suiActiveValidator `json:"activeValidators"` +} + +func suiRPC(ctx context.Context, client *http.Client, method string, out any) error { + payload := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": []any{}, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, suiRPCURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + return fmt.Errorf("sui rpc %s http %d: %s", method, resp.StatusCode, string(raw)) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + var env suiRPCEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return fmt.Errorf("sui rpc %s envelope parse: %w", method, err) + } + if env.Error != nil { + return fmt.Errorf("sui rpc %s error %d: %s", method, env.Error.Code, env.Error.Message) + } + if err := json.Unmarshal(env.Result, out); err != nil { + return fmt.Errorf("sui rpc %s result parse: %w", method, err) + } + return nil +} + +func fetchSuiValidatorsAPY(ctx context.Context, client *http.Client) (suiValidatorsApyResponse, error) { + var out suiValidatorsApyResponse + err := suiRPC(ctx, client, "suix_getValidatorsApy", &out) + return out, err +} + +func fetchSuiSystemState(ctx context.Context, client *http.Client) (suiSystemStateResponse, error) { + var out suiSystemStateResponse + err := suiRPC(ctx, client, "suix_getLatestSuiSystemState", &out) + return out, err +} + +func fetchSuiPrice(ctx context.Context, client *http.Client) (float64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, coingeckoSuiURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0") + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("coingecko sui http %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + var payload map[string]map[string]float64 + if err := json.Unmarshal(body, &payload); err != nil { + return 0, fmt.Errorf("coingecko sui parse: %w", err) + } + v, ok := payload["sui"] + if !ok { + return 0, fmt.Errorf("coingecko missing sui key") + } + price := v["usd"] + if price <= 0 { + return 0, fmt.Errorf("coingecko invalid sui price %f", price) + } + return price, nil +} + +// scrapeSui publishes per-validator yield gauges for Sui. The apy field +// from `suix_getValidatorsApy` is already net of commission per Sui +// docs, so we use it as both gross (before uptime adjustment) and net +// (after uptime, which we can't measure per-validator without the +// per-epoch checkpoint history — we assume 100% for v1). +// +// MEV: Sui rewards are pure staking rewards (protocol emission), no +// MEV component surfaces to validators today. +func scrapeSui(ctx context.Context, client *http.Client) { + apy, err := fetchSuiValidatorsAPY(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(suiChain, suiApysSourceTag).Inc() + fmt.Printf("[sui] getValidatorsApy err: %v\n", err) + return + } + + state, err := fetchSuiSystemState(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(suiChain, suiSystemSourceTag).Inc() + fmt.Printf("[sui] getLatestSuiSystemState err (metadata only, continuing): %v\n", err) + } + + // Index active validators by address for enrichment. + valMeta := make(map[string]suiActiveValidator, len(state.ActiveValidators)) + for _, v := range state.ActiveValidators { + valMeta[v.SuiAddress] = v + } + + suiPrice, err := fetchSuiPrice(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(suiChain, coingeckoSuiSourceTag).Inc() + fmt.Printf("[sui] coingecko err (stake_usd will be skipped): %v\n", err) + suiPrice = 0 + } + + const mistPerSui = 1e9 + + netYields := make([]float64, 0, len(apy.APYs)) + for _, entry := range apy.APYs { + meta := valMeta[entry.Address] + name := shortenName(meta.Name) + if name == "(unknown)" { + // Fall back to a truncated address so operators are + // distinguishable in Grafana even when metadata is missing. + if len(entry.Address) > 16 { + name = shortenName(entry.Address[:16]) + } else { + name = shortenName(entry.Address) + } + } + id := entry.Address + + grossBps := pctToBps(entry.APY) + // Uptime — no direct field; assume 100%. + uptimePct := 100.0 + netBps := grossBps * (uptimePct / 100) + + // Commission comes back as basis points already (e.g. "200" = 2%). + commissionBps, _ := strconv.ParseFloat(meta.CommissionRate, 64) + if commissionBps < 0 { + commissionBps = 0 + } + if commissionBps > 10000 { + commissionBps = 10000 + } + + stakeMist, _ := strconv.ParseFloat(meta.StakingPoolSuiBal, 64) + stakeSUI := stakeMist / mistPerSui + stakeUSD := stakeSUI * suiPrice + + validatorNetYieldBps.WithLabelValues(suiChain, id, name).Set(netBps) + validatorGrossYieldBps.WithLabelValues(suiChain, id, name).Set(grossBps) + validatorMevShareBps.WithLabelValues(suiChain, id, name).Set(0) + validatorCommissionBps.WithLabelValues(suiChain, id, name).Set(commissionBps) + validatorUptimePct.WithLabelValues(suiChain, id, name).Set(uptimePct) + if suiPrice > 0 { + validatorStakeUSD.WithLabelValues(suiChain, id, name).Set(stakeUSD) + } + validatorJailed.WithLabelValues(suiChain, id, name).Set(0) + + netYields = append(netYields, netBps) + } + + chainTotalValidators.WithLabelValues(suiChain).Set(float64(len(netYields))) + chainMedianNetYieldBps.WithLabelValues(suiChain).Set(medianBps(netYields)) + lastScrapeTimestamp.WithLabelValues(suiChain).Set(float64(time.Now().Unix())) + + fmt.Printf("[sui] published %d validators, median net yield %.0f bps, SUI=$%.4f\n", + len(netYields), medianBps(netYields), suiPrice) +} diff --git a/harnesses/ws-head-latency/Dockerfile b/harnesses/ws-head-latency/Dockerfile new file mode 100644 index 00000000..63108cfc --- /dev/null +++ b/harnesses/ws-head-latency/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/ws-head-latency/cmd/script/aggregator.go b/harnesses/ws-head-latency/cmd/script/aggregator.go new file mode 100644 index 00000000..87e384bc --- /dev/null +++ b/harnesses/ws-head-latency/cmd/script/aggregator.go @@ -0,0 +1,190 @@ +package main + +import ( + "fmt" + "sort" + "sync" + "time" +) + +// Per-chain arrival cohort. +// +// Every (provider, chain) connection reports "provider P saw block N at +// time T" into the chain's aggregator. The first report for a block +// opens a collection window (settleWindow); when it expires the cohort +// is scored: +// +// T0 = earliest arrival among providers +// lag(provider) = arrival(provider) - T0 → histogram sample +// winner = provider at T0 → wins counter +// gap = provider healthy on this chain but absent from the +// cohort → gap counter +// +// The measurement is deliberately RELATIVE: one vantage point cannot +// separate its own network path from provider pipeline latency, but the +// per-block delta between providers over the same path is a fair +// ordering. The fastest provider reads ~0 by construction; the spec's +// editorial copy discloses this. + +const ( + // settleWindow is how long a block cohort stays open after the first + // arrival. 5s covers everything we've observed (worst straggler + // ~1.5s on ethereum); providers later than this are scored as a gap, + // which is the honest reading for a push feed. + settleWindow = 5 * time.Second + // staleAfter bounds "healthy": a provider absent from a cohort only + // counts as a gap when it delivered *something* on this chain within + // staleAfter (otherwise it's just disconnected, which ws_health and + // ws_reconnects_total already report). + staleAfter = 120 * time.Second +) + +type cohortEntry struct { + arrivals map[string]time.Time +} + +type aggregator struct { + chain string + + mu sync.Mutex + blocks map[int64]*cohortEntry + maxFinalized int64 + // lastMsg tracks per-provider liveness on this chain (any accepted + // head/slot). Used for gap attribution and the ws_health sweeper. + lastMsg map[string]time.Time +} + +var ( + aggregatorsMu sync.Mutex + aggregators = map[string]*aggregator{} +) + +func aggregatorFor(chain string) *aggregator { + aggregatorsMu.Lock() + defer aggregatorsMu.Unlock() + if a, ok := aggregators[chain]; ok { + return a + } + a := &aggregator{ + chain: chain, + blocks: map[int64]*cohortEntry{}, + lastMsg: map[string]time.Time{}, + } + aggregators[chain] = a + return a +} + +// record ingests one arrival. Returns false when the arrival was +// dropped (duplicate from the same provider, or a straggler for an +// already-scored block). +func (a *aggregator) record(provider string, height int64, now time.Time) bool { + a.mu.Lock() + defer a.mu.Unlock() + + a.lastMsg[provider] = now + + // Straggler for a cohort that already settled. From a push-feed + // consumer's perspective this block was missed (the gap counter was + // bumped at settle time); opening a fresh single-provider cohort + // here would fabricate a lag=0 "win", so drop it. + if height <= a.maxFinalized { + return false + } + + e, ok := a.blocks[height] + if !ok { + e = &cohortEntry{arrivals: map[string]time.Time{}} + a.blocks[height] = e + time.AfterFunc(settleWindow, func() { a.settle(height) }) + } + if _, dup := e.arrivals[provider]; dup { + return false + } + e.arrivals[provider] = now + headBlocksSeen.WithLabelValues(provider, a.chain).Inc() + return true +} + +// settle scores one block cohort and releases it. +func (a *aggregator) settle(height int64) { + a.mu.Lock() + e, ok := a.blocks[height] + if !ok { + a.mu.Unlock() + return + } + delete(a.blocks, height) + if height > a.maxFinalized { + a.maxFinalized = height + } + // Snapshot healthy providers for gap attribution while still locked. + settleTime := time.Now() + healthy := make([]string, 0, len(a.lastMsg)) + for p, t := range a.lastMsg { + if settleTime.Sub(t) <= staleAfter { + healthy = append(healthy, p) + } + } + a.mu.Unlock() + + // A single-provider cohort carries no relative information: lag + // would be 0 by definition and the "win" unearned. Count gaps for + // the healthy absentees (they truly missed a block others pushed) + // but emit no lag samples. + if len(e.arrivals) >= 2 { + providers := make([]string, 0, len(e.arrivals)) + for p := range e.arrivals { + providers = append(providers, p) + } + // Deterministic tie-break: earliest arrival wins; equal + // timestamps resolve alphabetically. + sort.Strings(providers) + winner := providers[0] + t0 := e.arrivals[winner] + for _, p := range providers[1:] { + if e.arrivals[p].Before(t0) { + winner, t0 = p, e.arrivals[p] + } + } + for _, p := range providers { + lagMs := float64(e.arrivals[p].Sub(t0).Milliseconds()) + headLagHist.WithLabelValues(p, a.chain).Observe(lagMs) + } + headWins.WithLabelValues(winner, a.chain).Inc() + fmt.Printf("[%s] block=%d cohort=%d winner=%s\n", a.chain, height, len(e.arrivals), winner) + } + + for _, p := range healthy { + if _, saw := e.arrivals[p]; !saw { + headBlockGap.WithLabelValues(p, a.chain).Inc() + } + } +} + +// startHealthSweeper flips ws_health to 0 for any (provider, chain) +// that stopped delivering messages, per the gauge's contract (1 = +// connected AND received a message in the last 120s). The connection +// goroutines set it back to 1 on every accepted message. +func startHealthSweeper() { + go func() { + t := time.NewTicker(15 * time.Second) + defer t.Stop() + for now := range t.C { + aggregatorsMu.Lock() + aggs := make([]*aggregator, 0, len(aggregators)) + for _, a := range aggregators { + aggs = append(aggs, a) + } + aggregatorsMu.Unlock() + for _, a := range aggs { + a.mu.Lock() + for p, last := range a.lastMsg { + if now.Sub(last) > staleAfter { + wsHealth.WithLabelValues(p, a.chain).Set(0) + } + } + a.mu.Unlock() + } + } + }() +} diff --git a/harnesses/ws-head-latency/cmd/script/config.go b/harnesses/ws-head-latency/cmd/script/config.go new file mode 100644 index 00000000..915deef0 --- /dev/null +++ b/harnesses/ws-head-latency/cmd/script/config.go @@ -0,0 +1,87 @@ +package main + +import ( + "os" + "strings" +) + +// Endpoint is one (provider, chain) WebSocket subscription. Each entry +// gets its own goroutine holding a persistent connection; the arrival +// timestamps from every endpoint on the same chain are pooled by the +// per-chain cohort (see cohort.go logic in evm_ws.go / solana_ws.go and +// the shared aggregator below). +type Endpoint struct { + Provider string + Chain string // ethereum | base | solana + Kind string // "evm" (eth_subscribe newHeads) | "solana" (slotSubscribe) + URL string +} + +// endpoints is the source of truth for the measured cohort. All default +// URLs are public and keyless, verified live at harness inception: +// +// publicnode: ethereum / base / solana all push events +// drpc: ethereum verified; base + solana declared and allowed to +// fail at runtime (per-connection reconnect loop degrades +// gracefully, never crashes the process) +// tenderly: ethereum only (no keyless base/solana WS) +// +// Optional keyed providers are appended from env vars so contributor +// keys can widen the cohort without a rebuild. Every default URL is +// also env-overridable via WS_URL__. +func endpoints() []Endpoint { + eps := []Endpoint{ + {Provider: "publicnode", Chain: "ethereum", Kind: "evm", URL: envDefault("WS_URL_PUBLICNODE_ETHEREUM", "wss://ethereum-rpc.publicnode.com")}, + {Provider: "publicnode", Chain: "base", Kind: "evm", URL: envDefault("WS_URL_PUBLICNODE_BASE", "wss://base-rpc.publicnode.com")}, + {Provider: "publicnode", Chain: "solana", Kind: "solana", URL: envDefault("WS_URL_PUBLICNODE_SOLANA", "wss://solana-rpc.publicnode.com")}, + {Provider: "drpc", Chain: "ethereum", Kind: "evm", URL: envDefault("WS_URL_DRPC_ETHEREUM", "wss://eth.drpc.org")}, + {Provider: "drpc", Chain: "base", Kind: "evm", URL: envDefault("WS_URL_DRPC_BASE", "wss://base.drpc.org")}, + {Provider: "drpc", Chain: "solana", Kind: "solana", URL: envDefault("WS_URL_DRPC_SOLANA", "wss://solana.drpc.org")}, + {Provider: "tenderly", Chain: "ethereum", Kind: "evm", URL: envDefault("WS_URL_TENDERLY_ETHEREUM", "wss://mainnet.gateway.tenderly.co")}, + {Provider: "onfinality", Chain: "ethereum", Kind: "evm", URL: envDefault("WS_URL_ONFINALITY_ETHEREUM", "wss://eth.api.onfinality.io/public-ws")}, + {Provider: "solana", Chain: "solana", Kind: "solana", URL: envDefault("WS_URL_SOLANALABS_SOLANA", "wss://api.mainnet-beta.solana.com")}, + } + // Keyed providers, skipped when the env var is unset. URLs carry the + // key inline (e.g. wss://eth-mainnet.g.alchemy.com/v2/) so they + // live in the deployment env, never in the repo. The public keyless + // cohort is already saturated on every chain (Ankr, Blast, LlamaRPC, + // BlockPI, 1RPC, NodeReal, Omniatech all failed a live WSS handshake + // as of 2026-07 audit), so any new cohort member comes in via one of + // the slots below. + for _, opt := range []struct{ env, provider, chain, kind string }{ + // Ethereum keyed + {"WS_URL_ALCHEMY_ETHEREUM", "alchemy", "ethereum", "evm"}, + {"WS_URL_INFURA_ETHEREUM", "infura", "ethereum", "evm"}, + {"WS_URL_CHAINSTACK_ETHEREUM", "chainstack", "ethereum", "evm"}, + {"WS_URL_QUICKNODE_ETHEREUM", "quicknode", "ethereum", "evm"}, + // Base keyed + {"WS_URL_ALCHEMY_BASE", "alchemy", "base", "evm"}, + {"WS_URL_INFURA_BASE", "infura", "base", "evm"}, + {"WS_URL_CHAINSTACK_BASE", "chainstack", "base", "evm"}, + {"WS_URL_QUICKNODE_BASE", "quicknode", "base", "evm"}, + // Solana keyed + {"WS_URL_ALCHEMY_SOLANA", "alchemy", "solana", "solana"}, + {"WS_URL_CHAINSTACK_SOLANA", "chainstack", "solana", "solana"}, + {"WS_URL_HELIUS_SOLANA", "helius", "solana", "solana"}, + {"WS_URL_QUICKNODE_SOLANA", "quicknode", "solana", "solana"}, + } { + if url := strings.TrimSpace(os.Getenv(opt.env)); url != "" { + eps = append(eps, Endpoint{Provider: opt.provider, Chain: opt.chain, Kind: opt.kind, URL: url}) + } + } + return eps +} + +func envDefault(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +// listenAddr defaults to :2112, the OCB harness convention (scraped by +// the shared Prometheus). LISTEN_ADDR overrides for local runs so a dev +// copy doesn't collide with a deployed harness. +func listenAddr() string { + return envDefault("LISTEN_ADDR", ":2112") +} diff --git a/harnesses/ws-head-latency/cmd/script/evm_ws.go b/harnesses/ws-head-latency/cmd/script/evm_ws.go new file mode 100644 index 00000000..b69fa750 --- /dev/null +++ b/harnesses/ws-head-latency/cmd/script/evm_ws.go @@ -0,0 +1,212 @@ +package main + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +// Persistent WebSocket machinery, ported from the l2-block-time +// harness. One goroutine per (provider, chain) endpoint: +// +// - dial with timeout, subscribe, read loop with rolling deadline +// - client ping every 30s; pong handler extends the read deadline +// - head watchdog: the pong handler keeps the read deadline alive, so +// a provider that heartbeats while silently dropping the +// subscription (observed on publicnode) would wedge the goroutine +// forever. If no head/slot arrives for headWatchdog, slam the +// connection closed and let the outer loop redial. +// - reconnect with exponential backoff 2s → 60s; per-connection, so a +// dead endpoint (e.g. drpc/base if delisted) degrades to health=0 + +// periodic redials without touching the rest of the cohort. + +const ( + dialTimeout = 15 * time.Second + readDeadline = 60 * time.Second + pingEvery = 30 * time.Second + minBackoff = 2 * time.Second + maxBackoff = 60 * time.Second + // headWatchdog must sit well above the slowest cadence in the panel + // (ethereum, 12s slots; solana pushes multiple times per second). + // 90s of silence is unambiguously a broken subscription. + headWatchdog = 90 * time.Second +) + +type rpcReq struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params []any `json:"params"` +} + +// evmEnvelope matches both the eth_subscription push frame and the +// subscribe ack. +type evmEnvelope struct { + Method string `json:"method"` + Params *struct { + Result json.RawMessage `json:"result"` + } `json:"params,omitempty"` +} + +type evmHead struct { + Number string `json:"number"` + Hash string `json:"hash"` +} + +// StartEndpoints spawns the reconnect loop for every configured +// (provider, chain) connection. +func StartEndpoints() { + for _, ep := range endpoints() { + ep := ep + go func() { + backoff := minBackoff + for { + start := time.Now() + var err error + switch ep.Kind { + case "solana": + err = runSolanaConn(ep) + default: + err = runEVMConn(ep) + } + wsHealth.WithLabelValues(ep.Provider, ep.Chain).Set(0) + wsReconnects.WithLabelValues(ep.Provider, ep.Chain).Inc() + if err != nil { + fmt.Printf("[%s/%s] WS error: %v (reconnecting in %v)\n", ep.Provider, ep.Chain, err, backoff) + } + // A connection that held for a while earns a fresh + // backoff; only consecutive fast failures escalate. + if time.Since(start) > 5*time.Minute { + backoff = minBackoff + } + time.Sleep(backoff) + if backoff < maxBackoff { + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + } + }() + } +} + +// dialAndGuard opens the connection and installs ping + watchdog +// goroutines shared by the EVM and Solana read loops. The returned +// cleanup must be deferred; bumpHead must be called on every accepted +// head/slot. +func dialAndGuard(ep Endpoint) (conn *websocket.Conn, bumpHead func(), cleanup func(), err error) { + dialer := *websocket.DefaultDialer + dialer.HandshakeTimeout = dialTimeout + conn, _, err = dialer.Dial(ep.URL, nil) + if err != nil { + return nil, nil, nil, fmt.Errorf("dial: %w", err) + } + + done := make(chan struct{}) + var lastHeadUnixNano atomic.Int64 + lastHeadUnixNano.Store(time.Now().UnixNano()) + + go func() { + t := time.NewTicker(pingEvery) + defer t.Stop() + for { + select { + case <-done: + return + case <-t.C: + _ = conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)) + } + } + }() + go func() { + t := time.NewTicker(15 * time.Second) + defer t.Stop() + for { + select { + case <-done: + return + case now := <-t.C: + silent := now.Sub(time.Unix(0, lastHeadUnixNano.Load())) + if silent > headWatchdog { + fmt.Printf("[%s/%s] watchdog: no event for %s, forcing reconnect\n", ep.Provider, ep.Chain, silent.Round(time.Second)) + _ = conn.Close() + return + } + } + } + }() + + conn.SetPongHandler(func(string) error { + _ = conn.SetReadDeadline(time.Now().Add(readDeadline)) + return nil + }) + + bumpHead = func() { lastHeadUnixNano.Store(time.Now().UnixNano()) } + cleanup = func() { + close(done) + _ = conn.Close() + } + return conn, bumpHead, cleanup, nil +} + +func runEVMConn(ep Endpoint) error { + conn, bumpHead, cleanup, err := dialAndGuard(ep) + if err != nil { + return err + } + defer cleanup() + + if err := conn.WriteJSON(rpcReq{ + JSONRPC: "2.0", ID: 1, Method: "eth_subscribe", + Params: []any{"newHeads"}, + }); err != nil { + return fmt.Errorf("subscribe: %w", err) + } + + wsHealth.WithLabelValues(ep.Provider, ep.Chain).Set(1) + fmt.Printf("[%s/%s] connected, subscribed to newHeads\n", ep.Provider, ep.Chain) + + agg := aggregatorFor(ep.Chain) + for { + _ = conn.SetReadDeadline(time.Now().Add(readDeadline)) + _, msg, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("read: %w", err) + } + + var env evmEnvelope + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + if env.Method != "eth_subscription" || env.Params == nil { + continue // subscribe ack or unrelated frame + } + var head evmHead + if err := json.Unmarshal(env.Params.Result, &head); err != nil { + continue + } + blockNum := parseHexInt64(head.Number) + if blockNum == 0 { + continue + } + now := time.Now() + bumpHead() + wsHealth.WithLabelValues(ep.Provider, ep.Chain).Set(1) + agg.record(ep.Provider, blockNum, now) + } +} + +func parseHexInt64(s string) int64 { + s = strings.TrimPrefix(s, "0x") + if s == "" { + return 0 + } + n, _ := strconv.ParseInt(s, 16, 64) + return n +} diff --git a/harnesses/ws-head-latency/cmd/script/loghub.go b/harnesses/ws-head-latency/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/ws-head-latency/cmd/script/loghub.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a +// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token +// matching the LOGS_TOKEN env var. +// +// Keep in sync across miniapps (was previously the shared/loghub package; we +// inline because Railway's per-harness Docker build context can't reach a +// sibling shared module via go.mod replace). + +const logRingMax = 5000 + +type logRing struct { + mu sync.Mutex + lines []string + max int +} + +var globalLogRing = &logRing{max: logRingMax} + +func (b *logRing) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + if len(b.lines) >= b.max { + b.lines = append(b.lines[1:], entry) + } else { + b.lines = append(b.lines, entry) + } + b.mu.Unlock() +} + +func (b *logRing) snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + if tail <= 0 || tail >= len(b.lines) { + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out + } + start := len(b.lines) - tail + out := make([]string, tail) + copy(out, b.lines[start:]) + return out +} + +var logSetupOnce sync.Once + +// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a +// pipe, then spawns a goroutine that fan-outs every line to the original +// stdout AND the in-memory ring buffer. Call exactly once, very early in +// main(). +func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) } + +func doInstallLogCapture() { + originalStdout := os.Stdout + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err) + return + } + os.Stdout = w + os.Stderr = w + + go func() { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(originalStdout, line) + globalLogRing.push(line) + } + _, _ = io.Copy(originalStdout, r) + _ = originalStderr + }() +} + +// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header +// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset. +func logsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := os.Getenv("LOGS_TOKEN") + if expected == "" { + http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden) + return + } + if r.Header.Get("X-Logs-Token") != expected { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 500 + if t := r.URL.Query().Get("tail"); t != "" { + if n, err := strconv.Atoi(t); err == nil && n > 0 { + tail = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, l := range globalLogRing.snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/ws-head-latency/cmd/script/main.go b/harnesses/ws-head-latency/cmd/script/main.go new file mode 100644 index 00000000..2079aeaa --- /dev/null +++ b/harnesses/ws-head-latency/cmd/script/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" +) + +func main() { + installLogCapture() // capture stdout into /logs ring buffer + fmt.Println("=== WS Head Latency Monitor ===") + fmt.Println("Bench № 081 — per-block WebSocket push lag across RPC providers, relative to the earliest arrival in the cohort.") + fmt.Println() + + eps := endpoints() + addr := listenAddr() + fmt.Printf("Connections tracked: %d\n", len(eps)) + for _, ep := range eps { + fmt.Printf(" %-12s %-9s %s\n", ep.Provider, ep.Chain, ep.URL) + } + fmt.Printf("Metrics server: %s/metrics\n\n", addr) + + go func() { + if err := StartMetricsServer(addr); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + os.Exit(1) + } + }() + + StartEndpoints() + startHealthSweeper() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + s := <-sig + fmt.Printf("\n[shutdown] received %v\n", s) +} diff --git a/harnesses/ws-head-latency/cmd/script/metrics.go b/harnesses/ws-head-latency/cmd/script/metrics.go new file mode 100644 index 00000000..9c589c1c --- /dev/null +++ b/harnesses/ws-head-latency/cmd/script/metrics.go @@ -0,0 +1,119 @@ +package main + +import ( + "net/http" + "os" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Prom metrics for the ws-head-latency harness. Names follow the +// `__` OCB convention. Series carry +// {provider, chain} plus a static {region} ConstLabel bound at +// process startup, so a multi-region deploy (Railway replicas or +// VPS + Railway hybrid) never collides series across vantage points +// and no observation site needs to know about the region. +// +// Region resolution order at startup: REGION (matches the convention +// used by rpc-capabilities and every other multi-region harness on +// Railway), then RAILWAY_REPLICA_REGION (Railway auto-sets it), then +// the eu-west default that matches the historical single-vantage +// Paris VPS deployment. Set REGION explicitly on each replica to +// guarantee a stable value across redeploys. +var region = resolveRegion() + +func resolveRegion() string { + if r := os.Getenv("REGION"); r != "" { + return r + } + if r := os.Getenv("RAILWAY_REPLICA_REGION"); r != "" { + return r + } + return "eu-west" +} + +var regionLabels = prometheus.Labels{"region": region} + +var ( + headLagHist = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "ws_head_lag_milliseconds", + Help: "Per-block push lag vs the earliest provider in the cohort, in milliseconds. Relative measurement: the fastest provider on a block reads 0 by construction.", + // Sub-ms floor because the winner reads near zero by + // construction; a 5ms first bucket used to collapse every + // leader's p50 to ~2.5ms (histogram_quantile interpolation + // artifact inside [0,5ms]). Fine buckets from 0.5ms up + // through 10s cover same-frame ties through multi-second + // stragglers on every chain in the cohort (Solana ~400ms + // slots, Base 2s blocks, Ethereum 12s blocks). + Buckets: []float64{ + 0.5, 1, 2, 3, 5, 8, 12, 20, 35, 60, + 100, 160, 260, 420, 680, 1100, 1800, 3000, 5000, 10000, + }, + ConstLabels: regionLabels, + }, + []string{"provider", "chain"}, + ) + + headWins = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "ws_head_wins_total", + Help: "Blocks where this provider was the first in the cohort to push the head/slot. Only counted when at least 2 providers saw the block.", + ConstLabels: regionLabels, + }, + []string{"provider", "chain"}, + ) + + headBlocksSeen = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "ws_head_blocks_seen_total", + Help: "Distinct blocks/slots this provider pushed within the cohort settle window. Ratio against the cohort max is the coverage score.", + ConstLabels: regionLabels, + }, + []string{"provider", "chain"}, + ) + + wsReconnects = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "ws_reconnects_total", + Help: "WebSocket reconnections per (provider, chain) since process start. Sustained non-zero rate flags an unstable endpoint.", + ConstLabels: regionLabels, + }, + []string{"provider", "chain"}, + ) + + wsHealth = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "ws_health", + Help: "1 when the subscription is connected and delivered a message in the last 120s, 0 otherwise.", + ConstLabels: regionLabels, + }, + []string{"provider", "chain"}, + ) + + headBlockGap = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "ws_block_gap_total", + Help: "Blocks the cohort saw but this provider missed (absent from the 5s settle window while its connection was otherwise live).", + ConstLabels: regionLabels, + }, + []string{"provider", "chain"}, + ) +) + +// StartMetricsServer binds /metrics + /health + /logs on addr. Blocking +// call, run in its own goroutine. +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ws-head-latency harness · OpenChainBench № 081")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/ws-head-latency/cmd/script/solana_ws.go b/harnesses/ws-head-latency/cmd/script/solana_ws.go new file mode 100644 index 00000000..6ebf8cc3 --- /dev/null +++ b/harnesses/ws-head-latency/cmd/script/solana_ws.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "fmt" + "time" +) + +// Solana slot push via `slotSubscribe`. The notification frame looks +// like: +// +// {"jsonrpc":"2.0","method":"slotNotification", +// "params":{"result":{"parent":364,"root":362,"slot":365},"subscription":0}} +// +// The slot number is the cohort key, playing the role the block number +// plays on EVM chains. Solana slots tick every ~400ms, so the cohort +// churn is higher but the settle/score path is identical. + +type solanaEnvelope struct { + Method string `json:"method"` + Params *struct { + Result struct { + Slot int64 `json:"slot"` + } `json:"result"` + } `json:"params,omitempty"` + // Subscribe ack: {"jsonrpc":"2.0","result":23784,"id":1} + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func runSolanaConn(ep Endpoint) error { + conn, bumpHead, cleanup, err := dialAndGuard(ep) + if err != nil { + return err + } + defer cleanup() + + if err := conn.WriteJSON(rpcReq{ + JSONRPC: "2.0", ID: 1, Method: "slotSubscribe", + Params: []any{}, + }); err != nil { + return fmt.Errorf("subscribe: %w", err) + } + + wsHealth.WithLabelValues(ep.Provider, ep.Chain).Set(1) + fmt.Printf("[%s/%s] connected, subscribed to slotSubscribe\n", ep.Provider, ep.Chain) + + agg := aggregatorFor(ep.Chain) + for { + _ = conn.SetReadDeadline(time.Now().Add(readDeadline)) + _, msg, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("read: %w", err) + } + + var env solanaEnvelope + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + if env.Error != nil { + // Endpoint answered the subscribe with a JSON-RPC error + // (method disabled, quota, ...). Surface it and let the + // outer loop back off instead of holding a dead socket. + return fmt.Errorf("subscribe error %d: %s", env.Error.Code, env.Error.Message) + } + if env.Method != "slotNotification" || env.Params == nil { + continue // subscribe ack or unrelated frame + } + slot := env.Params.Result.Slot + if slot == 0 { + continue + } + now := time.Now() + bumpHead() + wsHealth.WithLabelValues(ep.Provider, ep.Chain).Set(1) + agg.record(ep.Provider, slot, now) + } +} diff --git a/harnesses/ws-head-latency/go.mod b/harnesses/ws-head-latency/go.mod new file mode 100644 index 00000000..3bca706e --- /dev/null +++ b/harnesses/ws-head-latency/go.mod @@ -0,0 +1,21 @@ +module ws-head-latency + +go 1.24.0 + +require ( + github.com/gorilla/websocket v1.5.3 + github.com/prometheus/client_golang v1.23.2 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/ws-head-latency/go.sum b/harnesses/ws-head-latency/go.sum new file mode 100644 index 00000000..02807ba4 --- /dev/null +++ b/harnesses/ws-head-latency/go.sum @@ -0,0 +1,48 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/ws-head-latency/railway.toml b/harnesses/ws-head-latency/railway.toml new file mode 100644 index 00000000..2abbb1f0 --- /dev/null +++ b/harnesses/ws-head-latency/railway.toml @@ -0,0 +1,7 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/health" +restartPolicyType = "ON_FAILURE" diff --git a/harnesses/xstocks-peg/Dockerfile b/harnesses/xstocks-peg/Dockerfile new file mode 100644 index 00000000..63108cfc --- /dev/null +++ b/harnesses/xstocks-peg/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/xstocks-peg/cmd/script/config.go b/harnesses/xstocks-peg/cmd/script/config.go new file mode 100644 index 00000000..b151be10 --- /dev/null +++ b/harnesses/xstocks-peg/cmd/script/config.go @@ -0,0 +1,60 @@ +package main + +import ( + "os" + "strings" + "time" +) + +// xstocks-peg: Backed's xStocks tokenized equities on Solana vs their +// Yahoo reference, deviation in bps, session-labeled. Same metric +// contract as the Robinhood harness (tsp_* family) with +// issuer="xstocks", so cross-issuer comparisons are pure PromQL. +// +// Price read: Jupiter lite-api swap quotes in BOTH directions per +// symbol (sell 1 share to USDC, buy with the equivalent USDC); the mid +// is the executable peg price, immune to the price/v3 drift observed +// on thin routes (PLTRx v3 was $1.93 off its executable quote). +// A single batched price/v3 call per tick supplies the Token-2022 +// ScaledUiAmount multiplier (7 of 12 mints carry one, ~1.0009), which +// converts raw 1e8 quote units to exactly one UI share. +// +// Cohort: 12 xStocks with verified Jupiter routes at 1-share impact +// under 2bp (2026-07-13). All mints 8 decimals. + +const ( + usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + oneShareRaw = 100000000 // 1e8 = 1 share at 8 decimals, pre-multiplier + pollInterval = 60 * time.Second + httpTimeout = 15 * time.Second + quoteGap = 1100 * time.Millisecond // lite tier: stay well under 60 req/min + issuerLabel = "xstocks" + logThresholdBps = 100.0 +) + +type Asset struct { + Symbol string // Yahoo ticker (HOODx maps to HOOD, etc.) + Mint string +} + +var assets = []Asset{ + {Symbol: "TSLA", Mint: "XsDoVfqeBukxuZHWhdvWHBhgEHjGNst4MLodqsJHzoB"}, + {Symbol: "NVDA", Mint: "Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh"}, + {Symbol: "AAPL", Mint: "XsbEhLAtcf6HdfpFZ5xEMdqW8nfAvcsP5bdudRLJzJp"}, + {Symbol: "MSFT", Mint: "XspzcW1PRtgf6Wj92HCiZdjzKCyFekVD8P5Ueh3dRMX"}, + {Symbol: "AMZN", Mint: "Xs3eBt7uRfJX8QUs4suhyU8p2M6DoUDrJyWBa8LLZsg"}, + {Symbol: "GOOGL", Mint: "XsCPL9dNWBMvFtTmwcCA5v3xWPSMEBCszbQdiLLq6aN"}, + {Symbol: "META", Mint: "Xsa62P5mvPszXL1krVUnU5ar38bBSVcWAB6fmPCo5Zu"}, + {Symbol: "HOOD", Mint: "XsvNBAYkrDRNhA7wPHQfX3ZUXZyZLdnCQDfHZ56bzpg"}, + {Symbol: "SPY", Mint: "XsoCS1TfEyfFhfvj8EtZ528L3CaKBDBRqRapnBbDF2W"}, + {Symbol: "QQQ", Mint: "Xs8S1uUs1zvS2p7iwtsG3b6fkhpvmwz4GYU3gWAmWHZ"}, + {Symbol: "COIN", Mint: "Xs7ZdzSHLU9ftNJsii5fCeJhoRWSC32SQGzGQtePxNu"}, + {Symbol: "PLTR", Mint: "XsoBhf2ufR8fTyNSjqfU71DYGaE6Z3SUGAidpzriAA4"}, +} + +func listenAddr() string { + if v := strings.TrimSpace(os.Getenv("LISTEN_ADDR")); v != "" { + return v + } + return ":2112" +} diff --git a/harnesses/xstocks-peg/cmd/script/jupiter.go b/harnesses/xstocks-peg/cmd/script/jupiter.go new file mode 100644 index 00000000..a34a4708 --- /dev/null +++ b/harnesses/xstocks-peg/cmd/script/jupiter.go @@ -0,0 +1,143 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// Jupiter lite-api legs. Two swap quotes per symbol per tick (sell one +// share, buy one share worth of USDC back), spaced quoteGap apart to +// stay far under the lite tier's 60 req/min. The mid of the two +// implied prices is the executable peg price. One batched price/v3 +// call per tick provides the ScaledUiAmount multiplier per mint. + +const jupUA = "OpenChainBench/1.0 (+https://openchainbench.com)" + +func jupGet(client *http.Client, url string) ([]byte, string) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, "request_build" + } + req.Header.Set("User-Agent", jupUA) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, "network" + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, "read" + } + if resp.StatusCode != 200 { + return nil, fmt.Sprintf("http_%d", resp.StatusCode) + } + return raw, "ok" +} + +// fetchMultipliers reads price/v3 for every mint in one call and +// derives the ScaledUiAmount multiplier as usdPrice / usdPricePrescaled +// (1.0 when the mint carries no scaling config). +func fetchMultipliers(client *http.Client) map[string]float64 { + ids := make([]string, 0, len(assets)) + for _, a := range assets { + ids = append(ids, a.Mint) + } + raw, status := jupGet(client, "https://lite-api.jup.ag/price/v3?ids="+strings.Join(ids, ",")) + if raw == nil { + tspSourceCall.WithLabelValues("jup_price", status).Inc() + return nil + } + var flat map[string]struct { + USDPrice float64 `json:"usdPrice"` + USDPricePrescaled float64 `json:"usdPricePrescaled"` + } + if err := json.Unmarshal(raw, &flat); err != nil { + tspSourceCall.WithLabelValues("jup_price", "parse").Inc() + return nil + } + out := make(map[string]float64, len(flat)) + for mint, v := range flat { + m := 1.0 + if v.USDPricePrescaled > 0 && v.USDPrice > 0 { + m = v.USDPrice / v.USDPricePrescaled + } + out[mint] = m + } + tspSourceCall.WithLabelValues("jup_price", "ok").Inc() + return out +} + +type quoteResp struct { + OutAmount string `json:"outAmount"` +} + +func quoteOut(client *http.Client, inMint, outMint string, amount int64) (float64, bool) { + url := fmt.Sprintf( + "https://lite-api.jup.ag/swap/v1/quote?inputMint=%s&outputMint=%s&amount=%d&slippageBps=100", + inMint, outMint, amount, + ) + raw, status := jupGet(client, url) + if raw == nil { + tspSourceCall.WithLabelValues("jup_quote", status).Inc() + return 0, false + } + var q quoteResp + if err := json.Unmarshal(raw, &q); err != nil || q.OutAmount == "" { + tspSourceCall.WithLabelValues("jup_quote", "parse").Inc() + return 0, false + } + n, err := strconv.ParseFloat(q.OutAmount, 64) + if err != nil || n <= 0 { + tspSourceCall.WithLabelValues("jup_quote", "decode").Inc() + return 0, false + } + tspSourceCall.WithLabelValues("jup_quote", "ok").Inc() + return n, true +} + +// fetchOnchainPrices returns the executable mid price in USDC per UI +// share for every symbol. Sequential with quoteGap spacing: ~26s for +// the 12-symbol cohort, comfortably inside the 60s tick. +func fetchOnchainPrices(client *http.Client, multipliers map[string]float64) map[string]float64 { + prices := make(map[string]float64, len(assets)) + start := time.Now() + for _, a := range assets { + mult := 1.0 + if m, ok := multipliers[a.Mint]; ok && m > 0 { + mult = m + } + + // Sell leg: 1 raw share -> USDC. + sellOut, okSell := quoteOut(client, a.Mint, usdcMint, oneShareRaw) + time.Sleep(quoteGap) + sellPx := 0.0 + if okSell { + sellPx = sellOut / 1e6 * mult + } + + // Buy leg: spend the sell proceeds, see how many shares return. + buyPx := 0.0 + if okSell { + gotRaw, okBuy := quoteOut(client, usdcMint, a.Mint, int64(sellOut)) + if okBuy && gotRaw > 0 { + buyPx = (sellOut / 1e6) / (gotRaw / 1e8 / mult) + } + time.Sleep(quoteGap) + } + + switch { + case sellPx > 0 && buyPx > 0: + prices[strings.ToLower(a.Symbol)] = (sellPx + buyPx) / 2 + case sellPx > 0: + prices[strings.ToLower(a.Symbol)] = sellPx + } + } + tspSourceLatency.WithLabelValues("onchain").Set(float64(time.Since(start).Milliseconds())) + return prices +} diff --git a/harnesses/xstocks-peg/cmd/script/loghub.go b/harnesses/xstocks-peg/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/xstocks-peg/cmd/script/loghub.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a +// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token +// matching the LOGS_TOKEN env var. +// +// Keep in sync across miniapps (was previously the shared/loghub package; we +// inline because Railway's per-harness Docker build context can't reach a +// sibling shared module via go.mod replace). + +const logRingMax = 5000 + +type logRing struct { + mu sync.Mutex + lines []string + max int +} + +var globalLogRing = &logRing{max: logRingMax} + +func (b *logRing) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + if len(b.lines) >= b.max { + b.lines = append(b.lines[1:], entry) + } else { + b.lines = append(b.lines, entry) + } + b.mu.Unlock() +} + +func (b *logRing) snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + if tail <= 0 || tail >= len(b.lines) { + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out + } + start := len(b.lines) - tail + out := make([]string, tail) + copy(out, b.lines[start:]) + return out +} + +var logSetupOnce sync.Once + +// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a +// pipe, then spawns a goroutine that fan-outs every line to the original +// stdout AND the in-memory ring buffer. Call exactly once, very early in +// main(). +func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) } + +func doInstallLogCapture() { + originalStdout := os.Stdout + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err) + return + } + os.Stdout = w + os.Stderr = w + + go func() { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(originalStdout, line) + globalLogRing.push(line) + } + _, _ = io.Copy(originalStdout, r) + _ = originalStderr + }() +} + +// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header +// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset. +func logsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := os.Getenv("LOGS_TOKEN") + if expected == "" { + http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden) + return + } + if r.Header.Get("X-Logs-Token") != expected { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 500 + if t := r.URL.Query().Get("tail"); t != "" { + if n, err := strconv.Atoi(t); err == nil && n > 0 { + tail = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, l := range globalLogRing.snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/xstocks-peg/cmd/script/main.go b/harnesses/xstocks-peg/cmd/script/main.go new file mode 100644 index 00000000..5cc18415 --- /dev/null +++ b/harnesses/xstocks-peg/cmd/script/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "math" + "net/http" + "strings" + "time" +) + +func main() { + installLogCapture() + fmt.Println("=== xStocks Peg Harness ===") + fmt.Println("OpenChainBench — Backed xStocks on Solana vs Nasdaq reference.") + fmt.Printf("Cohort: %d assets | poll: %s | Jupiter lite-api\n", len(assets), pollInterval) + for _, a := range assets { + fmt.Printf(" - %-6s mint=%s\n", a.Symbol, a.Mint) + } + + go func() { + if err := startMetricsServer(listenAddr()); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + } + }() + + client := &http.Client{Timeout: httpTimeout} + var periods *tradingPeriods + + tick := func() { + now := time.Now() + if periods == nil || now.Sub(periods.FetchedAt) > 30*time.Minute { + if tp := fetchTradingPeriods(client); tp != nil { + periods = tp + } + } + state := periods.state(now) + for _, s := range []string{"pre", "regular", "post", "closed", "unknown"} { + v := 0.0 + if s == state { + v = 1.0 + } + tspMarketState.WithLabelValues(s).Set(v) + } + + refs := fetchReferencePrices(client) + multipliers := fetchMultipliers(client) + onchain := fetchOnchainPrices(client, multipliers) + + for _, a := range assets { + sym := strings.ToLower(a.Symbol) + ref, hasRef := refs[sym] + pool, hasPool := onchain[sym] + if hasRef { + tspPriceReference.WithLabelValues(sym).Set(ref.Price) + if ref.AsOfSec > 0 { + tspRefAge.WithLabelValues(sym).Set(float64(now.Unix() - ref.AsOfSec)) + } + } + if hasPool { + tspPriceOnchain.WithLabelValues(sym, issuerLabel).Set(pool) + } + if hasRef && hasPool && ref.Price > 0 { + dev := math.Abs(pool-ref.Price) / ref.Price * 10000 + tspDeviationBps.WithLabelValues(sym, state, issuerLabel).Set(dev) + tspHealth.WithLabelValues(sym).Set(1) + flag := "" + if dev > logThresholdBps && state == "regular" { + flag = " <-- wide" + } + fmt.Printf("[%s][%s] pool=%.2f ref=%.2f dev=%.1fbps%s\n", sym, state, pool, ref.Price, dev, flag) + } else { + tspHealth.WithLabelValues(sym).Set(0) + } + } + } + + tick() + t := time.NewTicker(pollInterval) + defer t.Stop() + for range t.C { + tick() + } +} diff --git a/harnesses/xstocks-peg/cmd/script/metrics.go b/harnesses/xstocks-peg/cmd/script/metrics.go new file mode 100644 index 00000000..82072a68 --- /dev/null +++ b/harnesses/xstocks-peg/cmd/script/metrics.go @@ -0,0 +1,67 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + // Headline: absolute deviation of the onchain pool price from the + // Yahoo reference, in basis points, labeled with the market session + // the sample was taken in. The bench pins its ranking to + // market_state="regular"; the closed-state series is the weekend / + // overnight drift panel. + tspDeviationBps = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_deviation_bps", + Help: "Absolute onchain vs reference price deviation per tokenized stock, in bps, labeled by market session state.", + }, []string{"asset", "market_state", "issuer"}) + + tspPriceOnchain = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_price_onchain_usdg", + Help: "Venue spot price of the tokenized stock, in the venue quote stable.", + }, []string{"asset", "issuer"}) + + tspPriceReference = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_price_reference_usd", + Help: "Reference equity price from Yahoo Finance (regularMarketPrice; last close when the market is closed).", + }, []string{"asset"}) + + tspRefAge = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_reference_age_seconds", + Help: "Age of the reference price sample (now minus regularMarketTime). Large outside regular hours by design.", + }, []string{"asset"}) + + tspMarketState = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_market_session", + Help: "1 for the currently active market session label, 0 otherwise.", + }, []string{"market_state"}) + + tspSourceLatency = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_source_latency_milliseconds", + Help: "Round-trip latency of the last fetch per source.", + }, []string{"source"}) + + tspSourceCall = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "tsp_source_call_total", + Help: "Fetch outcomes per source (onchain batch, yahoo spark, yahoo chart).", + }, []string{"source", "result"}) + + tspHealth = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_health", + Help: "1 when the last tick produced a deviation sample for the asset, 0 otherwise.", + }, []string{"asset"}) +) + +func startMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/xstocks-peg/cmd/script/reference.go b/harnesses/xstocks-peg/cmd/script/reference.go new file mode 100644 index 00000000..3ebc5c3b --- /dev/null +++ b/harnesses/xstocks-peg/cmd/script/reference.go @@ -0,0 +1,207 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Reference leg: Yahoo Finance, keyless. One spark batch call fetches +// the latest price for every symbol; one chart call (AAPL as the +// bellwether) fetches the day's exact session windows, which Yahoo +// publishes holiday-aware so the harness never maintains an NYSE +// calendar. Verified 2026-07-13 from the harness host: clean 200s with +// a browser User-Agent (datacenter IP), 70-130ms. +// +// Market state is derived from currentTradingPeriod epochs: +// pre / regular / post / closed. Deviation samples carry the state as +// a label so the bench can pin its headline to regular hours and read +// the weekend drift from the closed-state series. + +const ( + sparkHost = "https://query1.finance.yahoo.com/v8/finance/spark" + chartHost = "https://query1.finance.yahoo.com/v8/finance/chart/AAPL" + yahooUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" +) + +type refQuote struct { + Price float64 + AsOfSec int64 +} + +type tradingPeriods struct { + PreStart, PreEnd int64 + RegularStart, RegularEnd int64 + PostStart, PostEnd int64 + FetchedAt time.Time +} + +// fetchReferencePrices returns the freshest Yahoo price per symbol +// (lowercased) from one spark batch call. When the market is closed +// the spark meta still carries regularMarketPrice = last close, which +// is exactly the weekend reference we want. +func fetchReferencePrices(client *http.Client) map[string]refQuote { + syms := make([]string, 0, len(assets)) + for _, a := range assets { + syms = append(syms, a.Symbol) + } + url := sparkHost + "?symbols=" + strings.Join(syms, ",") + "&range=1d&interval=1m" + raw, status := yahooGet(client, url) + if raw == nil { + tspSourceCall.WithLabelValues("yahoo", status).Inc() + // Rotate to query2 once before giving up this tick. + raw, status = yahooGet(client, strings.Replace(url, "query1", "query2", 1)) + if raw == nil { + tspSourceCall.WithLabelValues("yahoo", status).Inc() + return nil + } + } + tspSourceCall.WithLabelValues("yahoo", "ok").Inc() + + // Spark response: {"spark":{"result":[{"symbol":"AAPL","response":[{"meta":{...}}]}]}} + // or the flatter {"AAPL":{...}} shape depending on edge; handle both. + prices := make(map[string]refQuote, len(syms)) + var envel struct { + Spark struct { + Result []struct { + Symbol string `json:"symbol"` + Response []struct { + Meta struct { + RegularMarketPrice float64 `json:"regularMarketPrice"` + RegularMarketTime int64 `json:"regularMarketTime"` + } `json:"meta"` + } `json:"response"` + } `json:"result"` + } `json:"spark"` + } + if err := json.Unmarshal(raw, &envel); err == nil { + for _, r := range envel.Spark.Result { + if len(r.Response) == 0 || r.Response[0].Meta.RegularMarketPrice <= 0 { + continue + } + prices[strings.ToLower(r.Symbol)] = refQuote{ + Price: r.Response[0].Meta.RegularMarketPrice, + AsOfSec: r.Response[0].Meta.RegularMarketTime, + } + } + } + if len(prices) == 0 { + // Flat spark shape (observed live 2026-07-13): + // {"MSFT":{"timestamp":[...],"close":[...],"previousClose":X},...} + // Price = last non-null close; previousClose is the fallback when + // the close array is empty (market closed all day). + var flat map[string]struct { + Timestamp []int64 `json:"timestamp"` + Close []*float64 `json:"close"` + PreviousClose *float64 `json:"previousClose"` + } + if err := json.Unmarshal(raw, &flat); err == nil { + for sym, v := range flat { + q := refQuote{} + for i := len(v.Close) - 1; i >= 0; i-- { + if v.Close[i] != nil && *v.Close[i] > 0 { + q.Price = *v.Close[i] + if i < len(v.Timestamp) { + q.AsOfSec = v.Timestamp[i] + } + break + } + } + if q.Price == 0 && v.PreviousClose != nil && *v.PreviousClose > 0 { + q.Price = *v.PreviousClose + } + if q.Price > 0 { + prices[strings.ToLower(sym)] = q + } + } + } + } + if len(prices) == 0 { + tspSourceCall.WithLabelValues("yahoo", "parse_empty").Inc() + head := string(raw) + if len(head) > 400 { + head = head[:400] + } + fmt.Printf("[yahoo] spark yielded no symbols; body head: %s\n", head) + return nil + } + return prices +} + +// fetchTradingPeriods reads currentTradingPeriod from one chart call. +// Refreshed every 30 minutes; between refreshes marketState() reuses +// the cached windows. +func fetchTradingPeriods(client *http.Client) *tradingPeriods { + raw, status := yahooGet(client, chartHost+"?range=1d&interval=5m") + if raw == nil { + tspSourceCall.WithLabelValues("yahoo_chart", status).Inc() + return nil + } + var envel struct { + Chart struct { + Result []struct { + Meta struct { + CurrentTradingPeriod struct { + Pre struct{ Start, End int64 } `json:"pre"` + Regular struct{ Start, End int64 } `json:"regular"` + Post struct{ Start, End int64 } `json:"post"` + } `json:"currentTradingPeriod"` + } `json:"meta"` + } `json:"result"` + } `json:"chart"` + } + if err := json.Unmarshal(raw, &envel); err != nil || len(envel.Chart.Result) == 0 { + tspSourceCall.WithLabelValues("yahoo_chart", "parse").Inc() + return nil + } + m := envel.Chart.Result[0].Meta.CurrentTradingPeriod + tspSourceCall.WithLabelValues("yahoo_chart", "ok").Inc() + return &tradingPeriods{ + PreStart: m.Pre.Start, PreEnd: m.Pre.End, + RegularStart: m.Regular.Start, RegularEnd: m.Regular.End, + PostStart: m.Post.Start, PostEnd: m.Post.End, + FetchedAt: time.Now(), + } +} + +func (tp *tradingPeriods) state(now time.Time) string { + if tp == nil { + return "unknown" + } + u := now.Unix() + switch { + case u >= tp.RegularStart && u < tp.RegularEnd: + return "regular" + case u >= tp.PreStart && u < tp.PreEnd: + return "pre" + case u >= tp.PostStart && u < tp.PostEnd: + return "post" + default: + return "closed" + } +} + +func yahooGet(client *http.Client, url string) ([]byte, string) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, "request_build" + } + req.Header.Set("User-Agent", yahooUA) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, "network" + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<22)) + if err != nil { + return nil, "read" + } + if resp.StatusCode != 200 { + return nil, fmt.Sprintf("http_%d", resp.StatusCode) + } + return raw, "ok" +} diff --git a/harnesses/xstocks-peg/go.mod b/harnesses/xstocks-peg/go.mod new file mode 100644 index 00000000..815ee407 --- /dev/null +++ b/harnesses/xstocks-peg/go.mod @@ -0,0 +1,18 @@ +module xstocks-peg + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/xstocks-peg/go.sum b/harnesses/xstocks-peg/go.sum new file mode 100644 index 00000000..d6b8ca98 --- /dev/null +++ b/harnesses/xstocks-peg/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/infrastructure/monitoring/prometheus/prometheus.yml b/infrastructure/monitoring/prometheus/prometheus.yml index 840e35af..213f5321 100644 --- a/infrastructure/monitoring/prometheus/prometheus.yml +++ b/infrastructure/monitoring/prometheus/prometheus.yml @@ -171,6 +171,23 @@ scrape_configs: benchmark: rpc-capabilities metrics_path: /metrics + # OpenChainBench bench №081 + siblings 087 (base) + 088 (solana) — + # WebSocket head-latency race across free keyless RPC providers. + # Same 3-region layout as rpc-capabilities. Each service pins its + # REGION env var; the harness bakes it as a ConstLabel on every + # ws_head_* metric so Prom fed the union of the 3 replicas gets + # {provider, chain, region} triples for the per-region spec tabs. + - job_name: 'ws-head-latency' + honor_labels: true + static_configs: + - targets: + - 'ws-head-latency-us.railway.internal:2112' # us-east + - 'ws-head-latency-eu.railway.internal:2112' # eu-west + - 'ws-head-latency-sgp.railway.internal:2112' # sgp + labels: + benchmark: ws-head-latency + metrics_path: /metrics + # OpenChainBench — keyed free-tier RPC latency (Alchemy, Infura, # Chainstack, Ankr, Helius). Companion to rpc-capabilities: same # metric names + tier="keyed" label, picked up by the shared diff --git a/next.config.ts b/next.config.ts index dc7af8c4..dfe2988a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -91,6 +91,13 @@ const nextConfig: NextConfig = { "./answers/**/*.yaml", "./alternatives/**/*.yml", "./alternatives/**/*.yaml", + // Prebuild-generated manifest of editorial hub-page git mtimes. + // Read at module scope by src/app/sitemap.ts so reflects + // real edit history instead of Vercel's build-container mtime + // reset (which pins every file to Oct 20 2018). Not committed + // (regenerated every build) so it must be traced in explicitly to + // ship in the serverless bundle. + "./data/page-mtimes.json", ], }, async headers() { diff --git a/package.json b/package.json index e0f7bb70..776f5474 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "Open benchmarks for crypto infrastructure — aggregators, bridges, price feeds.", "scripts": { "dev": "next dev", + "prebuild": "tsx scripts/emit-page-mtimes.ts", "build": "next build", "start": "next start", "lint": "eslint", diff --git a/public/logos/aapl.svg b/public/logos/aapl.svg new file mode 100644 index 00000000..915e8909 --- /dev/null +++ b/public/logos/aapl.svg @@ -0,0 +1 @@ +Apple \ No newline at end of file diff --git a/public/logos/amd.svg b/public/logos/amd.svg new file mode 100644 index 00000000..ae3de073 --- /dev/null +++ b/public/logos/amd.svg @@ -0,0 +1 @@ +AMD \ No newline at end of file diff --git a/public/logos/amzn.png b/public/logos/amzn.png new file mode 100644 index 00000000..d466c792 Binary files /dev/null and b/public/logos/amzn.png differ diff --git a/public/logos/googl.svg b/public/logos/googl.svg new file mode 100644 index 00000000..088288fa --- /dev/null +++ b/public/logos/googl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/meta.svg b/public/logos/meta.svg new file mode 100644 index 00000000..385fb0a1 --- /dev/null +++ b/public/logos/meta.svg @@ -0,0 +1 @@ +Meta \ No newline at end of file diff --git a/public/logos/msft.svg b/public/logos/msft.svg new file mode 100644 index 00000000..5334aa7c --- /dev/null +++ b/public/logos/msft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/mu.png b/public/logos/mu.png new file mode 100644 index 00000000..f8f9a294 Binary files /dev/null and b/public/logos/mu.png differ diff --git a/public/logos/nvda.svg b/public/logos/nvda.svg new file mode 100644 index 00000000..e427e2ce --- /dev/null +++ b/public/logos/nvda.svg @@ -0,0 +1 @@ +NVIDIA \ No newline at end of file diff --git a/public/logos/orca.png b/public/logos/orca.png new file mode 100644 index 00000000..35bf2a7d Binary files /dev/null and b/public/logos/orca.png differ diff --git a/public/logos/parity.png b/public/logos/parity.png new file mode 100644 index 00000000..3d06da06 Binary files /dev/null and b/public/logos/parity.png differ diff --git a/public/logos/pltr.svg b/public/logos/pltr.svg new file mode 100644 index 00000000..6bb694d7 --- /dev/null +++ b/public/logos/pltr.svg @@ -0,0 +1 @@ +Palantir \ No newline at end of file diff --git a/public/logos/polkadot.png b/public/logos/polkadot.png new file mode 100644 index 00000000..23b3782e Binary files /dev/null and b/public/logos/polkadot.png differ diff --git a/public/logos/pyth.jpg b/public/logos/pyth.jpg new file mode 100644 index 00000000..4bbb1cac Binary files /dev/null and b/public/logos/pyth.jpg differ diff --git a/public/logos/qqq.png b/public/logos/qqq.png new file mode 100644 index 00000000..dab7e300 Binary files /dev/null and b/public/logos/qqq.png differ diff --git a/public/logos/spy.svg b/public/logos/spy.svg new file mode 100644 index 00000000..d3d540a1 --- /dev/null +++ b/public/logos/spy.svg @@ -0,0 +1,35 @@ + + + diff --git a/public/logos/stakely.svg b/public/logos/stakely.svg new file mode 100644 index 00000000..dc5790a2 --- /dev/null +++ b/public/logos/stakely.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/tsla.svg b/public/logos/tsla.svg new file mode 100644 index 00000000..e990fab5 --- /dev/null +++ b/public/logos/tsla.svg @@ -0,0 +1 @@ +Tesla \ No newline at end of file diff --git a/scripts/editorial-check.sh b/scripts/editorial-check.sh new file mode 100755 index 00000000..78449188 --- /dev/null +++ b/scripts/editorial-check.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Editorial guard for hand-written positioning + expert_take fields. +# Existing seo_title / question / FAQ / intro fields are outside the +# scope of this script; their editorial policy is enforced elsewhere. +# +# Extracts positioning: | and expert_take: | blocks from +# alternatives/*.yml and answers/*.yml, then greps for hard-rule +# violations (em/en dashes, marketing adjectives, first/second person +# pronouns, year mentions, vendor pricing leaks). +# +# Exit 0 clean, exit 1 with per-line diagnostics. +set -u +cd "$(dirname "$0")/.." + +# Extract the positioning and expert_take literal blocks into a +# temp file, tagged with the source file + starting line so grep +# output stays actionable. +TMP="$(mktemp)" +trap 'rm -f "$TMP"' EXIT + +python3 - <<'PY' > "$TMP" +import os, re +for d in ("alternatives", "answers"): + if not os.path.isdir(d): + continue + for name in sorted(os.listdir(d)): + if not name.endswith(".yml"): + continue + path = os.path.join(d, name) + lines = open(path).read().splitlines() + i, n = 0, len(lines) + while i < n: + m = re.match(r"^(positioning|expert_take):\s*\|\s*$", lines[i]) + if not m: + i += 1 + continue + field = m.group(1) + start = i + 1 + i += 1 + while i < n and (lines[i].startswith(" ") or lines[i].strip() == ""): + i += 1 + for k, ln in enumerate(lines[start:i]): + print(f"{path}:{field}:{start + k + 1}:{ln}") +PY + +FAIL=0 +report() { echo "$@"; FAIL=1; } + +# Hard rules +# Pronouns are matched case-sensitively so "US" (United States) and +# "US-based" don't false-flag as "us". Marketing list intentionally +# excludes "premier" and "top" because they collide with proper nouns +# (Kalshi Premier tier, top of book, top-of-book, top-of-stack). +while IFS= read -r hit; do report "[DASH] $hit"; done < <(python3 -c " +import sys +for ln in open(sys.argv[1]): + if '—' in ln or '–' in ln: + sys.stdout.write(ln) +" "$TMP") +while IFS= read -r hit; do report "[MARKETING] $hit"; done < <(grep -iE '\b(leading|blazing|cutting[- ]edge|industry[- ]leading|revolutionary|world[- ]class|unmatched|unparalleled|next[- ]gen)\b' "$TMP" || true) +while IFS= read -r hit; do report "[PRONOUN] $hit"; done < <(grep -E '\b(we|our|ours|you|your|yours)\b' "$TMP" || true) +while IFS= read -r hit; do report "[PRONOUN-US] $hit"; done < <(grep -E '\bus\b' "$TMP" || true) +while IFS= read -r hit; do report "[YEAR] $hit"; done < <(grep -E '\b(2024|2025|2026|2027)\b' "$TMP" || true) + +if [ "$FAIL" -eq 0 ]; then + echo "editorial: clean" + exit 0 +else + echo + echo "editorial: violations above. rules are in scripts/editorial-check.sh." + exit 1 +fi diff --git a/scripts/emit-page-mtimes.ts b/scripts/emit-page-mtimes.ts new file mode 100644 index 00000000..f6ee366b --- /dev/null +++ b/scripts/emit-page-mtimes.ts @@ -0,0 +1,111 @@ +#!/usr/bin/env tsx +/** + * Emit `data/page-mtimes.json`, a static map of + * { "": } + * for the editorial hub pages whose freshness feeds the sitemap. + * + * Vercel's build container runs a shallow `git clone` and, worse, + * resets every checked-out file's mtime to the build-system default + * (Oct 20 2018). Both make a runtime `statSync` useless for a real + * , and a runtime `git log` returns nothing. + * + * The fix: run this script in `prebuild` (executed BEFORE `next build`, + * on the deploy machine where `.git/` is complete), resolve each page's + * last commit timestamp via `git log -1 --format=%ct`, and write the + * result to a JSON manifest. `src/app/sitemap.ts` reads the manifest at + * module scope and returns the timestamp per URL. + * + * Fallback chain (per page): + * 1. `git log -1 --format=%ct -- ` + * 2. `statSync().mtime` (Unix seconds) + * 3. process.env.NEXT_PUBLIC_BUILD_TIME (Unix seconds) + * + * Regenerated on every build. Do NOT commit `data/page-mtimes.json`. + */ +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const ROOT = process.cwd(); +const APP_DIR = path.join(ROOT, "src/app"); +const OUT_DIR = path.join(ROOT, "data"); +const OUT_FILE = path.join(OUT_DIR, "page-mtimes.json"); + +// Pages the sitemap consults via pageMtime(). Keep this list in sync +// with staticHubRoutes in src/app/sitemap.ts. Ordered by ship date so +// diffs read cleanly when a new hub is added. +const PAGES: string[] = [ + "mcp/page.tsx", + "methodology/page.tsx", + "contribute/page.tsx", + "partners/page.tsx", + "about/page.tsx", + "press/page.tsx", + "compare/page.tsx", + "alternatives/page.tsx", + "team/page.tsx", +]; + +function gitMtimeSeconds(rel: string): number | null { + try { + const abs = path.join("src/app", rel); + const out = execSync(`git log -1 --format=%ct -- "${abs}"`, { + cwd: ROOT, + encoding: "utf8", + timeout: 5000, + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + if (!out) return null; + const secs = Number.parseInt(out, 10); + if (!Number.isFinite(secs) || secs <= 0) return null; + return secs; + } catch { + return null; + } +} + +function statMtimeSeconds(rel: string): number | null { + try { + const abs = path.join(APP_DIR, rel); + if (!existsSync(abs)) return null; + const s = statSync(abs); + const secs = Math.floor(s.mtimeMs / 1000); + if (!Number.isFinite(secs) || secs <= 0) return null; + return secs; + } catch { + return null; + } +} + +function buildTimeSeconds(): number { + const raw = process.env.NEXT_PUBLIC_BUILD_TIME; + if (raw) { + const t = Date.parse(raw); + if (Number.isFinite(t)) return Math.floor(t / 1000); + } + return Math.floor(Date.now() / 1000); +} + +function main(): void { + const manifest: Record = {}; + const buildFallback = buildTimeSeconds(); + for (const rel of PAGES) { + const git = gitMtimeSeconds(rel); + if (git !== null) { + manifest[rel] = git; + continue; + } + const stat = statMtimeSeconds(rel); + if (stat !== null) { + manifest[rel] = stat; + continue; + } + manifest[rel] = buildFallback; + } + if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(OUT_FILE, `${JSON.stringify(manifest, null, 2)}\n`); + const rel = path.relative(ROOT, OUT_FILE); + console.log(`wrote ${Object.keys(manifest).length} entries to ${rel}`); +} + +main(); diff --git a/src/app/alternatives/[slug]/page.tsx b/src/app/alternatives/[slug]/page.tsx index 0d427d19..3809608d 100644 --- a/src/app/alternatives/[slug]/page.tsx +++ b/src/app/alternatives/[slug]/page.tsx @@ -12,9 +12,11 @@ import { getBenchCreatedAt } from "@/lib/seo/bench-dates"; import { capDescription } from "@/lib/seo-text"; import { SectionLabel, SummaryStat } from "@/components/summary-stat"; import { SITE } from "@/data/site"; +import { CREATOR_PUBLISHER, CITABLE_JSON_URL, DATASET_LICENSE, HF_DATASET_URL } from "@/lib/dataset-jsonld"; import { loadAlternative } from "@/lib/alternatives"; import { Breadcrumb } from "@/components/breadcrumb"; import { buildBreadcrumbJsonLd, safeJsonLd } from "@/lib/jsonld"; +import { citableAsOf } from "@/lib/citation"; import { ProviderLogo } from "@/components/provider-logo"; import { ProviderTypeBadge } from "@/components/provider-type-badge"; import { isRegion } from "@/lib/brand"; @@ -119,14 +121,27 @@ export default async function AlternativePage({ bench.metric, ...bench.results.map((r) => r.name), ].join(", "), - creator: { "@id": `${SITE.url}/#org` }, - publisher: { "@id": `${SITE.url}/#org` }, + creator: CREATOR_PUBLISHER, + publisher: CREATOR_PUBLISHER, isAccessibleForFree: true, - license: "https://creativecommons.org/licenses/by/4.0/", + license: DATASET_LICENSE, datePublished: getBenchCreatedAt(bench.slug).toISOString(), - dateModified: bench.lastRunAt, + ...(citableAsOf(bench) ? { dateModified: bench.lastRunAt } : {}), variableMeasured: bench.metric, isBasedOn: benchUrl, + distribution: [ + { + "@type": "DataDownload", + encodingFormat: "application/json", + contentUrl: `${SITE.url}/api/stat/${bench.slug}`, + }, + { + "@type": "DataDownload", + encodingFormat: "application/json", + contentUrl: CITABLE_JSON_URL, + }, + ], + sameAs: [HF_DATASET_URL], }, { "@type": "Article", @@ -137,7 +152,7 @@ export default async function AlternativePage({ mainEntityOfPage: url, articleBody: alt.intro, datePublished: getBenchCreatedAt(bench.slug).toISOString(), - dateModified: bench.lastRunAt, + ...(citableAsOf(bench) ? { dateModified: bench.lastRunAt } : {}), author: { "@id": `${SITE.url}/#org` }, publisher: { "@id": `${SITE.url}/#org` }, about: { "@id": `${url}#dataset` }, @@ -191,6 +206,15 @@ export default async function AlternativePage({ {alt.intro} + {alt.positioning && ( +
+

Positioning

+
+ {alt.positioning} +
+
+ )} + {alt.target_url && (

About {alt.target_product}:{" "} diff --git a/src/app/answers/[slug]/page.tsx b/src/app/answers/[slug]/page.tsx index 97db48d8..395f1e73 100644 --- a/src/app/answers/[slug]/page.tsx +++ b/src/app/answers/[slug]/page.tsx @@ -9,7 +9,7 @@ import { hasLiveDataTokens, benchDataPendingFallback, } from "@/lib/answers-template"; -import { leader } from "@/lib/citation"; +import { citableAsOf, leader } from "@/lib/citation"; import { Breadcrumb } from "@/components/breadcrumb"; import { Pill } from "@/components/pill"; import { ProviderLogo } from "@/components/provider-logo"; @@ -21,6 +21,7 @@ import { buildFaqPageJsonLd, safeJsonLd, } from "@/lib/jsonld"; +import { PERSON_ID } from "@/lib/hub-jsonld"; import { capDescription } from "@/lib/seo-text"; import { getBenchCreatedAt } from "@/lib/seo/bench-dates"; @@ -105,7 +106,13 @@ export default async function AnswerPage({ // YAML's slug reference) fall through to a neutral fallback via // cleanLeftoverTokens so a placeholder string never reaches the SERP. const render = (s: string) => cleanLeftoverTokens(renderTemplate(s, bench)); - const asOfUtc = fmtAsOfUtc(bench.lastRunAt); + // Real measurement timestamp on live benches, null on drafts (whose + // lastRunAt is a wall-clock placeholder from the loader). Prevents the + // visible "Data as of ..." line and the associated

- Data as of , + Data as of , refreshed continuously.

)} @@ -241,6 +290,17 @@ export default async function AnswerPage({ {intro}

+ {expertTake && ( +
+

+ Editorial context +

+

+ {expertTake} +

+
+ )} + {topResults.length > 0 && (

diff --git a/src/app/api/badge/[slug]/[provider]/route.ts b/src/app/api/badge/[slug]/[provider]/route.ts index e5e6009f..d699b26b 100644 --- a/src/app/api/badge/[slug]/[provider]/route.ts +++ b/src/app/api/badge/[slug]/[provider]/route.ts @@ -220,21 +220,14 @@ export async function GET( // Legacy approximation for chain-dimensioned benches without a // rank_matrix_query in their spec. const scoped = rankOfChain(b, provider, chainParam); - if (!scoped) { - return new NextResponse("not found", { - status: 404, - // Short TTL: a scoped miss is usually transient (bench cache - // entry predating cellRanks, or a Prom hiccup on the matrix - // query), so don't let the CDN pin the 404 for long. - headers: { "cache-control": "public, s-maxage=60" }, - }); - } + // Serve a placeholder SVG instead of 404 on a transient scoped miss + // (bench cache entry predating cellRanks, Prom hiccup on the matrix + // query, provider without data on the scope). Keeps external embeds + // from displaying broken images and Ahrefs from flagging inbound. + if (!scoped) return placeholderSvg(b.title); r = { rank: scoped.rank, total: scoped.total, value: scoped.value }; } else { - return new NextResponse("not found", { - status: 404, - headers: { "cache-control": "public, s-maxage=60" }, - }); + return placeholderSvg(b.title); } scopeLabel = [ chainParam ? chainLabel(b, chainParam) : null, @@ -244,12 +237,7 @@ export async function GET( .join(" · "); } else { r = rankOf(b.results, provider, b.higherIsBetter); - if (!r) { - return new NextResponse("not found", { - status: 404, - headers: { "cache-control": "public, s-maxage=60" }, - }); - } + if (!r) return placeholderSvg(b.title); // Hint the aggregate scope when the bench declares dimensions so // embedders can read it. Benches without dimensions get no scope // label (it would be noise). @@ -382,6 +370,41 @@ export async function GET( }); } +/** Placeholder SVG served with HTTP 200 when the provider isn't ranked + * yet on this bench (missing p50, unknown scope cell, provider not in + * results). Serving 200 instead of 404 keeps embed images from breaking + * on partner sites during transient data gaps and keeps Ahrefs from + * flagging inbound links as broken. Short CDN TTL (60s) so a resolved + * gap surfaces quickly. */ +function placeholderSvg(benchTitle: string): NextResponse { + const title = truncate(benchTitle, 60); + const line1 = "Measurement pending"; + const line2 = "OpenChainBench"; + const W = Math.min( + W_MAX, + Math.max(W_MIN, TEXT_X + title.length * CH_11 + 30), + ); + const svg = ` + + OpenChainBench. ${escapeXml(title)}. Measurement pending. + + + + ${escapeXml(title)} + ${escapeXml(line1)} + ${escapeXml(line2)} +`; + return new NextResponse(svg, { + status: 200, + headers: { + "Content-Type": "image/svg+xml; charset=utf-8", + Vary: "Accept, Accept-Encoding", + "Cache-Control": + "public, max-age=60, s-maxage=60, stale-while-revalidate=300", + }, + }); +} + // C0 control chars (minus \t \n \r) + DEL are forbidden in XML 1.0 text. // A spec PR with a title containing one of these would otherwise produce // an SVG that browsers refuse to render - self-DoS on every embed of the diff --git a/src/app/api/citable/[date]/route.ts b/src/app/api/citable/[date]/route.ts index debd0be0..bbbbdd4b 100644 --- a/src/app/api/citable/[date]/route.ts +++ b/src/app/api/citable/[date]/route.ts @@ -2,7 +2,8 @@ import { NextResponse } from "next/server"; import { getBenchmarks } from "@/data/benchmarks"; import { SITE } from "@/data/site"; import { AllBenchmarksDraftError } from "@/lib/spec"; -import { citeBundle, fieldValue, leader, headlineSentence } from "@/lib/citation"; +import { citableAsOf, citeBundle, fieldValue, leader, headlineSentence } from "@/lib/citation"; +import { valueInDeclaredUnit } from "@/lib/format"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -111,6 +112,13 @@ export async function GET( const data = benches.map((b) => { const top = leader(b); const insufficient = b.dataConfidence === "insufficient"; + // `value` and `leader.value` are published in the declared `unit`. + // Latency benches with unit "s" store ms internally (fmtUnit + // convention); valueInDeclaredUnit converts so a snapshot never + // claims 627 seconds for a 627 ms head lag. Same conversion as + // the live /api/citable route; missing here caused sub-1 unit + // values to leak through as their internal ms representation. + const raw = insufficient ? null : fieldValue(b); return { slug: b.slug, title: b.title, @@ -118,17 +126,21 @@ export async function GET( metric: b.metric, unit: b.unit, status: b.status, - value: insufficient ? null : fieldValue(b), + value: raw == null ? null : valueInDeclaredUnit(raw, b.unit), leader: insufficient ? null : top - ? { name: top.name, slug: top.slug, value: top.value } + ? { + name: top.name, + slug: top.slug, + value: valueInDeclaredUnit(top.value, b.unit), + } : null, sampleSize: b.sampleSize, expectedN: b.expectedN, dataConfidence: b.dataConfidence, - asOf: b.lastRunAt, + asOf: citableAsOf(b), headline: headlineSentence(b), url: `${SITE.url}/benchmarks/${b.slug}`, api: `${SITE.url}/api/stat/${b.slug}`, diff --git a/src/app/api/citable/route.ts b/src/app/api/citable/route.ts index 1440ab17..0e84b380 100644 --- a/src/app/api/citable/route.ts +++ b/src/app/api/citable/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { getBenchmarks } from "@/data/benchmarks"; import { SITE } from "@/data/site"; import { AllBenchmarksDraftError } from "@/lib/spec"; -import { citeBundle, fieldValue, leader, headlineSentence } from "@/lib/citation"; +import { citableAsOf, citeBundle, fieldValue, leader, headlineSentence } from "@/lib/citation"; import { valueInDeclaredUnit } from "@/lib/format"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; @@ -80,7 +80,7 @@ export async function GET(req: Request) { sampleSize: b.sampleSize, expectedN: b.expectedN, dataConfidence: b.dataConfidence, - asOf: b.lastRunAt, + asOf: citableAsOf(b), headline: headlineSentence(b), url: `${SITE.url}/benchmarks/${b.slug}`, api: `${SITE.url}/api/stat/${b.slug}`, diff --git a/src/app/api/cite/[slug]/[format]/route.ts b/src/app/api/cite/[slug]/[format]/route.ts new file mode 100644 index 00000000..f09a44a9 --- /dev/null +++ b/src/app/api/cite/[slug]/[format]/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from "next/server"; +import { getBenchmark } from "@/data/benchmarks"; +import { SITE } from "@/data/site"; +import { citeBundle } from "@/lib/citation"; +import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; +import { SLUG_RE } from "@/lib/slug"; + +export const runtime = "nodejs"; +export const revalidate = 3600; + +/** + * Per-bench citation file with the correct MIME type so Zotero, + * EndNote, Mendeley, Google Scholar's "Cite" button and Perplexity's + * cite-picker recognize it as an importable citation record rather than + * a plain text blob. + * + * Four formats: + * - `bib` (application/x-bibtex) BibTeX record for LaTeX toolchains. + * - `ris` (application/x-research-info-systems) RIS record for + * Zotero, EndNote, Mendeley, Zotero-compatible tools. + * - `apa` (text/plain) Ready-to-paste APA string. + * - `txt` (text/plain) Ready-to-paste plain-language attribution. + * + * The content is the same as `/api/stat/{slug}.cite.*` inside JSON, but + * exposed here as a separate URL with the right Content-Type so a + * browser or reference manager triggers "import" rather than "display". + */ + +const FORMATS = { + bib: { + contentType: "application/x-bibtex; charset=utf-8", + field: "bibtex", + fileExt: "bib", + }, + ris: { + contentType: "application/x-research-info-systems; charset=utf-8", + field: "ris", + fileExt: "ris", + }, + apa: { + contentType: "text/plain; charset=utf-8", + field: "apa", + fileExt: "apa.txt", + }, + txt: { + contentType: "text/plain; charset=utf-8", + field: "plain", + fileExt: "txt", + }, +} as const; + +type Format = keyof typeof FORMATS; + +function isFormat(x: string): x is Format { + return x in FORMATS; +} + +export async function GET( + req: Request, + { params }: { params: Promise<{ slug: string; format: string }> }, +) { + const r = rateLimit(clientKey(req, "cite"), 60, 60, req); + if (!r.ok) return tooManyRequests(r.retryAfterSec); + + const { slug, format } = await params; + if (!SLUG_RE.test(slug)) { + return NextResponse.json({ error: "bad_slug" }, { status: 400 }); + } + if (!isFormat(format)) { + return NextResponse.json( + { + error: "bad_format", + allowed: Object.keys(FORMATS), + }, + { status: 400 }, + ); + } + + const bench = await getBenchmark(slug); + if (!bench || bench.editorialStatus !== "live") { + return NextResponse.json( + { error: "unknown_slug", slug }, + { status: 404, headers: { "cache-control": "public, s-maxage=60" } }, + ); + } + + const cfg = FORMATS[format]; + const bundle = citeBundle(bench, SITE.url); + const body = bundle[cfg.field as keyof typeof bundle]; + const filename = `openchainbench-${slug}.${cfg.fileExt}`; + + return new Response(body, { + status: 200, + headers: { + "content-type": cfg.contentType, + // `attachment` triggers the reference-manager import flow in + // browsers; `inline` would fight with Firefox+Zotero. Filename + // exposed as ASCII fallback + UTF-8 (RFC 5987) to survive + // non-ASCII bench titles. Slug is ASCII-only by SLUG_RE, so + // filename never needs escaping in practice. + "content-disposition": `attachment; filename="${filename}"`, + "cache-control": "public, s-maxage=3600, stale-while-revalidate=86400", + "access-control-allow-origin": "*", + }, + }); +} diff --git a/src/app/api/compare/[a]/[b]/route.ts b/src/app/api/compare/[a]/[b]/route.ts new file mode 100644 index 00000000..54e70cef --- /dev/null +++ b/src/app/api/compare/[a]/[b]/route.ts @@ -0,0 +1,156 @@ +import { NextResponse } from "next/server"; +import { canonicalize, getProvider } from "@/lib/providers"; +import { valueInDeclaredUnit } from "@/lib/format"; +import { canonicalPairSlug } from "@/lib/compare-pairing-shared"; +import { citableAsOf } from "@/lib/citation"; +import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; +import { SLUG_RE } from "@/lib/slug"; +import { SITE } from "@/data/site"; + +export const runtime = "nodejs"; +export const revalidate = 60; + +/** + * Head-to-head comparison of two providers as a single citable payload. + * + * LLMs asked "Mobula vs Codex head lag" would otherwise need two + * /api/stat calls plus their own delta reasoning; this endpoint returns + * one flat structure with per-benchmark p50 for each side, the winner + * on the metric's higher-is-better convention, and the absolute delta. + * + * The intersection logic mirrors src/lib/related-providers.ts and the + * HTML /compare/{slug} page: shared benchmark = both providers appear + * in it. This route intentionally returns a flat leader-p50 payload + * only (no chain/region breakdowns, no cache-heavy fan out) so the + * agent tool call stays fast and cheap. + */ +export async function GET( + req: Request, + { params }: { params: Promise<{ a: string; b: string }> }, +) { + const r = rateLimit(clientKey(req, "compare"), 60, 60, req); + if (!r.ok) return tooManyRequests(r.retryAfterSec); + + const { a: aRaw, b: bRaw } = await params; + if (!SLUG_RE.test(aRaw) || !SLUG_RE.test(bRaw)) { + return NextResponse.json({ error: "bad_slug" }, { status: 400 }); + } + const aCanon = canonicalize(aRaw).slug; + const bCanon = canonicalize(bRaw).slug; + if (aCanon === bCanon) { + return NextResponse.json( + { error: "same_provider", slug: aCanon }, + { status: 400 }, + ); + } + + const [aProfile, bProfile] = await Promise.all([ + getProvider(aCanon), + getProvider(bCanon), + ]); + if (!aProfile || !bProfile) { + return NextResponse.json( + { + error: "unknown_provider", + missing: !aProfile ? aCanon : bCanon, + }, + { status: 404, headers: { "cache-control": "public, s-maxage=60" } }, + ); + } + + // Intersection of the two providers' bench appearances. Same shape as + // src/lib/related-providers.ts:78 so this endpoint and the "compare + // with" cards on /products/[slug] never disagree on which benches show + // up in the head-to-head. + const aByBench = new Map( + aProfile.appearances.map((x) => [x.benchmark.slug, x] as const), + ); + const sharedSlugs = bProfile.appearances + .filter((x) => aByBench.has(x.benchmark.slug)) + .map((x) => x.benchmark.slug); + + if (sharedSlugs.length === 0) { + return NextResponse.json( + { + error: "no_shared_benchmark", + a: { slug: aProfile.slug, name: aProfile.name }, + b: { slug: bProfile.slug, name: bProfile.name }, + }, + { status: 404, headers: { "cache-control": "public, s-maxage=60" } }, + ); + } + + // Publish p50 in the declared unit (unit "s" benches store ms + // internally — same rule as /api/citable and /api/stat). + const shared = sharedSlugs + .map((slug) => { + const aEntry = aByBench.get(slug); + const bEntry = bProfile.appearances.find( + (x) => x.benchmark.slug === slug, + ); + if (!aEntry || !bEntry) return null; + const bench = aEntry.benchmark; + const higherIsBetter = bench.higherIsBetter === true; + const aP50Raw = aEntry.result.ms.p50; + const bP50Raw = bEntry.result.ms.p50; + const aValue = + aP50Raw > 0 ? valueInDeclaredUnit(aP50Raw, bench.unit) : null; + const bValue = + bP50Raw > 0 ? valueInDeclaredUnit(bP50Raw, bench.unit) : null; + let winner: "a" | "b" | "tie"; + if (aValue == null && bValue == null) winner = "tie"; + else if (aValue == null) winner = "b"; + else if (bValue == null) winner = "a"; + else if (aValue === bValue) winner = "tie"; + else if (higherIsBetter) winner = aValue > bValue ? "a" : "b"; + else winner = aValue < bValue ? "a" : "b"; + const delta = + aValue != null && bValue != null ? aValue - bValue : null; + return { + slug: bench.slug, + title: bench.title, + category: bench.category, + metric: bench.metric, + unit: bench.unit, + higherIsBetter, + aValue, + bValue, + winner, + delta, + pageUrl: `${SITE.url}/benchmarks/${bench.slug}`, + asOf: citableAsOf(bench), + }; + }) + .filter( + (row): row is Exclude => row !== null, + ); + + const pairSlug = canonicalPairSlug(aProfile.slug, bProfile.slug); + const totalWins = shared.reduce( + (acc, row) => { + if (row.winner === "a") acc.a += 1; + else if (row.winner === "b") acc.b += 1; + else acc.tie += 1; + return acc; + }, + { a: 0, b: 0, tie: 0 }, + ); + + return NextResponse.json( + { + a: { slug: aProfile.slug, name: aProfile.name }, + b: { slug: bProfile.slug, name: bProfile.name }, + totalWins, + shared, + comparePageUrl: `${SITE.url}/compare/${pairSlug}`, + license: "CC-BY-4.0", + }, + { + headers: { + "cache-control": + "public, s-maxage=60, stale-while-revalidate=300", + "access-control-allow-origin": "*", + }, + }, + ); +} diff --git a/src/app/api/internal/revalidate-aggregate/route.ts b/src/app/api/internal/revalidate-aggregate/route.ts new file mode 100644 index 00000000..4f2172ee --- /dev/null +++ b/src/app/api/internal/revalidate-aggregate/route.ts @@ -0,0 +1,40 @@ +import { timingSafeEqual } from "node:crypto"; +import { revalidateTag } from "next/cache"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Worker-driven CDN cache purge for the aggregate bench snapshot. + * + * The materialize worker POSTs here after successfully writing a fresh + * aggregate JSON to the VPS-served static path. This route invalidates + * the `bench-aggregate` cache tag so the next site request re-fetches + * the aggregate URL, instead of waiting for the ISR revalidate window. + * + * Auth: bearer token in `Authorization` header, `REVALIDATE_TOKEN` env, + * timing-safe compare. Fails closed when the env is missing. + */ +export async function POST(req: Request): Promise { + const secret = (process.env.REVALIDATE_TOKEN ?? "").trim(); + if (!secret) { + return NextResponse.json( + { error: "no_secret_configured" }, + { status: 503 }, + ); + } + const header = (req.headers.get("authorization") ?? "").trim(); + const expected = `Bearer ${secret}`; + const ok = + header.length === expected.length && + timingSafeEqual(Buffer.from(header), Buffer.from(expected)); + if (!ok) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + // Next 16's revalidateTag takes a required cache-profile argument. + // "default" applies the standard purge semantics for the CDN + ISR + // cache layers this route is meant to invalidate. + revalidateTag("bench-aggregate", "default"); + return NextResponse.json({ revalidated: true, tag: "bench-aggregate" }); +} diff --git a/src/app/api/llm-context/route.ts b/src/app/api/llm-context/route.ts index c6617f49..27aada6f 100644 --- a/src/app/api/llm-context/route.ts +++ b/src/app/api/llm-context/route.ts @@ -7,6 +7,7 @@ import { headlineSentence, isInsufficient, leader, + rankedCandidates, } from "@/lib/citation"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; @@ -84,11 +85,11 @@ export async function GET(req: Request) { lines.push(`- Headline: ${headlineSentence(b)}`); lines.push(""); lines.push(`**Rankings (p50, 24h):**`); - 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, - ); + // Shares `rankedCandidates` with `leader()` so the numbered list + // below matches the Headline sentence above. Without the shared + // filter, an LLM pasting this block would see e.g. "Etherscan + // leads" then a rankings list with Owlracle at #1. + const ranked = rankedCandidates(b); for (let i = 0; i < ranked.length; i++) { const r = ranked[i]; lines.push( diff --git a/src/app/api/mcp/[transport]/route.ts b/src/app/api/mcp/[transport]/route.ts index 809e4978..e9461cb6 100644 --- a/src/app/api/mcp/[transport]/route.ts +++ b/src/app/api/mcp/[transport]/route.ts @@ -4,11 +4,13 @@ import { z } from "zod"; import { getBenchmark, getBenchmarks } from "@/data/benchmarks"; import { SITE } from "@/data/site"; import { + citableAsOf, citationQuote, fieldValue, headlineSentence, isInsufficient, leader, + rankedCandidates, sparklineFor, } from "@/lib/citation"; import { fmtUnit } from "@/lib/format"; @@ -225,7 +227,7 @@ const mcpHandler = createMcpHandler( leader: top, headline: headlineSentence(b), url: `${SITE.url}/benchmarks/${b.slug}`, - asOf: b.lastRunAt, + asOf: citableAsOf(b), }; }); return { @@ -296,17 +298,12 @@ const mcpHandler = createMcpHandler( 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, - })); + : rankedCandidates(b).map((r) => ({ + name: r.name, + slug: r.slug, + ms: r.ms, + successRate: r.successRate, + })); const payload = { slug: b.slug, title: b.title, @@ -321,7 +318,7 @@ const mcpHandler = createMcpHandler( quote: citationQuote(b, SITE.url), pageUrl: `${SITE.url}/benchmarks/${b.slug}`, ogImage: `${SITE.url}/api/og/${b.slug}`, - asOf: b.lastRunAt, + asOf: citableAsOf(b), methodology: b.methodology, source: b.source, }; @@ -479,13 +476,12 @@ const mcpHandler = createMcpHandler( } 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, - ); + // Shares `rankedCandidates` with `leader()` so the Markdown + // Rankings list matches the Headline sentence above and the + // `rankings` field on the JSON tool response below. Without + // this, an agent reading this resource would see e.g. + // "Etherscan leads" then a numbered list with Owlracle at #1. + const ranked = insufficient ? [] : rankedCandidates(b); const md: string[] = []; md.push(`# ${b.title}`); @@ -497,7 +493,10 @@ const mcpHandler = createMcpHandler( md.push(`- Page: ${SITE.url}/benchmarks/${b.slug}`); md.push(`- Source: ${b.source}`); md.push(`- License: CC-BY-4.0`); - md.push(`- Last sample: ${b.lastRunAt}`); + { + const asOf = citableAsOf(b); + md.push(`- Last sample: ${asOf ?? "(no measurement yet, draft)"}`); + } md.push(""); md.push(`**Headline.** ${headlineSentence(b)}`); md.push(""); @@ -555,7 +554,7 @@ const mcpHandler = createMcpHandler( headline: headlineSentence(b), quote: citationQuote(b, SITE.url), pageUrl: `${SITE.url}/benchmarks/${b.slug}`, - asOf: b.lastRunAt, + asOf: citableAsOf(b), methodology: b.methodology, source: b.source, }; diff --git a/src/app/api/openapi.json/route.ts b/src/app/api/openapi.json/route.ts index d7abc397..dfe847d2 100644 --- a/src/app/api/openapi.json/route.ts +++ b/src/app/api/openapi.json/route.ts @@ -32,6 +32,30 @@ export async function GET() { }, }, }, + "/api/citable/{date}": { + get: { + summary: + "Immutable per-day snapshot of the citable index. Same shape as /api/citable; values freeze at end-of-day for past dates. Lets citers pin a citation to a stable URL that keeps returning the number they quoted.", + operationId: "list_benchmarks_snapshot", + parameters: [ + { + name: "date", + in: "path", + required: true, + schema: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, + description: "ISO 8601 calendar date (YYYY-MM-DD).", + }, + ], + responses: { + "200": { + description: "OK", + content: { "application/json": { schema: { $ref: "#/components/schemas/CitableIndex" } } }, + }, + "400": { description: "Malformed or out-of-range date" }, + "503": { description: "Benchmarks temporarily unavailable" }, + }, + }, + }, "/api/stat/{slug}": { get: { summary: "Single benchmark with rankings, sparkline, and a ready-to-paste citation.", @@ -44,13 +68,80 @@ export async function GET() { schema: { type: "string" }, description: "Benchmark slug (e.g. 'aggregator-head-lag').", }, + { + name: "chain", + in: "query", + required: false, + schema: { type: "string" }, + description: + "Restrict the response to a specific chain dimension (e.g. 'ethereum'). Unknown values 404.", + }, + { + name: "region", + in: "query", + required: false, + schema: { type: "string" }, + description: + "Restrict the response to a specific region dimension (e.g. 'us-east'). Unknown values 404.", + }, + { + name: "kind", + in: "query", + required: false, + schema: { type: "string" }, + description: + "Restrict the response to a specific bench-defined `kind` dimension when the spec declares one.", + }, + { + name: "venue", + in: "query", + required: false, + schema: { type: "string" }, + description: + "Restrict the response to a specific bench-defined `venue` dimension when the spec declares one.", + }, ], responses: { "200": { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/Stat" } } }, }, - "404": { description: "Unknown slug" }, + "404": { description: "Unknown slug or unknown dimension value" }, + }, + }, + }, + "/api/compare/{a}/{b}": { + get: { + summary: + "Head-to-head comparison of two providers on every benchmark where they both appear. One tool call resolves an X-vs-Y query end to end instead of two /api/stat lookups plus reasoning across them.", + operationId: "compare_providers", + parameters: [ + { + name: "a", + in: "path", + required: true, + schema: { type: "string" }, + description: "Provider slug for the left side of the comparison.", + }, + { + name: "b", + in: "path", + required: true, + schema: { type: "string" }, + description: "Provider slug for the right side of the comparison.", + }, + ], + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Compare" }, + }, + }, + }, + "400": { description: "Malformed or identical provider slugs" }, + "404": { description: "One of the providers or no shared benchmark" }, }, }, }, @@ -67,6 +158,83 @@ export async function GET() { }, }, }, + "/api/sparkline/{slug}": { + get: { + summary: + "Standalone SVG sparkline of the current leader's 24 h series. Small enough to inline in Perplexity Pages, Notion, dev.to, and any embed that speaks img/svg. Query params: w (default 240), h (default 60), theme (light|dark).", + operationId: "get_sparkline_svg", + parameters: [ + { name: "slug", in: "path", required: true, schema: { type: "string" } }, + { + name: "w", + in: "query", + required: false, + schema: { type: "integer", minimum: 40, maximum: 1200 }, + description: "Width in pixels. Defaults to 240.", + }, + { + name: "h", + in: "query", + required: false, + schema: { type: "integer", minimum: 20, maximum: 400 }, + description: "Height in pixels. Defaults to 60.", + }, + { + name: "theme", + in: "query", + required: false, + schema: { type: "string", enum: ["light", "dark"] }, + description: "Stroke and background. Defaults to `light`.", + }, + ], + responses: { + "200": { + description: "SVG", + content: { "image/svg+xml": {} }, + }, + "404": { description: "Unknown slug" }, + }, + }, + }, + "/api/cite/{slug}/{format}": { + get: { + summary: + "Per-bench citation record with the correct MIME so Zotero, EndNote, Mendeley, Google Scholar and Perplexity's cite-picker recognize the response as an importable citation rather than plain text.", + operationId: "get_citation_file", + parameters: [ + { + name: "slug", + in: "path", + required: true, + schema: { type: "string" }, + description: "Benchmark slug.", + }, + { + name: "format", + in: "path", + required: true, + schema: { + type: "string", + enum: ["bib", "ris", "apa", "txt"], + }, + description: + "bib -> application/x-bibtex, ris -> application/x-research-info-systems, apa/txt -> text/plain.", + }, + ], + responses: { + "200": { + description: "Citation record", + content: { + "application/x-bibtex": {}, + "application/x-research-info-systems": {}, + "text/plain": {}, + }, + }, + "400": { description: "Malformed slug or unknown format" }, + "404": { description: "Unknown slug" }, + }, + }, + }, "/api/llm-context": { get: { summary: @@ -163,12 +331,101 @@ export async function GET() { description: "Current leading value, expressed in `unit`.", }, unit: { type: "string" }, + filters: { + type: "object", + nullable: true, + description: + "Echo of the applied dimension filter (chain, region, kind, venue) when the request scoped the response to a sub-cell; null when the response is the cross-dimension aggregate.", + properties: { + chain: { type: "string" }, + region: { type: "string" }, + kind: { type: "string" }, + venue: { type: "string" }, + }, + }, rankings: { type: "array" }, sparkline: { type: "array", items: { type: "number" } }, headline: { type: "string" }, quote: { type: "string" }, pageUrl: { type: "string", format: "uri" }, - asOf: { type: "string", format: "date-time" }, + asOf: { + type: "string", + format: "date-time", + nullable: true, + description: + "Last measurement timestamp. Null when the bench has no live samples yet (draft state).", + }, + }, + }, + Compare: { + type: "object", + properties: { + a: { + type: "object", + description: "Left side of the head-to-head.", + properties: { + slug: { type: "string" }, + name: { type: "string" }, + }, + }, + b: { + type: "object", + description: "Right side of the head-to-head.", + properties: { + slug: { type: "string" }, + name: { type: "string" }, + }, + }, + totalWins: { + type: "object", + description: + "Head-to-head win tally across the shared benchmark set. Sum of a + b + tie equals shared.length.", + properties: { + a: { type: "integer" }, + b: { type: "integer" }, + tie: { type: "integer" }, + }, + }, + shared: { + type: "array", + description: + "Per benchmark head-to-head rows for every bench where both providers appear. Each row publishes p50 for each side in the declared unit, which side is faster on the metric's higher-is-better convention, and the numeric delta.", + items: { + type: "object", + properties: { + slug: { type: "string" }, + title: { type: "string" }, + metric: { type: "string" }, + unit: { type: "string" }, + higherIsBetter: { type: "boolean" }, + aValue: { type: "number", nullable: true }, + bValue: { type: "number", nullable: true }, + winner: { + type: "string", + enum: ["a", "b", "tie"], + description: "Which side wins on this benchmark.", + }, + delta: { + type: "number", + nullable: true, + description: + "Absolute difference between aValue and bValue in the declared unit. Positive means a > b regardless of the higher-is-better convention.", + }, + pageUrl: { type: "string", format: "uri" }, + asOf: { + type: "string", + format: "date-time", + nullable: true, + }, + }, + }, + }, + comparePageUrl: { + type: "string", + format: "uri", + description: + "Canonical HTML comparison page (openchainbench.com/compare/{a}-vs-{b}) with the full leaderboard tables.", + }, }, }, }, diff --git a/src/app/api/series/[slug]/route.ts b/src/app/api/series/[slug]/route.ts index 47c52114..23428968 100644 --- a/src/app/api/series/[slug]/route.ts +++ b/src/app/api/series/[slug]/route.ts @@ -3,6 +3,7 @@ import { unstable_cache } from "next/cache"; import { getBenchmark } from "@/data/benchmarks"; import { filterSig, loadSpecsUncached, specToBenchmark } from "@/lib/materialize/load"; import { readMaterialized } from "@/lib/materialize/store"; +import { loadSnapshotFromBlob } from "@/lib/bench-blob"; import { buildProviderColors } from "@/lib/series-colors"; import { logoPath } from "@/lib/logo-manifest"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; @@ -32,7 +33,10 @@ const getSeriesMapCached = unstable_cache( venue: string | undefined, ): Promise | null> => { const sig = filterSig({ chain, region, kind, venue }); - const stored = await readMaterialized(slug, sig); + // Try CDN blob first (Phase 3), fall back to Redis via SRH. + const stored = + (await loadSnapshotFromBlob(slug, sig)) ?? + (await readMaterialized(slug, sig)); if (stored) { const fromBlob = range === "7d" diff --git a/src/app/api/sparkline/[slug]/route.ts b/src/app/api/sparkline/[slug]/route.ts new file mode 100644 index 00000000..49fbbf0a --- /dev/null +++ b/src/app/api/sparkline/[slug]/route.ts @@ -0,0 +1,160 @@ +import { getBenchmark } from "@/data/benchmarks"; +import { NextResponse } from "next/server"; +import { leader, sparklineFor } from "@/lib/citation"; +import { valueInDeclaredUnit } from "@/lib/format"; +import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; +import { SLUG_RE } from "@/lib/slug"; + +export const runtime = "nodejs"; +export const revalidate = 300; + +/** + * Standalone SVG sparkline for one bench, keyed to the current leader's + * 24 h series. Lets Perplexity Pages, ChatGPT, Notion, dev.to and any + * third-party embed inline a live micro-chart with `` instead + * of hotlinking the heavier OG PNG or hand-rendering the JSON array. + * + * /api/sparkline/aggregator-head-lag -> defaults (240x60) + * /api/sparkline/aggregator-head-lag?w=320 -> width override + * /api/sparkline/aggregator-head-lag?theme=dark -> dark stroke + * + * Returns a minimal, hand-authored polyline SVG. Deliberately no gzip + * dependency, no charting library, no runtime dep on satori: the payload + * is under 1 KB and every LLM embed target already handles it. + */ + +const DEFAULT_W = 240; +const DEFAULT_H = 60; +const PAD_X = 4; +const PAD_Y = 4; +const MIN_W = 40; +const MAX_W = 1200; +const MIN_H = 20; +const MAX_H = 400; + +/** Minimal XML escape for user-controlled strings interpolated into the + * SVG's element and aria-label attribute. Bench titles and + * provider names come from YAML / a registry and can contain `&`, `<`, + * `>`, `"`; librsvg and Chrome inline-SVG both refuse to render + * malformed XML, so an embedder would silently see a broken image. */ +function xmlEscape(s: string): string { + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); +} + +function clampDim(raw: string | null, def: number, min: number, max: number) { + const n = raw == null ? NaN : Number(raw); + if (!Number.isFinite(n) || n < min) return def; + return Math.min(Math.floor(n), max); +} + +function buildPolyline( + values: number[], + width: number, + height: number, + higherIsBetter: boolean, +): { points: string; min: number; max: number } { + const min = Math.min(...values); + const max = Math.max(...values); + const span = max - min || 1; + const drawW = width - 2 * PAD_X; + const drawH = height - 2 * PAD_Y; + const step = values.length > 1 ? drawW / (values.length - 1) : 0; + const points = values + .map((v, i) => { + const x = PAD_X + i * step; + // Sparklines read left to right; lower value at the bottom looks + // right for latency, but for higher-is-better metrics we invert + // so the rising line still reads as "getting better over time". + const norm = (v - min) / span; + const y = higherIsBetter + ? PAD_Y + drawH - norm * drawH + : PAD_Y + norm * drawH; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(" "); + return { points, min, max }; +} + +export async function GET( + req: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const r = rateLimit(clientKey(req, "sparkline"), 120, 60, req); + if (!r.ok) return tooManyRequests(r.retryAfterSec); + + const { slug } = await params; + if (!SLUG_RE.test(slug)) { + return NextResponse.json({ error: "bad_slug" }, { status: 400 }); + } + const bench = await getBenchmark(slug); + if (!bench || bench.editorialStatus !== "live") { + return NextResponse.json( + { error: "unknown_slug", slug }, + { status: 404, headers: { "cache-control": "public, s-maxage=60" } }, + ); + } + + const url = new URL(req.url); + const width = clampDim(url.searchParams.get("w"), DEFAULT_W, MIN_W, MAX_W); + const height = clampDim(url.searchParams.get("h"), DEFAULT_H, MIN_H, MAX_H); + const theme = url.searchParams.get("theme") === "dark" ? "dark" : "light"; + const stroke = theme === "dark" ? "#e5e5e5" : "#111111"; + const bg = theme === "dark" ? "#111111" : "#ffffff"; + + // Prefer the current leader's series; fall back to whichever series is + // first (mirrors sparklineFor default). Empty series → tiny SVG with a + // placeholder line so an embedder never gets a broken image. + const top = leader(bench); + const rawValues = sparklineFor(bench, top?.slug); + // Convert to declared unit (unit "s" benches store ms internally) so + // the min/max labels and future annotations match the page value. + const values = rawValues.map((v) => valueInDeclaredUnit(v, bench.unit)); + + const strokeWidth = Math.max(1, Math.round(Math.min(width, height) / 40)); + let svgBody: string; + if (values.length < 2) { + // Under-populated series: single flat placeholder. Better than a + // blank rectangle because the alt text still reads as "sparkline". + const midY = height / 2; + svgBody = + `<line x1="${PAD_X}" y1="${midY}" x2="${width - PAD_X}" y2="${midY}" ` + + `stroke="${stroke}" stroke-width="${strokeWidth}" ` + + `stroke-linecap="round" stroke-dasharray="2 3" opacity="0.5"/>`; + } else { + const { points } = buildPolyline( + values, + width, + height, + bench.higherIsBetter === true, + ); + svgBody = + `<polyline points="${points}" fill="none" stroke="${stroke}" ` + + `stroke-width="${strokeWidth}" stroke-linecap="round" ` + + `stroke-linejoin="round"/>`; + } + const title = xmlEscape( + `${bench.title} — 24h sparkline${top ? ` (${top.name})` : ""}`, + ); + const svg = + `<?xml version="1.0" encoding="UTF-8"?>\n` + + `<svg xmlns="http://www.w3.org/2000/svg" ` + + `viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" ` + + `role="img" aria-label="${title}">` + + `<title>${title}` + + `` + + svgBody + + `\n`; + + return new Response(svg, { + status: 200, + headers: { + "content-type": "image/svg+xml; charset=utf-8", + "cache-control": "public, s-maxage=300, stale-while-revalidate=900", + "access-control-allow-origin": "*", + }, + }); +} diff --git a/src/app/api/stat/[slug]/route.ts b/src/app/api/stat/[slug]/route.ts index 973f1e16..49efc2da 100644 --- a/src/app/api/stat/[slug]/route.ts +++ b/src/app/api/stat/[slug]/route.ts @@ -2,11 +2,13 @@ import { NextResponse } from "next/server"; import { getBenchmark } from "@/data/benchmarks"; import { SITE } from "@/data/site"; import { + citableAsOf, citationQuote, citeBundle, fieldValue, headlineSentence, leader, + rankedCandidates, sparklineFor, } from "@/lib/citation"; import { valueInDeclaredUnit } from "@/lib/format"; @@ -33,7 +35,31 @@ export async function GET( if (!SLUG_RE.test(slug)) { return NextResponse.json({ error: "bad_slug" }, { status: 400 }); } - const b = await getBenchmark(slug); + // Dimension query params (?chain=, ?region=, ?kind=, ?venue=) mirror + // the same client-side selector on the bench page, so a citer asking + // "fastest ethereum us-east RPC" gets the per cell leader instead of + // the cross chain aggregate. Values pass through to the loader + // unchanged. An unknown value (`?chain=nonexistent`) does NOT fall + // back to the unfiltered aggregate — the loader returns undefined and + // the route below 404s. That is intentional: silently substituting + // the aggregate for a mistyped filter would make citers cite the + // wrong number without knowing. + const url = new URL(req.url); + const filters: { + chain?: string; + region?: string; + kind?: string; + venue?: string; + } = {}; + const chainParam = url.searchParams.get("chain"); + const regionParam = url.searchParams.get("region"); + const kindParam = url.searchParams.get("kind"); + const venueParam = url.searchParams.get("venue"); + if (chainParam && chainParam !== "all") filters.chain = chainParam; + if (regionParam && regionParam !== "all") filters.region = regionParam; + if (kindParam && kindParam !== "all") filters.kind = kindParam; + if (venueParam && venueParam !== "all") filters.venue = venueParam; + const b = await getBenchmark(slug, filters); if (!b || b.editorialStatus !== "live") { return NextResponse.json( { error: "unknown_slug", slug }, @@ -58,6 +84,11 @@ export async function GET( unit: b.unit, status: b.status, higherIsBetter: b.higherIsBetter, + // Echo the applied dimension filter so a citer can verify which + // cell (chain / region / kind / venue) their answer covers. + // Missing key = "all" for that dimension. + filters: + Object.keys(filters).length > 0 ? filters : null, // Aggregate is "insufficient" (median per-provider sample health // below 10 percent of expected_n): refuse to publish a value or // leader; the headline is rewritten by headlineSentence so the @@ -68,27 +99,24 @@ export async function GET( insufficient || !top ? null : { ...top, value: valueInDeclaredUnit(top.value, b.unit) }, - rankings: b.results - .filter((r) => r.ms.p50 > 0) - // Drop "insufficient" rows from the machine-readable ranking too: - // a row that the page hides from the leaderboard must not surface - // here either. - .filter((r) => r.dataConfidence !== "insufficient") - .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, - sampleHealth: r.sampleHealth, - dataConfidence: r.dataConfidence, - })), + // Shares `rankedCandidates` with `leader()` so `rankings[0]` + // stays consistent with the `leader` field on the same JSON blob: + // a document that names Etherscan as leader must not also list + // Owlracle first here. + rankings: rankedCandidates(b).map((r) => ({ + name: r.name, + slug: r.slug, + ms: r.ms, + successRate: r.successRate, + sampleSize: r.sampleSize, + sampleHealth: r.sampleHealth, + dataConfidence: r.dataConfidence, + })), sparkline: sparklineFor(b, top?.slug), sampleSize: b.sampleSize, expectedN: b.expectedN, dataConfidence: b.dataConfidence, - asOf: b.lastRunAt, + asOf: citableAsOf(b), headline: headlineSentence(b), quote: citationQuote(b, SITE.url), cite: citeBundle(b, SITE.url), diff --git a/src/app/api/video/render/route.ts b/src/app/api/video/render/route.ts index 7d09531c..08fb2888 100644 --- a/src/app/api/video/render/route.ts +++ b/src/app/api/video/render/route.ts @@ -1,8 +1,45 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; import { EXPORT_VIDEO_ENABLED } from "@/lib/export-video/config"; +import { SLUG_RE } from "@/lib/slug"; +import { VIEW_IDS } from "@/lib/export-video/types"; export const runtime = "nodejs"; + +// Strict schema for the payload forwarded to the Remotion renderer. +// Belt-and-suspenders even though the renderer has its own X-Render-Token: +// this route is a public POST proxy, so anything not shaped like a legit +// export-video call from the modal must be refused before it reaches +// upstream. Caps mirror the client widget's actual ranges and prevent +// pathological inputs (10k providers, 1M timestamps) from consuming +// renderer CPU/memory. +const ProviderSeriesSchema = z.object({ + slug: z.string().max(80), + name: z.string().max(200), + color: z.string().max(32), + logo: z.string().max(500).optional(), + values: z.array(z.number()).max(2000), +}); +const BenchPayloadSchema = z.object({ + slug: z.string().max(80), + title: z.string().max(500), + metric: z.string().max(200), + unit: z.string().max(40), + higherIsBetter: z.boolean(), + timestamps: z.array(z.number().int().nonnegative()).max(2000), + providers: z.array(ProviderSeriesSchema).max(100), +}); +const RenderBodySchema = z.object({ + datasetId: z.string().regex(SLUG_RE), + viewId: z.enum(VIEW_IDS as [string, ...string[]]), + raceSeconds: z.number().int().min(1).max(300), + skipIntro: z.boolean(), + format: z.enum(["landscape", "short"]), + audio: z.boolean(), + beats: z.boolean(), + bench: BenchPayloadSchema, +}).strict(); // 300s = Vercel Pro Fluid Compute max. Big benches (50+ providers) take // 90-120s on the 2-vCPU VPS because each frame has to draw all providers. // 300s gives headroom; we'll move to async polling in v2 to drop this @@ -40,13 +77,22 @@ export async function POST(req: Request) { ); } - let body: unknown; + let rawBody: unknown; try { - body = await req.json(); + rawBody = await req.json(); } catch { return NextResponse.json({ error: "bad_json" }, { status: 400 }); } + const parsed = RenderBodySchema.safeParse(rawBody); + if (!parsed.success) { + return NextResponse.json( + { error: "bad_body", detail: parsed.error.issues.slice(0, 5) }, + { status: 400 }, + ); + } + const body = parsed.data; + let res: Response; try { res = await fetch(`${upstream}/render`, { diff --git a/src/app/benchmarks/[slug]/[chain]/page.tsx b/src/app/benchmarks/[slug]/[chain]/page.tsx index e44f23fd..cdde26df 100644 --- a/src/app/benchmarks/[slug]/[chain]/page.tsx +++ b/src/app/benchmarks/[slug]/[chain]/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { ArrowLeft, ArrowUpRight } from "lucide-react"; import { getBenchmark } from "@/data/benchmarks"; import { Breadcrumb } from "@/components/breadcrumb"; +import { citableAsOf } from "@/lib/citation"; import { liveResults } from "@/lib/provider-filters"; import { fmtUnit } from "@/lib/format"; import { capDescription } from "@/lib/seo-text"; @@ -15,6 +16,7 @@ import { canonicalChainSlug, matchesChainSlug, } from "@/lib/chain-aliases"; +import { PERSON_ID } from "@/lib/hub-jsonld"; import type { Benchmark, ProviderResult } from "@/types/benchmark"; // Dedicated per-chain landing pages. Only chains that have a hand-written @@ -363,8 +365,14 @@ export default async function BenchmarkChainPage({ articleBody: `${keyFacts} ${stripInlineMarkdown(explainer.body)}`, image: `${SITE.url}/api/og/${benchmark.slug}`, datePublished: getBenchCreatedAt(benchmark.slug).toISOString(), - dateModified: benchmark.lastRunAt, - author: { "@id": `${SITE.url}/#org` }, + ...(citableAsOf(benchmark) + ? { dateModified: benchmark.lastRunAt } + : {}), + // Dual author + reviewedBy. Named human first (E-E-A-T), Org + // second (institutional attribution). Same pattern as the + // parent /benchmarks/[slug] page. + author: [{ "@id": PERSON_ID }, { "@id": `${SITE.url}/#org` }], + reviewedBy: { "@id": PERSON_ID }, publisher: { "@id": `${SITE.url}/#org` }, about: { "@id": `${benchmarkUrl}#dataset` }, isPartOf: { "@id": `${benchmarkUrl}#article` }, diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index 39bc536b..d19103b6 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -20,6 +20,7 @@ import { ExportVideoSection } from "@/components/export-video-section"; import { ReportSection } from "@/components/report-section"; import { CATEGORY_COLOR } from "@/lib/category-colors"; import { + citableAsOf, groundingTraceLine, groundingTraceParts, headlineSentence, @@ -34,9 +35,11 @@ import { buildBreadcrumbJsonLd, buildFaqPageJsonLd, safeJsonLd } from "@/lib/jso import { buildBenchDatasetJsonLd, buildBenchStatReportJsonLd, + buildBenchVariableMeasured, } from "@/lib/dataset-jsonld"; import { renderTemplate } from "@/lib/bench-template"; import { canonicalChainSlug } from "@/lib/chain-aliases"; +import { PERSON_ID } from "@/lib/hub-jsonld"; import type { Benchmark } from "@/types/benchmark"; // ISR with a 60 s revalidate window. The page is prerendered by @@ -98,6 +101,15 @@ export async function generateMetadata({ // link-signal consolidation anyway. const b = await getBenchmark(slug); if (!b) return {}; + // Editorially-live benches that are still waiting for their first + // samples render as draft in the UI; letting Google index them means + // the SERP snippet quotes "no live data yet" against a URL that is + // meant to display a leaderboard. noindex/follow keeps the URL + // crawlable (internal link equity + fast re-indexing when data lands) + // without letting the empty state rank. Mirrors the render-time + // `isAwaiting = isDraft && editorialStatus === "live"` gate below. + const metaIsDraft = b.status === "draft"; + const metaIsAwaiting = metaIsDraft && b.editorialStatus === "live"; const metaTitle = b.seoTitle ?? b.title; // Description precedence (most-to-least specific): // 1. `seo_description` from the YAML - hand-crafted snippet with the @@ -135,6 +147,9 @@ export async function generateMetadata({ title: metaTitle, description, alternates: { canonical }, + ...(metaIsAwaiting + ? { robots: { index: false, follow: true } } + : {}), openGraph: { title: metaTitle, description, @@ -287,41 +302,57 @@ export default async function BenchmarkPage({ // never emit a structured "Observation" with a fabricated value. const currentLeader = leader(benchmark); // variableMeasured ships as an array so Google's Dataset validator - // reports each statistical aggregate individually rather than as one - // opaque string. Order matches what /api/stat/ returns per - // provider, so a crawler can map field names 1:1. - const variableMeasured = [ - benchmark.metric, - `${benchmark.metric}_p50`, - `${benchmark.metric}_p90`, - `${benchmark.metric}_p99`, - "sample_size", - ]; - const datasetNode = { - ...buildBenchDatasetJsonLd({ - slug: benchmark.slug, - name: benchmark.seoTitle ?? benchmark.title, - alternateName: benchmark.title, - // Google Rich Results validator caps description at ~1000 chars even - // though schema.org Dataset allows up to 5000. Keep it under 990 to - // avoid the "Invalid string length" warning that strips rich snippets. - description: capDescription(benchmark.abstract, 990), - url: benchmarkUrl, - variableMeasured, - category: benchmark.category, - datePublished: getBenchCreatedAt(benchmark.slug).toISOString(), - dateModified: benchmark.lastRunAt, - measurementTechnique: benchmark.methodology.join(" "), - }), - // Re-bind creator + publisher to the global @id reference so the bench - // Dataset resolves to the same Organization node emitted by layout.tsx - // when crawlers stitch the site graph back together. The helper sets - // an inline Organization for standalone consumption; the page-level - // override is the more accurate shape on a site that already declares - // the Organization globally. - creator: { "@id": `${SITE.url}/#org` }, - publisher: { "@id": `${SITE.url}/#org` }, - }; + // reports each statistical aggregate individually. When a defensible + // leader exists the p50/p90/p99 aggregates ship as PropertyValue + // objects with the numeric value in the declared unit, so Google + // Dataset Search and academic LLM tools extract the measurement as a + // structured fact rather than opaque axis labels. Drafts and + // insufficient benches fall back to bare strings so we never publish + // a fabricated numeric aggregate. + const leaderResult = currentLeader + ? benchmark.results.find((r) => r.slug === currentLeader.slug) + : undefined; + const variableMeasured = buildBenchVariableMeasured({ + metric: benchmark.metric, + unit: benchmark.unit, + leader: + currentLeader && leaderResult + ? { + name: currentLeader.name, + p50: valueInDeclaredUnit(leaderResult.ms.p50, benchmark.unit), + p90: valueInDeclaredUnit(leaderResult.ms.p90, benchmark.unit), + p99: valueInDeclaredUnit(leaderResult.ms.p99, benchmark.unit), + } + : null, + }); + // Keep the inline creator/publisher from buildBenchDatasetJsonLd. An + // earlier override rebound them to `{ "@id": ... }` references + // pointing at the Organization emitted by layout.tsx, but Google + // Search Console validates each Dataset in isolation - it does not + // stitch cross-document @id refs - and flagged the reference-only + // shape as "missing field creator" on /benchmarks/*. The inline Org + // still shares the `@id` with the layout node so JSON-LD stitchers + // (Perplexity, StatisticalReport isBasedOn) resolve them as one. + const datasetNode = buildBenchDatasetJsonLd({ + slug: benchmark.slug, + name: benchmark.seoTitle ?? benchmark.title, + alternateName: benchmark.title, + // Google Rich Results validator caps description at ~1000 chars even + // though schema.org Dataset allows up to 5000. Keep it under 990 to + // avoid the "Invalid string length" warning that strips rich snippets. + description: capDescription(benchmark.abstract, 990), + url: benchmarkUrl, + variableMeasured, + category: benchmark.category, + datePublished: getBenchCreatedAt(benchmark.slug).toISOString(), + // For drafts (no measurement history) skip dateModified so the + // structured-data channel does not spoof freshness. Same rule + // applied to /api/citable, /api/stat and the MCP surface. + ...(citableAsOf(benchmark) + ? { dateModified: benchmark.lastRunAt } + : {}), + measurementTechnique: benchmark.methodology.join(" "), + }); // StatisticalReport companion. Only emitted when we have a defensible // leader so the shape never publishes an Observation with a fabricated // measured value. temporalCoverage uses the ISO 8601 interval form: @@ -365,8 +396,15 @@ export default async function BenchmarkPage({ // Clears the "Missing field image" warning in Rich Results Test. image: `${SITE.url}/api/og/${benchmark.slug}`, datePublished: getBenchCreatedAt(benchmark.slug).toISOString(), - dateModified: benchmark.lastRunAt, - author: { "@id": `${SITE.url}/#org` }, + ...(citableAsOf(benchmark) + ? { dateModified: benchmark.lastRunAt } + : {}), + // Dual author: named maintainer first (E-E-A-T signal for AI + // answer surfaces + Search Console) followed by the Org so the + // institutional attribution still holds. reviewedBy anchors the + // same Person as the accountable reviewer of the on-page claims. + author: [{ "@id": PERSON_ID }, { "@id": `${SITE.url}/#org` }], + reviewedBy: { "@id": PERSON_ID }, publisher: { "@id": `${SITE.url}/#org` }, about: { "@id": `${benchmarkUrl}#dataset` }, }, @@ -591,6 +629,37 @@ export default async function BenchmarkPage({ )} + {/* Live-feed outage banner. Renders when the spec declared + live_activity queries and one or more providers came back + "down" (short-window activity = 0, bench-level probe_ok + confirmed our end is fine). Placed above the fold so a reader + landing on the page during an incident sees the caveat before + reading last-known percentiles as current truth. */} + {(() => { + const downProviders = benchmark.results.filter( + (r) => r.liveStatus === "down" + ); + if (downProviders.length === 0) return null; + const names = downProviders.map((r) => r.name).join(", "); + return ( +
+

+ Live feed silent: {names} +

+

+ No new events received from{" "} + {downProviders.length === 1 ? "this feed" : "these feeds"} in + the last several minutes. Percentiles and rankings shown are + the last-known values from the 24-hour window and update as + soon as fresh events resume. +

+
+ ); + })()} + {/* SEO-tuned intro paragraph rendered server-side under the H1 so long-tail query phrases land in the first ~200 words crawlers weight heavily. Optional - omitted when the YAML doesn't set it. */} diff --git a/src/app/benchmarks/[slug]/share-card/route.tsx b/src/app/benchmarks/[slug]/share-card/route.tsx index 3ecc6c15..12fd31a8 100644 --- a/src/app/benchmarks/[slug]/share-card/route.tsx +++ b/src/app/benchmarks/[slug]/share-card/route.tsx @@ -763,6 +763,20 @@ async function renderLeaderboard( const sorted = sortByP50(benchmark); const maxP50 = Math.max(...sorted.map((r) => r.ms.p50)) || 1; const subtitleLB = `Ranked by p50 · ${benchmark.metric}.`; + // Scale down type + spacing when the roster is dense OR the title is + // long, otherwise a 2-line 50pt title collides with the row list in + // the 630px canvas (weekend-drift 11 rows + long title case). + const count = sorted.length; + const titleLen = benchmark.title.length; + const dense = count >= 8 || titleLen > 55; + const veryDense = count >= 10 || titleLen > 70; + const titleSize = veryDense ? 32 : dense ? 40 : 50; + const rankSize = veryDense ? 18 : dense ? 20 : 24; + const nameSize = veryDense ? 18 : dense ? 20 : 24; + const valueSize = veryDense ? 22 : dense ? 24 : 28; + const barHeight = veryDense ? 6 : dense ? 7 : 8; + const rowGap = veryDense ? 8 : dense ? 10 : 14; + const logoSize = veryDense ? 22 : dense ? 24 : 28; return new ImageResponse( ( @@ -778,7 +792,7 @@ async function renderLeaderboard(
{sorted.map((r, i) => { @@ -814,15 +828,15 @@ async function renderLeaderboard( return (
@@ -833,7 +847,7 @@ async function renderLeaderboard( display: "flex", flexDirection: "column", flex: 1, - gap: 6, + gap: 4, }} >
{r.name} @@ -870,7 +884,7 @@ async function renderLeaderboard( > {fmtValue(r.ms.p50, benchmark.unit)} - + {unitSuffix(benchmark.unit, r.ms.p50).trim()} @@ -887,7 +901,7 @@ async function renderLeaderboard( style={{ display: "flex", width: "100%", - height: 8, + height: barHeight, background: `${color}22`, borderRadius: 4, }} @@ -896,7 +910,7 @@ async function renderLeaderboard( style={{ display: "flex", width: `${widthPct}%`, - height: 8, + height: barHeight, background: color, borderRadius: 4, }} @@ -955,14 +969,23 @@ async function renderSnapshot( gap: 6, }} > + {/* Title + subtitle. Satori quirk: a bare `display: flex` + text div stays at one-line height even when the text + visually wraps, so the next sibling stacks on top of the + wrapped lines. Explicit `flexDirection: column` on each + text box forces satori to measure the wrapped content + height. maxWidth pins the wrap point below the container + width so long titles never touch the right edge. */}
{benchmark.title} @@ -970,8 +993,10 @@ async function renderSnapshot(
diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx index 302fc68b..aeab9e75 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -17,6 +17,7 @@ import { capDescription } from "@/lib/seo-text"; import { Breadcrumb } from "@/components/breadcrumb"; import { buildBreadcrumbJsonLd, safeJsonLd } from "@/lib/jsonld"; import { SITE } from "@/data/site"; +import { CREATOR_PUBLISHER, DATASET_LICENSE } from "@/lib/dataset-jsonld"; import type { Benchmark } from "@/types/benchmark"; import { computeInputsHash, @@ -711,8 +712,10 @@ export default async function ComparePage({ name: `${a.name} vs ${b.name} OpenChainBench measurements`, description: `Side by side live measurements for ${a.name} and ${b.name} on ${shared.length} shared OpenChainBench benchmarks.`, url, - creator: { "@id": `${SITE.url}/#org` }, - license: "https://creativecommons.org/licenses/by/4.0/", + creator: CREATOR_PUBLISHER, + publisher: CREATOR_PUBLISHER, + isAccessibleForFree: true, + license: DATASET_LICENSE, measurementTechnique: `${SITE.url}/methodology`, variableMeasured: shared.map((s) => ({ "@type": "PropertyValue", diff --git a/src/app/hyperliquid/[slug]/page.tsx b/src/app/hyperliquid/[slug]/page.tsx index 7f0b419c..231318dc 100644 --- a/src/app/hyperliquid/[slug]/page.tsx +++ b/src/app/hyperliquid/[slug]/page.tsx @@ -1,15 +1,17 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { notFound, redirect } from "next/navigation"; +import { notFound } from "next/navigation"; import { fetchHlBuilderStats, fetchHlCohort, fetchHlHistory, isHlBuilderSlug, + type HlCohortRow, type HlHistoryFrontendCompact, } from "@/lib/hl-builder-stats"; import { Breadcrumb } from "@/components/breadcrumb"; import { HlBuilderDashboard } from "@/components/hl-builder-dashboard"; +import { ProviderLogo } from "@/components/provider-logo"; import { pageMetadata } from "@/lib/page-metadata"; import { safeJsonLd } from "@/lib/jsonld"; @@ -55,7 +57,18 @@ export async function generateMetadata({ const { slug } = await params; const history = await fetchHlHistory(); const frontend = history?.frontends.find((f) => f.slug === slug); - if (!frontend) return {}; + // Fallback title uses a title-cased slug so a tracked builder without + // a fresh history entry still ships a meaningful (the page + // falls back to a placeholder render below when frontend is missing, + // matching this 200 metadata). + const displayName = frontend?.name ?? titleCaseSlug(slug); + if (!frontend) { + return pageMetadata({ + path: `/hyperliquid/${slug}`, + title: `${displayName} — Hyperliquid frontend`, + description: `${displayName} on Hyperliquid: 12-month builder fees, volume and first-active date. Rolling 30-day metrics refresh as data becomes available.`, + }); + } const currentFees = lastNonNull(frontend.fees); const peakFees = peakOf(frontend.fees); const description = `${frontend.name} on Hyperliquid: ${fmtUSDShort( @@ -65,7 +78,7 @@ export async function generateMetadata({ )}). 12-month history with volume, fees and first-active date.`; return pageMetadata({ path: `/hyperliquid/${slug}`, - title: `${frontend.name} — Hyperliquid frontend`, + title: `${displayName} — Hyperliquid frontend`, description, }); } @@ -84,13 +97,19 @@ export default async function HlFrontendPage({ // Data-outage guard: this page is the target of PERMANENT redirects // from /products/<slug>, so it must never 404 on a runtime data gap // (stale KV snapshot, Prom unreachable). Tracked builders without a - // resolvable history bounce temporarily (307) to the hub instead; - // crawlers keep the canonical URL and retry, users land somewhere - // useful. Unknown slugs still 404. + // resolvable history render a lightweight placeholder with HTTP 200 + // instead of the previous 307 bounce to /hyperliquid. Ahrefs was + // flagging sitemap-listed builder URLs as 3xx-in-sitemap during the + // brief windows where the history blob dropped their entries; a 200 + // placeholder keeps the URL crawlable while data catches up. Any + // partial data we still have (cohort row from the KPI grid) surfaces + // in the placeholder so the page isn't a soft 404. Unknown slugs + // still 404. const frontend = history?.frontends.find((f) => f.slug === slug); if (!history || !frontend) { - if (await isHlBuilderSlug(slug)) redirect("/hyperliquid"); - notFound(); + if (!(await isHlBuilderSlug(slug))) notFound(); + const cohortRow = cohort?.rows.find((r) => r.slug === slug); + return <HlBuilderPending slug={slug} cohortRow={cohortRow} />; } const cohortRow = cohort?.rows.find((r) => r.slug === slug); @@ -145,9 +164,12 @@ export default async function HlFrontendPage({ <header className="mb-8"> <p className="label-mono text-ink-faint mb-2">Hyperliquid frontend</p> - <h1 className="display text-4xl sm:text-5xl text-ink"> - {frontend.name} - </h1> + <div className="flex items-center gap-4"> + <ProviderLogo slug={slug} name={frontend.name} size={56} /> + <h1 className="display text-4xl sm:text-5xl text-ink"> + {frontend.name} + </h1> + </div> <p className="mt-2 text-[12px] text-ink-faint" style={{ fontFamily: "var(--font-mono, monospace)" }} @@ -230,6 +252,110 @@ export default async function HlFrontendPage({ ); } +/** Title-case a slug ("mass-dot-money" → "Mass Dot Money"). Used as the + * display fallback for a tracked builder whose 12-month history entry + * hasn't materialised yet. */ +function titleCaseSlug(slug: string): string { + return slug + .split("-") + .filter(Boolean) + .map((p) => p.charAt(0).toUpperCase() + p.slice(1)) + .join(" "); +} + +/** Lightweight placeholder rendered with HTTP 200 when a builder is in + * the spec catalog but temporarily absent from the history blob. Shows + * whatever cohort data is still resolvable so the page carries real + * content instead of a soft-404 shell. */ +function HlBuilderPending({ + slug, + cohortRow, +}: { + slug: string; + cohortRow: HlCohortRow | undefined; +}) { + const name = cohortRow?.name ?? titleCaseSlug(slug); + const breadcrumbLd = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: "Home", + item: "https://openchainbench.com/", + }, + { + "@type": "ListItem", + position: 2, + name: "Hyperliquid", + item: "https://openchainbench.com/hyperliquid", + }, + { + "@type": "ListItem", + position: 3, + name, + item: `https://openchainbench.com/hyperliquid/${slug}`, + }, + ], + }; + return ( + <article className="mx-auto max-w-[1200px] px-4 sm:px-6 py-12 sm:py-16"> + <script + type="application/ld+json" + // biome-ignore lint/security/noDangerouslySetInnerHtml: serialized via safeJsonLd + dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbLd) }} + /> + <Breadcrumb + items={[ + { label: "Home", href: "/" }, + { label: "Hyperliquid", href: "/hyperliquid" }, + { label: name }, + ]} + /> + <header className="mb-8"> + <p className="label-mono text-ink-faint mb-2">Hyperliquid frontend</p> + <div className="flex items-center gap-4"> + <ProviderLogo slug={slug} name={name} size={56} /> + <h1 className="display text-4xl sm:text-5xl text-ink">{name}</h1> + </div> + <p + className="mt-2 text-[12px] text-ink-faint" + style={{ fontFamily: "var(--font-mono, monospace)" }} + > + {slug} + </p> + </header> + {cohortRow ? ( + <section className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-10"> + <Kpi label="Fees 30d" value={fmtUSDShort(cohortRow.revenue30d)} /> + <Kpi label="Volume 30d" value={fmtUSDShort(cohortRow.volume30d)} /> + <Kpi label="Users 30d" value={cohortRow.users30d.toLocaleString()} /> + <Kpi + label="Cohort share 24h" + value={`${(cohortRow.cohortVolumeShare24h * 100).toFixed(2)}%`} + /> + </section> + ) : null} + <section className="rounded-lg border border-ink/10 bg-paper-soft/40 p-5 max-w-2xl"> + <h2 className="text-lg font-semibold text-ink mb-2"> + 12-month history aggregating + </h2> + <p className="text-sm text-ink-soft"> + {name} is tracked on the Hyperliquid builder cohort. The full + 12-month history chart, peer group and detailed KPIs will appear + here as soon as the next data sweep completes. In the meantime, + browse the{" "} + <Link href="/hyperliquid" className="underline hover:no-underline"> + Hyperliquid frontends grid + </Link>{" "} + for live rankings. + </p> + </section> + </article> + ); +} + function Kpi({ label, value }: { label: string; value: string }) { return ( <div className="rounded-lg border border-ink/10 bg-paper p-4"> diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ac4ae382..14d36da7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,7 @@ import { SearchProvider } from "@/components/search/search-provider"; import { buildSearchIndex } from "@/lib/search/buildIndex"; import { SITE } from "@/data/site"; import { safeJsonLd } from "@/lib/jsonld"; +import { PERSON_ID, PERSON_JSONLD } from "@/lib/hub-jsonld"; const inter = Inter({ variable: "--font-inter", @@ -140,6 +141,11 @@ const ORG_JSONLD = { name: "Mobula", url: "https://mobula.io", }, + // Named founder, anchored to the canonical Person node emitted below + // in the same @graph. Lets Search Console + AI answer surfaces + // attribute the project to a real human, which is the missing + // E-E-A-T signal on data-driven editorial pages. + founder: { "@id": PERSON_ID }, sameAs: [ SITE.github, `https://x.com/${SITE.twitter.replace(/^@/, "")}`, @@ -162,6 +168,13 @@ const ORG_JSONLD = { "query-input": "required name=search_term_string", }, }, + // Canonical Person node for the sole named maintainer. Referenced by + // Organization.founder above, by every bench's StatisticalReport + // (contributor) and by bench TechArticles (author + reviewedBy). + // Anchoring here in the site-wide @graph means every per-page node + // that references PERSON_ID resolves against a single authoritative + // definition instead of duplicating the Person shape across pages. + PERSON_JSONLD, ], }; diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 4496b357..cbc63040 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -15,6 +15,7 @@ import { } from "@/data/provider-registry"; import { Breadcrumb } from "@/components/breadcrumb"; import { buildBreadcrumbJsonLd, safeJsonLd } from "@/lib/jsonld"; +import { CREATOR_PUBLISHER, CITABLE_JSON_URL, DATASET_LICENSE } from "@/lib/dataset-jsonld"; import { fetchHlBuilderStats, isHlBuilderSlug, @@ -181,7 +182,25 @@ export default async function ProviderPage({ // is warming up. Throwing here makes the ISR revalidation fail, so // Vercel keeps serving the last good render instead of caching a page // full of "data warming up" for the next 5 minutes. - if (p.appearances.length >= 3 && p.appearances.every((a) => a.rank === 0)) { + // + // Tightened gate: only fire when EVERY appearance claims availability + // "live" yet none of them ranked. That is the true store-read-failure + // signature (data present but ranking silently failed). Any appearance + // with `unresponsive: true` or `availability: "unavailable"` — or the + // mixed shape Cloudflare produces (some benches live-but-broken with + // rank=0, others outright unavailable) — is real provider data, not a + // store fault, so we render the page as-is instead of 500ing the smoke + // test into a rollback loop. + const allClaimLive = p.appearances.every( + (a) => + a.result.availability === "live" && + a.result.unresponsive !== true, + ); + if ( + p.appearances.length >= 3 && + p.appearances.every((a) => a.rank === 0) && + allClaimLive + ) { throw new Error(`degraded store read for /products/${slug}: ${p.appearances.length} appearances, all unranked`); } @@ -427,8 +446,22 @@ export default async function ProviderPage({ name: a.benchmark.title, description: capDescription(a.benchmark.subtitle, 990), url: `${SITE.url}/benchmarks/${a.benchmark.slug}`, - creator: { "@id": `${SITE.url}/#org` }, - license: "https://creativecommons.org/licenses/by/4.0/", + creator: CREATOR_PUBLISHER, + publisher: CREATOR_PUBLISHER, + isAccessibleForFree: true, + license: DATASET_LICENSE, + distribution: [ + { + "@type": "DataDownload", + encodingFormat: "application/json", + contentUrl: `${SITE.url}/api/stat/${a.benchmark.slug}`, + }, + { + "@type": "DataDownload", + encodingFormat: "application/json", + contentUrl: CITABLE_JSON_URL, + }, + ], })), }, // NB: previously emitted a SoftwareApplication node here, but Google's diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 499a86fb..04545af2 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,4 +1,4 @@ -import { statSync } from "node:fs"; +import { readFileSync, statSync } from "node:fs"; import path from "node:path"; import type { MetadataRoute } from "next"; import { getBenchmarks } from "@/data/benchmarks"; @@ -56,7 +56,32 @@ const BUILD_TIME = process.env.NEXT_PUBLIC_BUILD_TIME // crawler sees a current timestamp and keeps the pages in the warm pool. const REAL_REPO_BIRTH = new Date("2024-01-01"); +// Prebuilt manifest of `src/app/<rel>` → git-log %ct (Unix seconds) for +// editorial hub pages. Written by scripts/emit-page-mtimes.ts during +// prebuild so the sitemap can emit a real per-page <lastmod> derived +// from git history, instead of Vercel's build-container mtime (which +// resets every file to Oct 20 2018). Missing entries silently fall +// through to the statSync path below, then to BUILD_TIME. Read once +// at module scope so /sitemap.xml doesn't restat the file per page. +const MTIME_MANIFEST_PATH = path.join( + process.cwd(), + "data", + "page-mtimes.json", +); +const MTIME_MANIFEST: Record<string, number> = (() => { + try { + return JSON.parse(readFileSync(MTIME_MANIFEST_PATH, "utf8")); + } catch { + return {}; + } +})(); + function pageMtime(relPath: string): Date { + const gitSeconds = MTIME_MANIFEST[relPath]; + if (typeof gitSeconds === "number" && Number.isFinite(gitSeconds)) { + const gitDate = new Date(gitSeconds * 1000); + if (gitDate > REAL_REPO_BIRTH) return gitDate; + } try { const mtime = statSync(path.join(process.cwd(), "src/app", relPath)).mtime; if (mtime < REAL_REPO_BIRTH) return BUILD_TIME; @@ -97,6 +122,7 @@ function staticHubRoutes(catalogTs: Date): MetadataRoute.Sitemap { { url: `${SITE.url}/partners`, lastModified: pageMtime("partners/page.tsx"), changeFrequency: "monthly", priority: 0.7 }, { url: `${SITE.url}/badges`, lastModified: catalogTs, changeFrequency: "daily", priority: 0.7 }, { url: `${SITE.url}/about`, lastModified: pageMtime("about/page.tsx"), changeFrequency: "monthly", priority: 0.5 }, + { url: `${SITE.url}/team`, lastModified: pageMtime("team/page.tsx"), changeFrequency: "monthly", priority: 0.6 }, { url: `${SITE.url}/press`, lastModified: pageMtime("press/page.tsx"), changeFrequency: "monthly", priority: 0.4 }, { url: `${SITE.url}/compare`, lastModified: pageMtime("compare/page.tsx"), changeFrequency: "weekly", priority: 0.6 }, { url: `${SITE.url}/alternatives`, lastModified: pageMtime("alternatives/page.tsx"), changeFrequency: "weekly", priority: 0.6 }, diff --git a/src/app/team/page.tsx b/src/app/team/page.tsx new file mode 100644 index 00000000..dc824966 --- /dev/null +++ b/src/app/team/page.tsx @@ -0,0 +1,96 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowUpRight } from "lucide-react"; +import { SectionRule } from "@/components/section-rule"; +import { teamPageLd } from "@/lib/hub-jsonld"; +import { safeJsonLd } from "@/lib/jsonld"; +import { pageMetadata } from "@/lib/page-metadata"; + +export const metadata: Metadata = pageMetadata({ + path: "/team", + title: "Team", + description: + "Who maintains OpenChainBench: spec review, harness design, statistical review and editorial corrections.", +}); + +export default function TeamPage() { + return ( + <article className="mx-auto max-w-3xl px-4 sm:px-6 py-8 sm:py-12"> + {teamPageLd().map((ld, i) => ( + <script + key={i} + type="application/ld+json" + // biome-ignore lint/security/noDangerouslySetInnerHtml: serialized via safeJsonLd + dangerouslySetInnerHTML={{ __html: safeJsonLd(ld) }} + /> + ))} + <header className="border-b-2 border-ink pb-6"> + <h1 className="display text-3xl sm:text-4xl tracking-tight">Team</h1> + <p className="mt-3 max-w-3xl text-base sm:text-lg text-ink-soft leading-snug"> + A named maintainer signs off on every spec, every harness change, + every statistical revision. Numbers on the site are attributable to + a human, not a pipeline. + </p> + </header> + + <SectionRule label="Maintainer" number="i" /> + <section className="card-soft rounded-xl p-5 sm:p-6"> + <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1"> + <h2 className="display text-xl sm:text-2xl tracking-tight text-ink"> + Florent Tapponnier + </h2> + <p className="font-sans text-[11px] uppercase tracking-[0.16em] text-ink-muted font-medium"> + Maintainer + </p> + </div> + <p className="mt-4 text-base leading-relaxed text-ink-soft"> + Owns spec review (the YAML contract that governs what a bench + measures), harness design (the code that emits the metrics), + statistical review (percentile choice, window sizing, sample-size + floors) and editorial corrections (dated notes on the affected + report when a number is challenged and found wrong). + </p> + <div className="mt-5 flex flex-wrap gap-2"> + <a + href="https://github.com/Flotapponnier" + rel="noopener" + className="inline-flex items-center gap-1.5 rounded-md card-soft px-3 py-1.5 text-sm text-ink-soft hover:text-ink" + > + GitHub + <ArrowUpRight size={12} strokeWidth={2} /> + </a> + <a + href="https://www.linkedin.com/in/florent-tapponnier-26324a17a/" + rel="noopener" + className="inline-flex items-center gap-1.5 rounded-md card-soft px-3 py-1.5 text-sm text-ink-soft hover:text-ink" + > + LinkedIn + <ArrowUpRight size={12} strokeWidth={2} /> + </a> + </div> + </section> + + <SectionRule label="Editorial policy" number="ii" /> + <p className="text-base leading-relaxed text-ink-soft"> + Every published benchmark ships with a spec, a harness reference and + a set of Prometheus queries anyone can re-run. When a reader flags a + number as wrong, the correction is applied in place with a dated + note on the affected report, and the fix is shipped as a public pull + request. The full policy, including how the statistical conventions + are chosen and reviewed, lives on the{" "} + <Link className="lnk" href="/methodology">methodology page</Link>. + </p> + + <SectionRule label="Contact" number="iii" /> + <p className="text-base leading-relaxed text-ink-soft"> + For corrections, methodology questions or press, reach the + maintainer at{" "} + <a className="lnk" href="mailto:contact@openchainbench.com"> + contact@openchainbench.com + </a> + . Material errors are corrected in place with a dated note on the + affected report. + </p> + </article> + ); +} diff --git a/src/components/benchmark-card.tsx b/src/components/benchmark-card.tsx index 6a03581f..8ec09662 100644 --- a/src/components/benchmark-card.tsx +++ b/src/components/benchmark-card.tsx @@ -27,12 +27,18 @@ export function BenchmarkCard({ benchmark }: { benchmark: BenchmarkCardData }) { const insufficient = !isDraft && isInsufficient(b); const catColor = CATEGORY_COLOR[b.category] ?? "var(--color-ink-muted)"; - // Field composite p50: best-of-class (or worst if higher-is-better is - // false). We pick the leader, same heuristic as the table. + // Prefer the canonical leader from citation.ts (reliability floor + + // insufficient-sample filter applied at projection). Falls back to + // the raw best of the projected results when the projection never + // returned a leader (draft, insufficient, or every provider filtered + // out) so the card still surfaces a headline value instead of "n/a" + // whenever any result is present. const sorted = [...b.results].sort( b.higherIsBetter ? (a, x) => x.ms.p50 - a.ms.p50 : (a, x) => a.ms.p50 - x.ms.p50, ); - const leader = sorted[0]; + const leader = b.leaderSlug + ? (sorted.find((r) => r.slug === b.leaderSlug) ?? sorted[0]) + : sorted[0]; const headlineValue = !isDraft && !insufficient && leader ? fmtValue(leader.ms.p50, b.unit) : "n/a"; // Pass the leader's p50 so unitSuffix mirrors fmtUnit's auto-conversion diff --git a/src/components/chain-kpi-strip.tsx b/src/components/chain-kpi-strip.tsx index 24c8da33..b8da98d5 100644 --- a/src/components/chain-kpi-strip.tsx +++ b/src/components/chain-kpi-strip.tsx @@ -20,6 +20,8 @@ type CardDef = { label: string; value: number | null; tip: string; + /** Optional formatter override; defaults to fmtUSD. */ + fmt?: (v: number) => string; }; export function ChainKpiStrip({ @@ -35,7 +37,32 @@ export function ChainKpiStrip({ tvlHistory?: ChainTvlHistory | null; chainLabel: string; }) { + const subsidyCards: CardDef[] = slug === "robinhood" + ? [ + { + label: "Gas subsidy days left", + value: kpis.subsidyDaysRemaining, + tip: + "Days remaining until Robinhood Chain's 90-day launch gas subsidy ends on 2026-09-29. Users pay $0 in gas during this window; Robinhood covers the sequencer cost.", + fmt: fmtDays, + }, + { + label: "Subsidy paid so far", + value: kpis.subsidyCostToDate, + tip: + "Estimated cumulative USD Robinhood has paid the sequencer since launch to cover user gas. This is NOT growthepie's fees_paid_by_users (~$0 during the subsidy by design). Refreshed hourly.", + }, + { + label: "Projected subsidy total", + value: kpis.subsidyProjectedTotal, + tip: + "Where the subsidy lands if the current daily burn rate holds through Sep 29. Linear projection: cumulative_cost x (90 / days_elapsed).", + }, + ] + : []; + const staticCards: CardDef[] = [ + ...subsidyCards, { label: "TVL", value: kpis.tvl, @@ -133,7 +160,7 @@ function KpiCard({ card }: { card: CardDef }) { {card.label} </p> <p className="mt-auto text-lg sm:text-xl font-semibold tabular-nums leading-tight"> - {fmtUSD(card.value!)} + {(card.fmt ?? fmtUSD)(card.value!)} </p> {/* Empty slot mirrors the live-card sparkline space so static and live KpiCards share the exact same baseline geometry. */} @@ -142,6 +169,12 @@ function KpiCard({ card }: { card: CardDef }) { ); } +function fmtDays(v: number): string { + if (!Number.isFinite(v)) return "-"; + if (v < 0) return "ended"; + return `${Math.round(v)}d`; +} + function fmtUSD(v: number): string { if (!Number.isFinite(v) || v === 0) return "$0"; const abs = Math.abs(v); diff --git a/src/components/home-bench-table.tsx b/src/components/home-bench-table.tsx index bd475b12..d26258d7 100644 --- a/src/components/home-bench-table.tsx +++ b/src/components/home-bench-table.tsx @@ -3,6 +3,7 @@ import { ArrowUpRight } from "lucide-react"; import type { Benchmark } from "@/types/benchmark"; import { Hint } from "@/components/hint"; import { MiniChart } from "@/components/mini-chart"; +import { ProviderLogo } from "@/components/provider-logo"; import { CATEGORY_COLOR } from "@/lib/category-colors"; import { fieldValue, isInsufficient, leader } from "@/lib/citation"; import { fmtValue, unitSuffix } from "@/lib/format"; @@ -90,9 +91,12 @@ function BenchTitleCell({ b }: { b: Benchmark }) { {chips.map((c) => ( <li key={c.slug} className="inline-flex"> <Hint label={c.name}> - <span className="inline-flex items-center justify-center w-5 h-5 rounded-full border border-rule bg-surface text-[9px] font-semibold text-ink-muted"> - {c.name.charAt(0)} - </span> + {/* Was: bare first-letter chip that collapsed + "Mobula/CoinGecko/CoinPaprika/Moralis" into "M C C M" + with no useful signal. ProviderLogo renders the real + brand mark (falls back to a multi-letter initials + chip when the logo is missing). */} + <ProviderLogo slug={c.slug} name={c.name} size={20} /> </Hint> </li> ))} diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index b98c53de..638f0b8c 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -760,6 +760,11 @@ function Row({ <span itemProp="name">{r.name}</span> </Link> )} + {benchmark.providerNotes?.[r.slug] && !isMuted && ( + <span className="inline-flex items-center shrink-0 rounded bg-paper-soft px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.12em] text-ink-muted"> + {benchmark.providerNotes[r.slug]} + </span> + )} {r.tag && !isMuted && ( <span className="hidden sm:inline-block truncate max-w-[140px] md:max-w-[220px] font-sans text-[10px] uppercase tracking-[0.14em] text-ink-muted"> {r.tag} @@ -781,6 +786,14 @@ function Row({ </span> </Hint> )} + {!isMuted && r.liveStatus === "down" && ( + <Hint label="The provider's live feed has produced no new events in the last several minutes. The percentiles shown are the last-known values from the 24-hour window and will keep displaying until fresh events resume. Rolling reliability (Success column) barely moves on a short outage, which is why this pill exists."> + <span className="inline-flex items-center gap-1 shrink-0 font-sans text-[10px] uppercase tracking-[0.14em] text-ink-muted"> + <span className="inline-block w-1.5 h-1.5 rounded-full bg-[var(--color-danger,#b0402e)]" aria-hidden /> + Feed down + </span> + </Hint> + )} {!isMuted && r.dataConfidence === "low" && ( <Hint label={ diff --git a/src/components/live/live-number.tsx b/src/components/live/live-number.tsx index c8db0d74..6508076c 100644 --- a/src/components/live/live-number.tsx +++ b/src/components/live/live-number.tsx @@ -28,20 +28,50 @@ export function LiveNumber({ value, format, monotonic = false, + maxValue, className, }: { value: number | undefined; format: (n: number | undefined) => string; monotonic?: boolean; + /** Defensive ceiling. A single garbage push from the upstream relay + * (observed: onchain vol24h briefly returning 8e79) would otherwise + * latch into `lastRef` and, under `monotonic`, stick as the + * displayed value forever because every subsequent healthy push is + * smaller and gets rejected as a "down tick". Reject any incoming + * value above the ceiling before it can pollute the snapshot pair. */ + maxValue?: number; className?: string; }) { - const [display, setDisplay] = useState<number | undefined>(value); + // Never SEED the display with a value that already exceeds the + // ceiling. Otherwise the very first render (before any useEffect runs) + // would paint the garbage, then the monotonic guard would reject + // every healthy value that follows as a "down tick" and the garbage + // would stick until the tab was closed. + const seed = + value == null || (maxValue != null && value > maxValue) ? undefined : value; + const [display, setDisplay] = useState<number | undefined>(seed); const lastRef = useRef<{ value: number; ts: number } | null>(null); const prevRef = useRef<{ value: number; ts: number } | null>(null); const rafRef = useRef<number | null>(null); useEffect(() => { if (value == null || !Number.isFinite(value)) return; + if (maxValue != null && value > maxValue) { + // Flush a poisoned lastRef AND the displayed value so a healthy + // push can seed the ticker fresh on the next tick. Without + // resetting `display`, the monotonic guard below would keep + // rejecting the healthy value as smaller than the garbage still + // shown to the user. + if (lastRef.current && lastRef.current.value > maxValue) { + lastRef.current = null; + prevRef.current = null; + } + setDisplay((d) => + d != null && maxValue != null && d > maxValue ? undefined : d, + ); + return; + } const now = performance.now(); // Only advance the snapshot pair on DISTINCT values. Consecutive pushes // with the same value (relay 1 Hz tick × lighthouse 1-5 min poll) would @@ -53,7 +83,7 @@ export function LiveNumber({ if (lastRef.current.value === value) return; prevRef.current = lastRef.current; lastRef.current = { value, ts: now }; - }, [value]); + }, [value, maxValue]); useEffect(() => { let cancelled = false; diff --git a/src/components/live/ticker.tsx b/src/components/live/ticker.tsx index 1409ef69..118504e2 100644 --- a/src/components/live/ticker.tsx +++ b/src/components/live/ticker.tsx @@ -56,22 +56,30 @@ export const LiveTicker = memo(function LiveTicker({ </span> </div> + {/* maxValue: defensive ceilings against upstream corruption. Real + world highs (crypto ATH): vol24h ~$500B, trades24h ~50M, + mcap ~$4T. Ceilings sit 20-100x above those to leave room for + plausible growth while catching the 10^20+ garbage tick that + latched the ticker at 8e79 on the home page on 2026-07-17. */} <Stat label="Onchain volume · 24h" value={stats?.vol24h} format={fmtMoneyFull} monotonic + maxValue={1e13} /> <Stat label="Onchain txs · 24h" value={stats?.trades24h} format={fmtCountFull} monotonic + maxValue={1e10} /> <Stat label="Total crypto mcap" value={stats?.mcap} format={fmtMoneyFull} + maxValue={1e14} /> <button @@ -90,11 +98,13 @@ function Stat({ value, format, monotonic, + maxValue, }: { label: string; value: number | undefined; format: (n: number | undefined) => string; monotonic?: boolean; + maxValue?: number; }) { return ( <div className="flex items-baseline gap-2 min-w-0"> @@ -103,6 +113,7 @@ function Stat({ value={value} format={format} monotonic={monotonic} + maxValue={maxValue} className="font-sans tabular text-[12px] text-ink-soft" /> </div> diff --git a/src/components/provider-logo.tsx b/src/components/provider-logo.tsx index 62d4b0f4..8f52e362 100644 --- a/src/components/provider-logo.tsx +++ b/src/components/provider-logo.tsx @@ -80,6 +80,9 @@ export function ProviderLogo({ // hairline shadow so they pop on both light and dark page backgrounds. const NEEDS_LIGHT_CHIP = new Set([ "aptos", + "aapl", + "pltr", + "leorpc", "blinklabs", "mobula", "lighter", diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx index 51bb7d04..ecaa15bb 100644 --- a/src/components/site-footer.tsx +++ b/src/components/site-footer.tsx @@ -36,6 +36,7 @@ export function SiteFooter() { { label: "Alternatives", href: "/alternatives" }, { label: "Answers", href: "/answers" }, { label: "Methodology", href: "/methodology" }, + { label: "Team", href: "/team" }, { label: "Press kit", href: "/press" }, ]} /> diff --git a/src/components/time-series-chart/chart.tsx b/src/components/time-series-chart/chart.tsx index 6998b116..bdaca025 100644 --- a/src/components/time-series-chart/chart.tsx +++ b/src/components/time-series-chart/chart.tsx @@ -6,7 +6,7 @@ import { type LineWithColor, } from "./scales"; import { YAxis, XAxis } from "./axis"; -import { SeriesGradients, SeriesPaths, HoverMarkers, type DrawnLine } from "./series"; +import { SeriesGradients, SeriesPaths, DowntimeBands, HoverMarkers, type DrawnLine } from "./series"; import { Legend } from "./legend"; import { Tooltip } from "./tooltip"; @@ -22,6 +22,11 @@ type ChartProps = { onZoom?: (next: { startFrac: number; endFrac: number } | null) => void; onToggleExclude?: (slug: string) => void; onResetExcluded?: () => void; + /** Only render the downtime bands + pill labels when the parent bench + * opted in (spec declared live_activity). Otherwise natural nulls in + * any bench's series24h would fire a "DATA MISSING" pill on charts + * that never asked for the feature (observed on rpc-reliability). */ + showDowntime?: boolean; }; export function Chart({ @@ -33,6 +38,7 @@ export function Chart({ onZoom, onToggleExclude, onResetExcluded, + showDowntime = false, }: ChartProps) { const W = 1000; const H = 360; @@ -386,6 +392,11 @@ export function Chart({ {/* X tick labels */} <XAxis xTicks={xTicks} padL={padL} padT={padT} innerW={innerW} innerH={innerH} /> + {/* Downtime bands. Rendered before the lines so the stroke stays + on top of the highlight. Contiguous null-buckets (from spec + `unless changes == 0`) become visible red columns. */} + {showDowntime && <DowntimeBands drawn={drawn} padT={padT} innerH={innerH} />} + {/* Areas + lines */} <SeriesPaths drawn={drawn} unit={unit} /> diff --git a/src/components/time-series-chart/index.tsx b/src/components/time-series-chart/index.tsx index 6e105ae1..519ae13e 100644 --- a/src/components/time-series-chart/index.tsx +++ b/src/components/time-series-chart/index.tsx @@ -475,6 +475,7 @@ export function TimeSeriesChart({ onZoom={setZoom} onToggleExclude={toggle} onResetExcluded={excluded.size > 0 ? reset : undefined} + showDowntime={benchmark.results.some((r) => r.liveStatus != null)} /> )} </figure> diff --git a/src/components/time-series-chart/series.tsx b/src/components/time-series-chart/series.tsx index 22e685cd..ca2ffa77 100644 --- a/src/components/time-series-chart/series.tsx +++ b/src/components/time-series-chart/series.tsx @@ -16,6 +16,177 @@ type SeriesPathsProps = { unit: string; }; +type DowntimeBandsProps = { + drawn: DrawnLine[]; + padT: number; + innerH: number; +}; + +/** Colored rectangles that highlight the exact window each line was + * silent (contiguous gap indices), plus a high-contrast pill label + * above each band naming the provider that dropped off. Pills stack + * vertically when multiple providers went silent at the same time so + * simultaneous outages read as separate lines instead of colliding + * as unreadable overlapping text. Rendered under SeriesPaths so the + * line stroke stays on top; skipped for excluded (legend-toggled) + * providers so their bands disappear with the line. */ +export function DowntimeBands({ drawn, padT, innerH }: DowntimeBandsProps) { + // Collect every band across every visible provider before rendering. + // Two-pass so we can assign each band a vertical stack slot based on + // how many EARLIER-placed bands its X-range overlaps — labels then + // don't collide when three providers went dark in the same window. + type Band = { + slug: string; + name: string; + color: string; + x: number; + w: number; + slot: number; + }; + const all: Band[] = []; + for (const d of drawn) { + if (d.excluded) continue; + // Only count gap runs that start AFTER the first observed sample. + // A leading run of nulls (Prom retention shorter than the visible + // window, harness started mid-range, or provider added recently) + // is "we didn't measure yet", not "provider was down for a week". + // Without this guard the 30D view on aggregator-head-lag rendered + // a chart-wide band during Prom's initial fill period. + let seenData = false; + let runStart: number | null = null; + const push = (endX: number) => { + if (runStart == null) return; + if (!seenData) { + runStart = null; + return; + } + const startX = d.pts[runStart].x; + all.push({ + slug: d.slug, + name: d.name, + color: d.color, + x: startX, + w: Math.max(6, endX - startX), + slot: 0, + }); + runStart = null; + }; + for (let i = 0; i < d.pts.length; i++) { + const p = d.pts[i]; + if (p.gap) { + if (runStart == null) runStart = i; + } else { + push(d.pts[i].x); + seenData = true; + } + } + // Trailing gap that runs to the current time — meaningful only + // when the series had prior data (same seenData guard). + if (runStart != null) push(d.pts[d.pts.length - 1].x); + } + if (all.length === 0) return null; + + // Slot assignment: for each band, pick the smallest slot not used + // by another band whose X-range overlaps this one. Naive O(n²) is + // fine — a chart has at most ~20 bands in the pathological case. + all.sort((a, b) => a.x - b.x); + for (let i = 0; i < all.length; i++) { + const b = all[i]; + const used = new Set<number>(); + for (let j = 0; j < i; j++) { + const p = all[j]; + if (p.x < b.x + b.w && p.x + p.w > b.x) used.add(p.slot); + } + let slot = 0; + while (used.has(slot)) slot++; + b.slot = slot; + } + + // Pill geometry constants. Pills sit ABOVE the plot area so they + // never occlude data. Small padding on the container's padT keeps + // them within the chart frame. + const PILL_H = 15; + const PILL_GAP = 3; + + return ( + <g className="ts-downtime"> + {/* Bands first (behind), then all pills on top so no band tint + can bleed onto a label from a taller-slot pill. */} + {all.map((b, i) => ( + <g key={`band-${b.slug}-${i}`}> + <rect + x={b.x} + y={padT} + width={b.w} + height={innerH} + fill={b.color} + opacity={0.1} + /> + <line + x1={b.x} + x2={b.x} + y1={padT} + y2={padT + innerH} + stroke={b.color} + strokeWidth={0.8} + strokeDasharray="3 3" + opacity={0.55} + /> + <line + x1={b.x + b.w} + x2={b.x + b.w} + y1={padT} + y2={padT + innerH} + stroke={b.color} + strokeWidth={0.8} + strokeDasharray="3 3" + opacity={0.55} + /> + </g> + ))} + {all.map((b, i) => { + const cx = b.x + b.w / 2; + // "NAME DATA MISSING" width estimate: ~5.5 px per char plus + // 14 px of horizontal padding (7 each side). "DATA MISSING" + // stays neutral: absence of samples could be the provider + // going down, our harness losing its WebSocket, or a Prom + // scrape failure — the pill doesn't blame either side. + const label = `${b.name.toUpperCase()} DATA MISSING`; + const pillW = Math.max(80, label.length * 5.5 + 14); + const pillY = padT + 2 + b.slot * (PILL_H + PILL_GAP); + const pillX = Math.max(0, cx - pillW / 2); + return ( + <g key={`pill-${b.slug}-${i}`}> + <rect + x={pillX} + y={pillY} + width={pillW} + height={PILL_H} + rx={PILL_H / 2} + ry={PILL_H / 2} + fill={b.color} + opacity={0.95} + /> + <text + x={pillX + pillW / 2} + y={pillY + PILL_H / 2 + 0.5} + textAnchor="middle" + dominantBaseline="middle" + fontFamily="var(--font-sans)" + fontSize="9.5" + fontWeight="700" + letterSpacing="0.06em" + fill="var(--color-paper, #ffffff)" + > + {label} + </text> + </g> + ); + })} + </g> + ); +} + export function SeriesPaths({ drawn, unit }: SeriesPathsProps) { return ( <> diff --git a/src/data/benchmarks.ts b/src/data/benchmarks.ts index 0808aaf5..7aa14f07 100644 --- a/src/data/benchmarks.ts +++ b/src/data/benchmarks.ts @@ -9,6 +9,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import yaml from "js-yaml"; import { cache } from "react"; +import { leader } from "@/lib/citation"; import { downsample, MINI_CHART_POINTS } from "@/lib/downsample"; import type { Benchmark } from "@/types/benchmark"; import { @@ -51,6 +52,14 @@ export type BenchmarkCardData = { sampleSize: number; lastRunAt: string; metric: string; + /** Slug of the citation-eligible leader for this bench (result of + * `leader(b)` in citation.ts). Passed at the projection boundary so + * the hub card can render the same headline the bench page names + * without duplicating the reliability + insufficient-sample filter + * logic. Null when the bench has no defensible leader (draft, + * insufficient, or every provider below the reliability floor with + * an empty live pool). */ + leaderSlug: string | null; results: { slug: string; name: string; @@ -76,6 +85,7 @@ export function toBenchmarkCardData(b: Benchmark): BenchmarkCardData { sampleSize: b.sampleSize, lastRunAt: b.lastRunAt, metric: b.metric, + leaderSlug: leader(b)?.slug ?? null, results: b.results.map((r) => ({ slug: r.slug, name: r.name, @@ -115,7 +125,7 @@ export const getBenchmarksSafe = cache(loadAllBenchmarksSafe); export async function getBenchmark( slug: string, - options: { chain?: string; region?: string } = {} + options: { chain?: string; region?: string; kind?: string; venue?: string } = {} ): Promise<Benchmark | undefined> { return loadBenchmark(slug, options); } diff --git a/src/data/compare-pairs.ts b/src/data/compare-pairs.ts index 5fcf0bfe..60f28aa1 100644 --- a/src/data/compare-pairs.ts +++ b/src/data/compare-pairs.ts @@ -115,12 +115,6 @@ export const COMPARE_PAIRS: ComparePair[] = [ providerB: "geckoterminal", publishedAt: "2026-06-17", }, - { - slug: "codex-vs-mobula", - providerA: "codex", - providerB: "mobula", - publishedAt: "2026-06-09", - }, { slug: "dai-vs-usdc", providerA: "dai", diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index 8b89ddbc..18c02f94 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -214,6 +214,91 @@ export const PROVIDER_REGISTRY: Record<string, ProviderRegistryEntry> = { "Solana transaction landing service by Temporal Labs. Direct-to-leader submission from 9 colocated regions, tip paid only on successful landing.", twitter: "@temporal_xyz", }, + leorpc: { + url: "https://leorpc.com", + description: + "LeoRPC is a community Solana RPC operation exposing a publicly documented FREE tier through a query key, one of the few remaining keyless ways to read Solana mainnet.", + }, + aapl: { + url: "https://www.apple.com", + description: + "Apple tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", + }, + nvda: { + url: "https://www.nvidia.com", + description: + "Nvidia tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", + }, + googl: { + url: "https://abc.xyz", + description: + "Alphabet tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", + }, + tsla: { + url: "https://www.tesla.com", + description: + "Tesla tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", + }, + pltr: { + url: "https://www.palantir.com", + description: + "Palantir tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", + }, + meta: { + url: "https://www.meta.com", + description: + "Meta Platforms tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", + }, + amd: { + url: "https://www.amd.com", + description: + "AMD tokenized equity issued by Robinhood on Robinhood Chain (contract 0x86923f96303D656E4aa86D9d42D1e57ad2023fdC), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + }, + msft: { + url: "https://www.microsoft.com", + description: + "Microsoft tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", + }, + amzn: { + url: "https://www.amazon.com", + description: + "Amazon tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", + }, + spy: { + url: "https://www.ssga.com", + description: + "SPDR S&P 500 ETF (State Street) tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", + }, + mu: { + url: "https://www.micron.com", + description: + "Micron Technology tokenized equity issued by Robinhood on Robinhood Chain (contract 0xfF080c8ce2E5feadaCa0Da81314Ae59D232d4afD), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + }, + hood: { + url: "https://robinhood.com", + description: + "Robinhood Markets tokenized equity, issued by Backed as HOODx on Solana. Notably absent from Robinhood's own chain: the only onchain HOOD is third party. Tracked live against the Nasdaq price on the xStocks peg benchmark.", + }, + qqq: { + url: "https://www.invesco.com", + description: + "Invesco QQQ Nasdaq 100 ETF, tokenized by Backed as QQQx on Solana. Tracked live against the real ETF price on the xStocks peg benchmark.", + }, + coin: { + url: "https://www.coinbase.com", + description: + "Coinbase tokenized equity, issued by Backed as COINx on Solana, a crypto exchange's stock trading on a blockchain. Tracked live against the Nasdaq price on the xStocks peg benchmark.", + }, + "orca-solana": { + url: "https://www.orca.so", + description: + "Orca is Solana's concentrated liquidity DEX. Its USDY/USDC whirlpool is the deepest genuine venue for Ondo's tokenized treasury and the market leg of the USDY NAV basis benchmark.", + }, + "pyth-market": { + url: "https://pyth.network", + description: + "Pyth Network's USDY/USD market composite aggregates USDY trading into one feed. Measured against Pyth's own USDY redemption rate feed on the NAV basis benchmark.", + }, bloxroute: { url: "https://bloxroute.com", description: @@ -488,6 +573,78 @@ export const PROVIDER_REGISTRY: Record<string, ProviderRegistryEntry> = { }, // ─── Public RPC providers ───────────────────────────────────── + parity: { + url: "https://www.parity.io", + description: + "Parity Technologies is the primary implementation shop behind Polkadot and Substrate, and the operator of the reference public relay RPC at rpc.polkadot.io. Also builds the Polkadot-JS Apps UI, Kusama tooling, and enterprise Substrate deployments.", + twitter: "@ParityTech", + }, + "polkadot-official": { + url: "https://rpc.polkadot.io", + description: + "Parity-operated Polkadot relay-chain public RPC endpoint (rpc.polkadot.io). Substrate JSON-RPC (chain_getHeader, state_getRuntimeVersion, system_health), keyless, rate-limited per IP. The reference endpoint any Polkadot integrator points at before wiring a dedicated node.", + twitter: "@ParityTech", + }, + "osmosis-official": { + url: "https://rpc.osmosis.zone", + description: + "Osmosis Foundation-operated public Cosmos SDK RPC endpoint (rpc.osmosis.zone). Tendermint / CometBFT JSON-RPC (status, block, abci_query), keyless, rate-limited per IP. The reference endpoint Keplr and the Osmosis Frontier route through by default.", + twitter: "@osmosiszone", + }, + polkachu: { + url: "https://polkachu.com", + description: + "Community-run Cosmos validator and public RPC network. Operates no-key Tendermint RPC endpoints across 40+ Cosmos SDK chains (osmosis-rpc.polkachu.com, cosmos-rpc.polkachu.com, etc.) with archive support on a paid tier.", + twitter: "@Polkachu_com", + }, + imperator: { + url: "https://imperator.co", + description: + "Cosmos-native infrastructure provider with a strong IBC and Osmosis ecosystem focus. Runs no-key Tendermint RPC endpoints (rpc-osmosis.imperator.co) alongside its analytics and data products for Cosmos appchains.", + twitter: "@imperatorco", + }, + lavenderfive: { + url: "https://lavenderfive.com", + description: + "Cosmos validator running multi-chain no-key RPC endpoints under rpc.lavenderfive.com/<chain> and lcd.lavenderfive.com/<chain>. Covers Osmosis, Cosmos Hub, Neutron, Injective and 20+ other Cosmos SDK chains.", + twitter: "@LavenderFive", + }, + "hyperliquid-official": { + url: "https://hyperliquid.xyz", + description: + "Hyperliquid Labs's official public HyperEVM RPC endpoint (rpc.hyperliquid.xyz/evm). Standard EVM JSON-RPC (eth_blockNumber, eth_getBlockByNumber, eth_getBalance), keyless, rate-limited per IP. The reference endpoint HyperCore front-ends and Hyperliquid dashboards route through by default.", + twitter: "@HyperliquidX", + }, + stakely: { + url: "https://stakely.io", + description: + "Non-custodial staking + infra provider running keyless public RPC endpoints across HyperEVM, Cosmos, Solana and 30+ chains. Founded 2019, EU-based, self-hosted validator fleet.", + twitter: "@stakely_io", + }, + purroofgroup: { + url: "https://purroofgroup.com", + description: + "Community-operated Hyperliquid validator and public RPC (rpc.purroofgroup.com). One of the earliest independent HyperEVM RPC providers; keyless, community-maintained.", + twitter: "@PurroofGroup", + }, + hypurrscan: { + url: "https://hypurrscan.io", + description: + "Community HyperEVM block explorer + public RPC gateway (rpc.hypurrscan.io). Indexed transaction search, contract verification, and a no-key HyperEVM JSON-RPC endpoint alongside the explorer UI.", + twitter: "@hypurrscan", + }, + trongrid: { + url: "https://www.trongrid.io", + description: + "TRON Foundation's official public API gateway (api.trongrid.io). Serves both the native TRON REST API (wallet/getnowblock, wallet/triggersmartcontract) and an EVM-compatible JSON-RPC endpoint (/jsonrpc). Keyless with a free API-key tier for higher quotas. The reference endpoint every TronWeb/tronbox integrator points at first.", + twitter: "@Trondao", + }, + dwellir: { + url: "https://www.dwellir.com", + description: + "Web3 Foundation grantee running production-grade Substrate infrastructure across globally distributed bare-metal servers. Keyless public gateways on Polkadot, Kusama and every major parachain, plus dedicated node service.", + twitter: "@dwellir", + }, "monad-official": { url: "https://docs.monad.xyz", description: diff --git a/src/lib/aggregate-blob.ts b/src/lib/aggregate-blob.ts new file mode 100644 index 00000000..13eb46fe --- /dev/null +++ b/src/lib/aggregate-blob.ts @@ -0,0 +1,112 @@ +/** + * CDN-cached aggregate reader (Phase 2 of the SRH-elimination roadmap). + * + * The materialize worker publishes `{ v, builtAt, benches[] }` to + * `https://kv.openchainbench.com/aggregate/latest.json` after every + * Tier A sweep (see worker/publish-aggregate.ts). This module fetches + * that URL from Vercel Fluid Compute, applies the current-spec editorial + * overlay + slim projection, and returns a `Benchmark[]` that is + * drop-in compatible with what `loadAllBenchmarksCached` produces. + * + * The whole point: one HTTP fetch (~1.5 MB gzipped, CDN-cached, ~20 ms + * cold from edge) replaces the ~150 concurrent SRH GETs that the + * per-bench aggregator used to fan out on every homepage revalidate. + * That fan-out was the root cause of the "Awaiting samples" bursts + * whenever SRH's connection pool went sideways. + * + * Failure model: any error (fetch throw, 4xx/5xx, malformed JSON, + * schema mismatch, empty envelope) returns `null` so the caller can + * fall through to the legacy Redis path. Never throws. + */ + +import { unstable_cache } from "next/cache"; +import type { Benchmark } from "@/types/benchmark"; +import { loadSpecsUncached } from "@/lib/materialize/load"; +import { overlayEditorial, slimBenchmarkForCache } from "@/lib/spec"; + +const DEFAULT_URL = "https://kv.openchainbench.com/aggregate/latest.json"; +const FETCH_TIMEOUT_MS = 8_000; +const MIN_BENCHES = 40; + +type AggregateEnvelope = { + v: number; + builtAt: number; + total?: number; + liveCount?: number; + draftCount?: number; + benches: Benchmark[]; +}; + +function isEnvelope(x: unknown): x is AggregateEnvelope { + if (typeof x !== "object" || x === null) return false; + const o = x as Record<string, unknown>; + return ( + typeof o.v === "number" && + typeof o.builtAt === "number" && + Array.isArray(o.benches) + ); +} + +async function fetchAndProject(): Promise<Benchmark[] | null> { + const url = process.env.AGGREGATE_BLOB_URL || DEFAULT_URL; + let raw: unknown; + try { + const res = await fetch(url, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + // Bypass Next's fetch memoization; the outer unstable_cache handles + // cross-request caching, we don't want double-layered TTLs. + cache: "no-store", + }); + if (!res.ok) { + console.warn(`[aggregate-blob] fetch ${url} → ${res.status}`); + return null; + } + raw = await res.json(); + } catch (err) { + console.warn( + `[aggregate-blob] fetch failed: ${err instanceof Error ? err.message : err}`, + ); + return null; + } + if (!isEnvelope(raw) || raw.v !== 1) { + console.warn("[aggregate-blob] envelope schema mismatch"); + return null; + } + if (raw.benches.length < MIN_BENCHES) { + console.warn( + `[aggregate-blob] rejecting suspiciously small envelope: ${raw.benches.length} < ${MIN_BENCHES}`, + ); + return null; + } + + // Apply the current-spec editorial overlay + slim projection so the + // shape matches what `loadAllBenchmarksCached` produces. The worker + // may have written a snapshot BEFORE a YAML edit rolled through, so + // the overlay is what keeps chain renames + editorial edits + prov + // rename reconciliation live without waiting for the worker sweep. + const specs = await loadSpecsUncached(); + const specBySlug = new Map(specs.map((s) => [s.slug, s] as const)); + const projected: Benchmark[] = []; + for (const bench of raw.benches) { + const spec = specBySlug.get(bench.slug); + if (!spec) continue; // Bench in blob no longer has a spec — skip. + projected.push(slimBenchmarkForCache(overlayEditorial(bench, spec))); + } + return projected.sort((a, b) => + (a.number ?? "").localeCompare(b.number ?? ""), + ); +} + +/** + * Fetch the aggregate snapshot from the CDN and project it against the + * current specs. Cross-request cached under the `bench-aggregate` tag + * so `revalidateTag('bench-aggregate', 'default')` (called by the + * worker's revalidate hook) purges instantly on new publish. + * + * Returns `null` on any failure so callers can fall back to Redis. + */ +export const loadAggregateFromBlob = unstable_cache( + fetchAndProject, + ["aggregate-blob-v1"], + { revalidate: 60, tags: ["bench-aggregate", "benchmarks"] }, +); diff --git a/src/lib/alternatives.ts b/src/lib/alternatives.ts index dffcf322..af5f5a7f 100644 --- a/src/lib/alternatives.ts +++ b/src/lib/alternatives.ts @@ -21,6 +21,15 @@ const ALT_DIR = path.join(process.cwd(), "alternatives"); const slug = z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*$/); +// Editorial voice guard, mirrors spec-schema.ts:176. Em-dash (—) and +// en-dash (–) are the classic "AI tells" that hurt brand voice; refuse +// them on any prose field a maintainer can hand-author. Plain hyphen +// stays legal for compound words. +const noAiDashes = (s: string) => + !s.includes("—") && !s.includes("–"); +const noAiDashesMsg = + "Avoid em or en dashes; use a comma, semicolon, or period instead"; + const AlternativeSchema = z.object({ slug, /** Display name of the product the page is alternative-to. */ @@ -41,6 +50,17 @@ const AlternativeSchema = z.object({ chain: z.string().optional(), /** Lead paragraph rendered above the data. */ intro: z.string().min(40), + /** Optional editorial positioning paragraph. Substantive prose (400+ + * chars) that explains where the target product fits in the market, + * what problem it solves and how OpenChainBench measures it. Kept + * optional so existing YAMLs keep parsing; populated in a follow-up + * editorial pass. */ + positioning: z + .string() + .min(400) + .max(2000) + .refine(noAiDashes, noAiDashesMsg) + .optional(), /** Optional SEO override for browser tab + meta title. */ seo_title: z.string().min(1).optional(), /** Optional SEO override for meta description. Falls back to intro. */ diff --git a/src/lib/answers.ts b/src/lib/answers.ts index 343d9e04..033c7955 100644 --- a/src/lib/answers.ts +++ b/src/lib/answers.ts @@ -27,6 +27,15 @@ const ANSWERS_DIR = path.join(process.cwd(), "answers"); const slugSchema = z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*$/); +// Editorial voice guard, mirrors spec-schema.ts:176. Em-dash (—) and +// en-dash (–) are the classic "AI tells" that hurt brand voice; refuse +// them on any prose field a maintainer can hand-author. Plain hyphen +// stays legal for compound words. +const noAiDashes = (s: string) => + !s.includes("—") && !s.includes("–"); +const noAiDashesMsg = + "Avoid em or en dashes; use a comma, semicolon, or period instead"; + const FaqSchema = z.object({ q: z.string().min(1).max(300), a: z.string().min(1).max(2000), @@ -57,6 +66,26 @@ const AnswerSchema = z.object({ /** Slugs of related answers, rendered as internal links at the bottom * of the page. Keeps the cluster crawlable. */ related: z.array(slugSchema).max(8).optional(), + /** Optional editorial commentary from the named maintainer, rendered + * as a distinct "Editorial context" block above the leaderboard with + * a byline. Substantive prose (400+ chars) that reads as an expert + * take on the question, not a data restatement. Only shown when the + * referenced bench has a defensible leader (dataPending swap + * suppresses it, since commentary without a leaderboard is meaningless). + * Emitted as QAPage.suggestedAnswer with a Person author. */ + expert_take: z + .string() + .min(400) + .max(1400) + .refine(noAiDashes, noAiDashesMsg) + .optional(), + /** ISO date (YYYY-MM-DD) of the last editorial review of expert_take. + * Kept as a bare date rather than a datetime because reviews are a + * human-scale event, not a runtime signal. */ + expert_take_reviewed: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), /** Optional SEO override for browser tab + meta title. */ seo_title: z.string().min(1).optional(), /** Optional SEO override for meta description. Falls back to short_answer. */ diff --git a/src/lib/bench-blob.ts b/src/lib/bench-blob.ts new file mode 100644 index 00000000..07f96cde --- /dev/null +++ b/src/lib/bench-blob.ts @@ -0,0 +1,127 @@ +/** + * Per-bench + per-variant CDN reader (Phase 3 of the SRH-elimination). + * + * The materialize worker publishes: + * - `bench-aggregate/latest.json` (all benches, homepage) + * - `bench-aggregate/benches/<slug>.json` (one unfiltered bench) + * - `bench-aggregate/variants/<slug>/<sig>.json` (one filtered variant) + * + * Aggregate is consumed by `src/lib/aggregate-blob.ts` (Phase 2). This + * module handles the two per-bench shapes — replaces the SRH GETs that + * `loadBenchmarkUnfilteredCached` and `loadBenchmarkFiltered` used to + * make in `src/lib/spec.ts`. + * + * Failure model: any error (fetch throw, 4xx/5xx, malformed JSON, schema + * mismatch) resolves to `null` so callers fall through to the Redis + * path. Never throws. + */ + +import type { Benchmark } from "@/types/benchmark"; +import { + MAT_SCHEMA_VERSION, + type MaterializedSnapshot, +} from "@/lib/materialize/schema"; + +const DEFAULT_BASE_URL = "https://kv.openchainbench.com/aggregate"; +const FETCH_TIMEOUT_MS = 5_000; + +type BenchEnvelope = { + v: number; + builtAt: number; + slug: string; + bench: Benchmark; +}; + +type VariantEnvelope = BenchEnvelope & { sig: string }; + +function baseUrl(): string { + return process.env.AGGREGATE_BLOB_BASE_URL || DEFAULT_BASE_URL; +} + +function isBenchEnvelope(x: unknown): x is BenchEnvelope { + if (typeof x !== "object" || x === null) return false; + const o = x as Record<string, unknown>; + return ( + typeof o.v === "number" && + typeof o.builtAt === "number" && + typeof o.slug === "string" && + typeof o.bench === "object" && + o.bench !== null + ); +} + +async function fetchJson(url: string): Promise<unknown | null> { + try { + const res = await fetch(url, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + cache: "no-store", + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +/** + * Fetch a single unfiltered bench from the CDN. Returns the raw + * `Benchmark` from the store (no editorial overlay applied — caller + * layers that on top so a stale blob doesn't outlive a spec edit). + */ +export async function loadBenchFromBlob( + slug: string, +): Promise<Benchmark | null> { + const url = `${baseUrl()}/benches/${encodeURIComponent(slug)}.json`; + const raw = await fetchJson(url); + if (!isBenchEnvelope(raw) || raw.v !== 1 || raw.slug !== slug) return null; + return raw.bench; +} + +/** + * Fetch a single filtered variant from the CDN. `sig` is the filterSig + * string (empty for unfiltered — callers with sig="" should use + * `loadBenchFromBlob` instead). + */ +export async function loadVariantFromBlob( + slug: string, + sig: string, +): Promise<Benchmark | null> { + if (!sig) return null; + const url = `${baseUrl()}/variants/${encodeURIComponent(slug)}/${encodeURIComponent(sig)}.json`; + const raw = await fetchJson(url); + if (!isBenchEnvelope(raw) || raw.v !== 1 || raw.slug !== slug) return null; + const env = raw as VariantEnvelope; + if (env.sig !== sig) return null; + return env.bench; +} + +/** + * Snapshot-shaped adapter for callers that expect the exact + * `readMaterialized` return value. Lets `readMaterialized(slug, sig)` + * call sites become `loadSnapshotFromBlob(slug, sig) ?? readMaterialized(slug, sig)` + * with zero downstream code change. `state` is stubbed empty because + * the blob broadcast doesn't include ring-buffer state (worker-internal, + * never read by the site — only the aggregation loop uses it). + */ +export async function loadSnapshotFromBlob( + slug: string, + sig: string, +): Promise<MaterializedSnapshot | null> { + const url = sig + ? `${baseUrl()}/variants/${encodeURIComponent(slug)}/${encodeURIComponent(sig)}.json` + : `${baseUrl()}/benches/${encodeURIComponent(slug)}.json`; + const raw = await fetchJson(url); + if (!isBenchEnvelope(raw) || raw.v !== 1 || raw.slug !== slug) return null; + if (sig) { + const env = raw as VariantEnvelope; + if (env.sig !== sig) return null; + } + return { + v: MAT_SCHEMA_VERSION, + slug, + sig, + builtAt: raw.builtAt, + bench: raw.bench, + state: { providers: {}, rings: {} }, + }; +} diff --git a/src/lib/bench-template.ts b/src/lib/bench-template.ts index f36e4300..f1addc5f 100644 --- a/src/lib/bench-template.ts +++ b/src/lib/bench-template.ts @@ -38,6 +38,7 @@ import type { Benchmark, ProviderResult } from "@/types/benchmark"; import { liveResults } from "@/lib/provider-filters"; +import { citationCandidates } from "@/lib/citation"; import { rankResults } from "@/lib/ranking"; import { fmtUnit } from "@/lib/format"; @@ -87,8 +88,18 @@ function worstForChain(b: Benchmark, chain: string): ProviderResult | undefined export function renderTemplate(text: string, benchmark: Benchmark): string { if (!text || text.indexOf("{{") === -1) return text; + // `live` still drives per-slug lookups so callers of {{p50:some-slug}} + // can still address unreliable providers by name (the token is + // explicit). `bestPool` applies the same reliability floor as + // `leader()` so {{best_name}} and {{best_p50}} tokens in bench copy + // never elevate a provider that would be filtered out of the + // citation headline. const live = liveResults(benchmark.results); - const sorted = rankResults(live, benchmark.higherIsBetter); + const bestPool = citationCandidates(benchmark); + const sorted = rankResults( + bestPool.length > 0 ? bestPool : live, + benchmark.higherIsBetter, + ); const best = sorted[0]; const worst = sorted[sorted.length - 1]; diff --git a/src/lib/categories.ts b/src/lib/categories.ts index d432767f..091bde4e 100644 --- a/src/lib/categories.ts +++ b/src/lib/categories.ts @@ -30,7 +30,8 @@ export type Category = | "Wallets" | "RPCs" | "NFT APIs" - | "Explorers"; + | "Explorers" + | "RWA"; export type CategoryEntry = { /** URL slug. Lowercase, kebab-case, ASCII. */ @@ -93,6 +94,13 @@ export const CATEGORIES: readonly CategoryEntry[] = [ description: "Live measurements for block explorer APIs. Compare how many chains each family actually serves with a working indexer, probe-verified daily against the vendors' own registries.", }, + { + slug: "rwa", + label: "RWA", + heading: "RWA benchmarks", + description: + "Live measurements for tokenized real world assets. How closely tokenized stocks and treasuries track their underlying market price, measured onchain around the clock, including nights and weekends when the real markets are closed.", + }, { slug: "nft-apis", label: "NFT APIs", diff --git a/src/lib/category-colors.ts b/src/lib/category-colors.ts index e06035d5..6cf03306 100644 --- a/src/lib/category-colors.ts +++ b/src/lib/category-colors.ts @@ -18,4 +18,6 @@ export const CATEGORY_COLOR: Record<string, string> = { // in use. Picks up OpenSea/Alchemy/Moralis brand palettes which all // cluster around blues/purples. "NFT APIs": "#6366f1", + // RWA: teal, the tradfi-meets-onchain lane gets its own hue. + RWA: "#0d9488", }; diff --git a/src/lib/chain-kpis.ts b/src/lib/chain-kpis.ts index aa5960f3..eaa28e35 100644 --- a/src/lib/chain-kpis.ts +++ b/src/lib/chain-kpis.ts @@ -34,6 +34,12 @@ export type ChainKpis = { nativePrice: number | null; /** Mobula native token circulating market cap in USD. */ nativeMcap: number | null; + /** Robinhood Chain gas subsidy: days remaining until 2026-09-29 (null on every other chain). */ + subsidyDaysRemaining: number | null; + /** Robinhood Chain gas subsidy: cumulative USD Robinhood has paid the sequencer since launch. */ + subsidyCostToDate: number | null; + /** Robinhood Chain gas subsidy: projected total cost at the current daily rate through Sep 29. */ + subsidyProjectedTotal: number | null; }; /** TVL daily samples for the sparkline chart. Last 30 days, oldest first. */ @@ -78,14 +84,28 @@ export async function fetchChainKpisFresh( } const sel = `{chain="${slug}"}`; - const [tvl, dexVolume24h, stablesMcap, nativePrice, nativeMcap] = - await Promise.all([ - prom.scalar(`chain_tvl_usd${sel}`), - prom.scalar(`chain_dex_volume_24h_usd${sel}`), - prom.scalar(`chain_stables_mcap_usd${sel}`), - prom.scalar(`chain_native_price_usd${sel}`), - prom.scalar(`chain_native_mcap_usd${sel}`), - ]); + const isRobinhood = slug === "robinhood"; + const [ + tvl, + dexVolume24h, + stablesMcap, + nativePrice, + nativeMcap, + subsidyDaysRemaining, + subsidyCostToDate, + subsidyProjectedTotal, + ] = await Promise.all([ + prom.scalar(`chain_tvl_usd${sel}`), + prom.scalar(`chain_dex_volume_24h_usd${sel}`), + prom.scalar(`chain_stables_mcap_usd${sel}`), + prom.scalar(`chain_native_price_usd${sel}`), + prom.scalar(`chain_native_mcap_usd${sel}`), + // Robinhood-only. On every other chain these queries return null, + // and the strip skips the cards silently. + isRobinhood ? prom.scalar(`robinhood_subsidy_days_remaining`) : Promise.resolve(null), + isRobinhood ? prom.scalar(`robinhood_subsidy_chainside_cost_total_usd`) : Promise.resolve(null), + isRobinhood ? prom.scalar(`robinhood_subsidy_projected_total_usd`) : Promise.resolve(null), + ]); return { slug, @@ -94,6 +114,9 @@ export async function fetchChainKpisFresh( stablesMcap, nativePrice, nativeMcap, + subsidyDaysRemaining, + subsidyCostToDate, + subsidyProjectedTotal, }; } @@ -146,7 +169,10 @@ export function hasAnyKpi(k: ChainKpis | null): boolean { k.dexVolume24h != null || k.stablesMcap != null || k.nativePrice != null || - k.nativeMcap != null + k.nativeMcap != null || + k.subsidyDaysRemaining != null || + k.subsidyCostToDate != null || + k.subsidyProjectedTotal != null ); } diff --git a/src/lib/chains.ts b/src/lib/chains.ts index 29fd4bff..5444c075 100644 --- a/src/lib/chains.ts +++ b/src/lib/chains.ts @@ -115,6 +115,14 @@ export const CHAINS: ChainEntry[] = [ description: "Ouroboros Proof of Stake, deterministic protocol fees set by governance, EUTXO accounting model.", }, + { + slug: "cosmos-hub", + label: "Cosmos Hub", + category: "L1", + nativeSymbol: "ATOM", + description: + "Cosmos Hub (ATOM). Tendermint / CometBFT consensus, ~6 s block, instant finality, IBC hub for the wider Cosmos ecosystem.", + }, { slug: "litecoin", label: "Litecoin", @@ -155,6 +163,14 @@ export const CHAINS: ChainEntry[] = [ description: "OP Stack reference rollup, 2 s sequencer cadence, EIP-4844 blob calldata settlement on Ethereum.", }, + { + slug: "robinhood", + label: "Robinhood Chain", + category: "L2", + nativeSymbol: "ETH", + description: + "Robinhood's Arbitrum Orbit rollup for tokenized stocks, ~100 ms blocks, ETH gas, Ethereum blob settlement, sequencer operated by Robinhood with 10% of chain fees shared to ArbitrumDAO.", + }, { slug: "base", label: "Base", @@ -301,6 +317,30 @@ export const CHAINS: ChainEntry[] = [ description: "Sony Block Solutions OP Stack rollup in the Optimism Superchain, 2 s blocks, consumer and entertainment focus.", }, + { + slug: "polkadot", + label: "Polkadot", + category: "L1", + nativeSymbol: "DOT", + description: + "Substrate-based relay chain coordinating a parachain ecosystem via shared security. GRANDPA finality with BABE block production, ~6 s block time.", + }, + { + slug: "osmosis", + label: "Osmosis", + category: "L1", + nativeSymbol: "OSMO", + description: + "Cosmos SDK appchain and the largest IBC-connected DEX. CometBFT (Tendermint) consensus with deterministic finality per block, ~6 s block time. TokenFactory + IBC v3 native, x/superfluid staking on LP shares.", + }, + { + slug: "hyperliquid", + label: "Hyperliquid", + category: "L1", + nativeSymbol: "HYPE", + description: + "HyperEVM (chain id 999). Hyperliquid Labs's EVM execution layer bolted onto the HyperCore perps engine, ~2 s blocks, HyperBFT consensus, native HYPE gas. First on-chain perp DEX to reach $1B+ daily volume.", + }, ]; export const CHAIN_BY_SLUG = new Map(CHAINS.map((c) => [c.slug, c])); diff --git a/src/lib/citation.test.ts b/src/lib/citation.test.ts new file mode 100644 index 00000000..376995f8 --- /dev/null +++ b/src/lib/citation.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; +import { leader, fieldValue, rankedCandidates } from "./citation"; +import type { Benchmark, ProviderResult } from "@/types/benchmark"; + +function r( + slug: string, + name: string, + p50: number, + successRate = 100, +): ProviderResult { + return { + slug, + name, + ms: { p50, p90: p50, p99: p50, mean: p50 }, + successRate, + availability: "live", + }; +} + +function bench(results: ProviderResult[]): Benchmark { + return { + slug: "test", + number: "001", + title: "Test bench", + subtitle: "", + lastRunAt: "2026-07-14T00:00:00.000Z", + status: "live", + editorialStatus: "live", + sampleSize: 100, + abstract: "", + metric: "Latency", + unit: "ms", + higherIsBetter: false, + category: "RPCs", + results, + findings: [], + methodology: [], + source: "", + extras: { series24h: {}, regions: {} }, + }; +} + +describe("citation reliability threshold", () => { + test("leader excludes providers with success rate below 50 percent", () => { + // Owlracle scenario: technically most accurate (lowest p50 gap) but + // fails 93 percent of calls. Etherscan should win despite a higher p50. + const b = bench([ + r("owlracle", "Owlracle", 0.001, 6.48), + r("etherscan", "Etherscan", 1.0, 95.7), + r("publicnode", "PublicNode", 1.5, 99.99), + ]); + const top = leader(b); + expect(top?.slug).toBe("etherscan"); + expect(top?.value).toBe(1.0); + }); + + test("fieldValue reflects the reliability-filtered leader", () => { + const b = bench([ + r("owlracle", "Owlracle", 0.001, 6.48), + r("etherscan", "Etherscan", 1.0, 95.7), + ]); + expect(fieldValue(b)).toBe(1.0); + }); + + test("falls back to the full live pool when every provider is unreliable", () => { + // Bench in a totally degraded state: rather than vanishing from + // downstream surfaces, surface the least-bad provider so readers + // still see a live number with the caveats their spec already + // documents. + const b = bench([ + r("a", "A", 5, 20), + r("b", "B", 10, 30), + ]); + const top = leader(b); + expect(top?.slug).toBe("a"); + }); + + test("providers with the default 100 percent success rate pass through", () => { + // Freshness / gauge-only benches never emit a `success` query; + // the loader defaults to 100 percent, so the guard is inert. + const b = bench([r("a", "A", 100), r("b", "B", 200)]); + expect(leader(b)?.slug).toBe("a"); + }); + + test("higher-is-better ranks the correct reliable leader", () => { + const b = bench([ + r("high-but-flaky", "Flaky", 999, 10), + r("modest-reliable", "Reliable", 50, 100), + r("mid-reliable", "Mid", 80, 100), + ]); + b.higherIsBetter = true; + // higherIsBetter=true reverses sort so the biggest p50 wins. + // Flaky wins on raw value but is filtered out; Mid (80) wins the + // reliable pool. + expect(leader(b)?.slug).toBe("mid-reliable"); + }); + + test("rankedCandidates[0] matches leader for the same bench", () => { + // Locks the invariant that downstream surfaces (/api/stat rankings, + // llm-context, MCP resource) can share `rankedCandidates` with + // `leader()` and never emit a document where the "leader" field + // contradicts the first entry of the "rankings" list. + const b = bench([ + r("owlracle", "Owlracle", 0.001, 6.48), + r("etherscan", "Etherscan", 1.0, 95.7), + r("publicnode", "PublicNode", 1.5, 99.99), + ]); + const top = leader(b); + const ranks = rankedCandidates(b); + expect(top?.slug).toBe(ranks[0].slug); + expect(top?.value).toBe(ranks[0].ms.p50); + }); +}); diff --git a/src/lib/citation.ts b/src/lib/citation.ts index ad3eb923..09062a88 100644 --- a/src/lib/citation.ts +++ b/src/lib/citation.ts @@ -8,16 +8,71 @@ import type { Benchmark, ProviderResult } from "@/types/benchmark"; import { liveResults } from "@/lib/provider-filters"; import { fmtUnit } from "@/lib/format"; +/** Minimum measured success rate (in percent, 0-100) for a provider to + * contribute to the headline leader claim. Providers with a real + * success measurement below this floor are excluded from citation + * candidates because an "unreliable but accurate when it works" + * outlier should not top the leaderboard: it misleads AI agents citing + * the bench and any human reader glancing at the headline. Benches + * whose harness does not emit a `success` query default to 100 in the + * load path (see materialize/load.ts), so this guard is a no-op for + * freshness / gauge-only benches and only bites where the harness + * actually measures polling reliability (gas-estimation, RPC + * benches). */ +const LEADER_MIN_SUCCESS_PCT = 50; + /** Provider set used to derive the headline figures. Drops rows whose * per-provider sample-health is "insufficient" (set on the load path * when the bench declares expected_n and the row falls below the 10 - * percent of expected floor). Those rows can still render in some - * surfaces with a soft tag, but they must not contribute to the leader - * claim shipped to AI agents and journalists via the citable APIs. */ -function citationCandidates(b: Benchmark): ProviderResult[] { + * percent of expected floor) and rows whose measured success rate + * sits below the reliability floor. Those rows can still render in + * some surfaces with a soft tag, but they must not contribute to the + * leader claim shipped to AI agents and journalists via the citable + * APIs. Falls back to the full live pool when every provider is + * below the reliability floor so a totally-degraded bench still + * reports a best-of-bad-options leader instead of vanishing. + * + * Exported so downstream machine-readable surfaces (`/api/stat` + * rankings, `/api/llm-context`, MCP `get_benchmark`, `/api/compare`) + * can share the same eligibility rule as `leader()` and stay + * internally consistent: a JSON that names Etherscan as leader + * should not simultaneously rank Owlracle first in its `rankings` + * array. */ +export function citationCandidates(b: Benchmark): ProviderResult[] { const live = liveResults(b.results); - if (!b.expectedN) return live; - return live.filter((r) => r.dataConfidence !== "insufficient"); + const reliable = live.filter( + (r) => (r.successRate ?? 100) >= LEADER_MIN_SUCCESS_PCT, + ); + const pool = reliable.length > 0 ? reliable : live; + if (!b.expectedN) return pool; + return pool.filter((r) => r.dataConfidence !== "insufficient"); +} + +/** Sorted candidate pool for the machine-readable `rankings` array on + * `/api/stat`, MCP, llm-context and any downstream that ranks the + * full field. Applies the same reliability + insufficient-sample + * filters as `leader()` so a document that names X as leader ranks X + * first in its own list. Sort direction honors the bench's + * `higherIsBetter` flag. */ +export function rankedCandidates(b: Benchmark): ProviderResult[] { + return [...citationCandidates(b)].sort((a, c) => + b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50, + ); +} + +/** Timestamp of the last real measurement, or null when the bench has + * no measurement history yet (draft placeholder). Draft benches carry + * a wall-clock `lastRunAt` for type safety (Benchmark.lastRunAt is a + * non-nullable string), which downstream JSON, JSON-LD and MCP surfaces + * would otherwise expose as a real freshness signal to LLM crawlers. + * Use this helper on every machine-readable surface. + * + * Accepts any object that carries `status` + `lastRunAt` so the slim + * `ProviderAppearance.benchmark` (a Pick of Benchmark) can use it too. */ +export function citableAsOf( + b: Pick<Benchmark, "status" | "lastRunAt">, +): string | null { + return b.status === "draft" ? null : b.lastRunAt; } /** Median value of the benchmark (the field shown in the headline). */ @@ -27,11 +82,8 @@ export function fieldValue(b: Benchmark): number | null { // (downstream LLM tools and SERP snippets would otherwise quote a // number drawn from a wildly undersized field). if (b.dataConfidence === "insufficient") return null; - const candidates = citationCandidates(b); - if (candidates.length === 0) return null; - const sorted = [...candidates].sort((a, c) => - b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50 - ); + const sorted = rankedCandidates(b); + if (sorted.length === 0) return null; return sorted[0].ms.p50; } @@ -39,11 +91,8 @@ export function fieldValue(b: Benchmark): number | null { export function leader(b: Benchmark): { name: string; slug: string; value: number } | null { if (b.status !== "live") return null; if (b.dataConfidence === "insufficient") return null; - const candidates = citationCandidates(b); - if (candidates.length === 0) return null; - const sorted = [...candidates].sort((a, c) => - b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50 - ); + const sorted = rankedCandidates(b); + if (sorted.length === 0) return null; return { name: sorted[0].name, slug: sorted[0].slug, value: sorted[0].ms.p50 }; } @@ -154,6 +203,13 @@ export type CiteBundle = { plain: string; bibtex: string; apa: string; + /** RIS record (Research Information Systems) for Zotero, EndNote, + * Mendeley and every academic reference manager that speaks the + * format. Same fields as bibtex, RIS field codes: TY (type), + * AU (author), TI (title), PY (year), UR (url), Y2 (retrieved + * date), ER (end record). Type GEN is the neutral fallback for a + * dataset citation — RIS has no "benchmark" type. */ + ris: string; }; const MONTHS = [ @@ -187,6 +243,17 @@ export function citeBundle( plain: `OpenChainBench. "${b.title}". Retrieved ${isoDate}. ${url}`, bibtex: `@misc{${bibKey},\n author = {OpenChainBench},\n title = {${b.title}},\n year = {${yyyy}},\n url = {${url}},\n note = {Retrieved ${isoDate}}\n}`, apa: `OpenChainBench. (${yyyy}). ${b.title}. Retrieved ${longDate}, from ${url}`, + // RIS records use CRLF line endings by convention (RFC-style), + // which every consumer we've tested (Zotero 6+, EndNote 20+, + // Mendeley) accepts either way — using \r\n stays safe. + ris: + `TY - GEN\r\n` + + `AU - OpenChainBench\r\n` + + `TI - ${b.title}\r\n` + + `PY - ${yyyy}\r\n` + + `UR - ${url}\r\n` + + `Y2 - ${isoDate}\r\n` + + `ER - \r\n`, }; } diff --git a/src/lib/compare/adhoc-pairs.ts b/src/lib/compare/adhoc-pairs.ts index 067b55dd..4e110b0a 100644 --- a/src/lib/compare/adhoc-pairs.ts +++ b/src/lib/compare/adhoc-pairs.ts @@ -56,8 +56,15 @@ export function adHocPairs(profiles: ProviderProfile[]): AdHocPair[] { const bBenches = liveBenchesBySlug.get(bSlug)!; let sharedLive = 0; for (const s of aBenches) if (bBenches.has(s)) sharedLive += 1; + // Buffer above the render-time noindex gate (< 2 live shared). The + // page recomputes on its own ISR clock, so a single bench flapping + // unavailable/back can drop `liveSharedCount` under 2 for one render + // window while the sitemap (force-dynamic) still lists the pair. + // Ahrefs then flags "noindex page in sitemap". Requiring >= 3 (brand) + // and >= 4 (otherwise) here means a single bench flap can't cross + // both thresholds simultaneously. const bothBrand = BRAND_WHITELIST.has(aSlug) && BRAND_WHITELIST.has(bSlug); - const threshold = bothBrand ? 2 : 3; + const threshold = bothBrand ? 3 : 4; if (sharedLive < threshold) continue; out.push({ a: aSlug, b: bSlug, slug: `${aSlug}-vs-${bSlug}` }); } diff --git a/src/lib/dataset-jsonld.ts b/src/lib/dataset-jsonld.ts index 567080b1..6db08662 100644 --- a/src/lib/dataset-jsonld.ts +++ b/src/lib/dataset-jsonld.ts @@ -18,6 +18,7 @@ * https://developers.google.com/search/docs/appearance/structured-data/dataset */ import { SITE } from "@/data/site"; +import { PERSON_ID } from "@/lib/hub-jsonld"; /** Public Hugging Face dataset mirror. CC-BY-4.0, daily parquet snapshots. */ export const HF_DATASET_URL = @@ -59,13 +60,25 @@ const KEYWORDS = [ "infrastructure", ]; -const CREATOR_PUBLISHER = { +/** Embedded Org node used for Dataset `creator` / `publisher`. Exported + * so pages emitting a Dataset outside the home graph can inline the + * full node (Google Search Console flags an `@id`-only reference as + * "missing field creator" when the referenced Org isn't declared on + * the same page). */ +export const CREATOR_PUBLISHER = { "@type": "Organization", "@id": `${SITE.url}/#org`, name: SITE.name, url: SITE.url, } as const; +/** Full JSON download of the current benchmark corpus. Used as the + * default `distribution.contentUrl` for Dataset nodes emitted outside + * `/benchmarks/[slug]` (products, alternatives, compare) so every + * Dataset carries a machine-readable download link, satisfying the + * Google Dataset "encodingFormat" / "contentUrl" requirements. */ +export const CITABLE_JSON_URL = `${SITE.url}/api/citable`; + /** * Site-wide Dataset entry. Emitted on the home page so Google Dataset * Search and Perplexity have a single canonical record pointing at both @@ -117,22 +130,85 @@ export const GLOBAL_DATASET_JSONLD = { /** Inputs for a per-bench Dataset entry. Keeps the call site at * /benchmarks/[slug] decoupled from the Benchmark type so the helper is * trivially reusable from the per-chain page and future variants. */ +/** Schema.org PropertyValue node used inside variableMeasured to publish + * a measured aggregate with its numeric value. This is the shape Google + * Dataset Search + academic LLM tools extract as a structured fact, + * unlike a bare label string which is opaque to a crawler. + * + * `value` is the current leader's p50 (or leader's p90/p99) expressed in + * the same unit as `unitText`; downstream consumers can key on + * `propertyID` for machine matching and on `name` for display. */ +export type VariableMeasuredValue = { + "@type": "PropertyValue"; + propertyID: string; + name: string; + value: number; + unitText: string; +}; + export type BenchDatasetInput = { slug: string; name: string; alternateName?: string; description: string; url: string; - /** Schema.org Dataset accepts variableMeasured as a string or array. - * We pass an array of metric labels (p50, p90, p99, sample_size, ...) - * so Google's validator reports each metric individually. */ - variableMeasured: string[]; + /** Schema.org Dataset accepts variableMeasured as a string, a + * PropertyValue, or an array mixing both. Pass PropertyValue objects + * when the current leader's numeric aggregates are available (Google + * Dataset Search / academic LLM tools index the numeric `value` + * directly); fall back to bare label strings for drafts or when the + * aggregate is `sample_size`-shaped rather than a per-percentile + * measurement. */ + variableMeasured: Array<string | VariableMeasuredValue>; category: string; datePublished: string; dateModified?: string; measurementTechnique?: string; }; +/** Build the `variableMeasured` array for a bench Dataset node. + * - When the leader carries a real numeric p50 (and unit), the p50 / + * p90 / p99 percentiles ship as PropertyValue objects so Google + * Dataset Search and academic LLM tools extract the numeric fact + * without parsing prose. + * - `sample_size` and the bare metric label stay as strings — they + * describe axes, not measured values. + * - When no defensible leader exists (draft, insufficient) the array + * degrades to the legacy string-only shape so we never publish a + * fabricated PropertyValue with a zero-fallback value. */ +export function buildBenchVariableMeasured(input: { + metric: string; + unit: string; + leader: { name: string; p50: number; p90: number; p99: number } | null; +}): Array<string | VariableMeasuredValue> { + if (!input.leader || input.leader.p50 <= 0) { + return [ + input.metric, + `${input.metric}_p50`, + `${input.metric}_p90`, + `${input.metric}_p99`, + "sample_size", + ]; + } + const mk = ( + suffix: "p50" | "p90" | "p99", + value: number, + ): VariableMeasuredValue => ({ + "@type": "PropertyValue", + propertyID: `${input.metric.toLowerCase().replace(/\s+/g, "_")}_${suffix}`, + name: `${input.metric} ${suffix}`, + value, + unitText: input.unit, + }); + return [ + input.metric, + mk("p50", input.leader.p50), + mk("p90", input.leader.p90), + mk("p99", input.leader.p99), + "sample_size", + ]; +} + /** Inputs for the per-bench StatisticalReport companion node. Wrapping * the single-leader claim as `StatisticalReport` + inline `Observation` * is the shape Google Dataset Search and Perplexity's grounding pipe @@ -185,6 +261,11 @@ export function buildBenchStatReportJsonLd( temporalCoverage: input.temporalCoverage, creator: { "@id": `${SITE.url}/#org` }, publisher: { "@id": `${SITE.url}/#org` }, + // Named contributor: the maintainer who signs off on the spec, + // harness and statistical review that produce this report. Distinct + // from creator/publisher (the Org) so the report carries both an + // institutional and a human attribution signal. + contributor: { "@id": PERSON_ID }, isBasedOn: { "@id": `${input.url}#dataset` }, license: DATASET_LICENSE, isAccessibleForFree: true, @@ -220,7 +301,12 @@ export function buildBenchDatasetJsonLd( keywords: [ input.category, ...KEYWORDS, - ...input.variableMeasured, + // Keywords should stay flat strings for indexer compat, so pull + // the display name off any PropertyValue entries rather than + // leaking `[object Object]` into the graph. + ...input.variableMeasured.map((v) => + typeof v === "string" ? v : v.name, + ), ], creator: CREATOR_PUBLISHER, publisher: CREATOR_PUBLISHER, diff --git a/src/lib/hub-jsonld.ts b/src/lib/hub-jsonld.ts index 445e062f..235dd026 100644 --- a/src/lib/hub-jsonld.ts +++ b/src/lib/hub-jsonld.ts @@ -8,6 +8,38 @@ const ORIGIN = "https://openchainbench.com"; +/** Canonical @id for the sole named maintainer of the project. Referenced + * from the site-wide Organization node (as `founder`), from every bench's + * StatisticalReport (as `contributor`), and from bench TechArticles (as + * `author` + `reviewedBy`). Anchoring a single Person @id sitewide is + * what lets Search Console + AI answer surfaces attribute the numbers + * to a named human, which is the missing E-E-A-T signal on data-driven + * editorial pages that would otherwise read as machine-authored. */ +export const PERSON_ID = `${ORIGIN}/team#florent-tapponnier`; + +export const PERSON_JSONLD = { + "@type": "Person", + "@id": PERSON_ID, + name: "Florent Tapponnier", + givenName: "Florent", + familyName: "Tapponnier", + url: `${ORIGIN}/team`, + mainEntityOfPage: `${ORIGIN}/team`, + jobTitle: "Maintainer", + knowsAbout: [ + "crypto infrastructure benchmarking", + "RPC latency measurement", + "oracle deviation", + "L2 finality", + "bridge fees", + "statistical methodology", + ], + sameAs: [ + "https://github.com/Flotapponnier", + "https://www.linkedin.com/in/florent-tapponnier-26324a17a/", + ], +} as const; + function breadcrumb(name: string, path: string) { return { "@context": "https://schema.org", @@ -95,6 +127,24 @@ export function pressPageLd() { ]; } +export function teamPageLd() { + const url = `${ORIGIN}/team`; + return [ + { + "@context": "https://schema.org", + "@type": "ProfilePage", + "@id": `${url}#profile`, + url, + name: "Team", + description: + "Who maintains OpenChainBench: spec review, harness design, statistical review and editorial corrections.", + mainEntity: { "@id": PERSON_ID }, + }, + { "@context": "https://schema.org", ...PERSON_JSONLD }, + breadcrumb("Team", "/team"), + ]; +} + export function mcpPageLd() { const url = `${ORIGIN}/mcp`; return [ diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 3df2764e..a365668a 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -108,6 +108,8 @@ const RAW: Record<string, string> = { drpc: "/logos/drpc.webp", "1rpc": "/logos/1rpc.svg", cloudflare: "/logos/cloudflare.svg", + parity: "/logos/parity.png", + polkadot: "/logos/polkadot.png", "base-official": "/logos/base.jpeg", binance: "/logos/binance.png", lava: "/logos/lava.webp", @@ -133,6 +135,9 @@ const RAW: Record<string, string> = { injective: "/logos/injective.svg", neutron: "/logos/neutron.svg", + // ─── HyperEVM RPC provider (bench 092) ─── + stakely: "/logos/stakely.svg", + // ─── Gas oracles ─── etherscan: "/logos/etherscan.svg", owlracle: "/logos/owlracle.webp", @@ -164,6 +169,21 @@ const RAW: Record<string, string> = { nextblock: "/logos/nextblock.png", astralane: "/logos/astralane.svg", solanavibestation: "/logos/solanavibestation.png", + leorpc: "/logos/leorpc.png", + aapl: "/logos/aapl.svg", + nvda: "/logos/nvda.svg", + googl: "/logos/googl.svg", + tsla: "/logos/tsla.svg", + pltr: "/logos/pltr.svg", + meta: "/logos/meta.svg", + amd: "/logos/amd.svg", + msft: "/logos/msft.svg", + amzn: "/logos/amzn.png", + spy: "/logos/spy.svg", + mu: "/logos/mu.png", + qqq: "/logos/qqq.png", + "orca-solana": "/logos/orca.png", + "pyth-market": "/logos/pyth.jpg", // ─── Buyback audit (bench 018) ─── sky: "/logos/sky.svg", @@ -365,6 +385,14 @@ const ALIASES: Record<string, string> = { // Long-tail RPC cluster (055-066). "sonic-official": "sonic", "monad-official": "monad", + "solana-official": "solana", + "solana-labs": "solana", + "polkadot-official": "parity", + "osmosis-official": "osmosis", + "hyperliquid-official": "hyperliquid", + trongrid: "tron", + hood: "robinhood", + coin: "coinbase", "megaeth-official": "megaeth", "celo-official": "celo", "blast-official": "blast", diff --git a/src/lib/materialize/load.ts b/src/lib/materialize/load.ts index 8c19ed90..150e8828 100644 --- a/src/lib/materialize/load.ts +++ b/src/lib/materialize/load.ts @@ -144,6 +144,7 @@ export function buildEditorial( source: spec.source, dimensions: spec.dimensions, ledgerColumns: spec.ledger_columns, + providerNotes: spec.provider_notes, }; } @@ -366,6 +367,58 @@ function activeFilterLabels(opts: BenchmarkFilters): Record<string, string> { return out; } + +/** + * Nullify every bucket in `coarse` whose covering time-range overlaps + * a null bucket in `fine`. Both series are dense right-anchored ("now" + * = last index), so index i in a series of length N maps to time-ago + * (N - 1 - i) / (N - 1) of the window. Each contiguous null RUN on the + * fine grid nulls every coarse bucket its range straddles, so a short + * outage detected on the 24h grid stays visible as a contiguous band + * on the 7d and 30d grids (not two neighbouring pills). + * + * No-op when either input is missing or too short. Mutates `coarse`. + * Exported for tests. + */ +export function propagateNullsToCoarser( + fine: (number | null)[] | null | undefined, + fineWindowSec: number, + coarse: (number | null)[] | null | undefined, + coarseWindowSec: number, +): void { + if (!fine || !coarse || fine.length < 2 || coarse.length < 2) return; + if (fineWindowSec <= 0 || coarseWindowSec <= 0) return; + const fineStep = fineWindowSec / (fine.length - 1); + const coarseStep = coarseWindowSec / (coarse.length - 1); + // Right-anchored: last index = now. Map a fine index to the same + // ABSOLUTE time-ago on the coarse grid (both grids share "now"; the + // step sizes are what differ, not the reference point). Previous + // math scaled by the fine window fraction, which shrank a 7 h ago + // outage down to 40 h ago once projected onto the 7 d grid. + const toIdxCoarse = (i: number): number => { + const secondsAgo = (fine.length - 1 - i) * fineStep; + return (coarse.length - 1) - secondsAgo / coarseStep; + }; + let runStart: number | null = null; + const closeRun = (endExclusive: number) => { + if (runStart == null) return; + const startX = toIdxCoarse(runStart); + const endX = toIdxCoarse(endExclusive); + const lo = Math.max(0, Math.floor(Math.min(startX, endX))); + const hi = Math.min(coarse.length - 1, Math.ceil(Math.max(startX, endX))); + for (let k = lo; k <= hi; k++) coarse[k] = null; + runStart = null; + }; + for (let i = 0; i < fine.length; i++) { + if (fine[i] === null) { + if (runStart == null) runStart = i; + } else { + closeRun(i); + } + } + closeRun(fine.length); +} + /** * Run the spec's `rank_matrix_query` (one instant vector with a sample per * (provider[, chain][, region])) and fold it into full per-cell rankings. @@ -538,6 +591,7 @@ function applyDimensionsToSpec(spec: Spec, labels: Record<string, string>): Spec success: inject(p.queries.success), sample_size: inject(p.queries.sample_size), series: inject(p.queries.series), + live_activity: inject(p.queries.live_activity), regions: p.queries.regions?.map((r) => ({ ...r, p50: inject(r.p50), @@ -546,6 +600,9 @@ function applyDimensionsToSpec(spec: Spec, labels: Record<string, string>): Spec } : p.queries, })), + prometheus: spec.prometheus + ? { ...spec.prometheus, probe_ok: inject(spec.prometheus.probe_ok) } + : spec.prometheus, }; } @@ -654,6 +711,19 @@ async function tryLoadLive( const sevenDaysSec = 7 * 86_400; const thirtyDaysSec = 30 * 86_400; + // Bench-level "is our end fine" gate. Fetched once per sweep; feeds + // every provider's liveStatus verdict below so a broken harness / Prom + // scrape can't fake-flag every provider as down at once. Absent when + // the spec doesn't declare probe_ok — in which case we trust each + // provider's live_activity unconditionally. + const probeOkQuery = spec.prometheus?.probe_ok; + const probeOk = probeOkQuery + ? await prom.scalar(probeOkQuery) + : null; + // Interpret: >0 or null-when-not-declared → trust per-provider verdicts. + // Explicit 0 (or NaN) → the probe itself is down; suppress all badges. + const trustLiveVerdicts = !probeOkQuery || (probeOk != null && probeOk > 0); + for (const p of spec.providers) { const q = p.queries; if (!q) return null; @@ -663,12 +733,13 @@ async function tryLoadLive( q.p90 ? prom.scalar(q.p90) : Promise.resolve(null), q.p99 ? prom.scalar(q.p99) : Promise.resolve(null), ]); - const [mean, success, sampleSize, slotP50, slotP99] = await Promise.all([ + const [mean, success, sampleSize, slotP50, slotP99, liveActivity] = await Promise.all([ q.mean ? prom.scalar(q.mean) : Promise.resolve(null), q.success ? prom.scalar(q.success) : Promise.resolve(null), q.sample_size ? prom.scalar(q.sample_size) : Promise.resolve(null), q.slot_p50 ? prom.scalar(q.slot_p50) : Promise.resolve(null), q.slot_p99 ? prom.scalar(q.slot_p99) : Promise.resolve(null), + q.live_activity ? prom.scalar(q.live_activity) : Promise.resolve(null), ]); // One retry on the load-bearing percentiles. A null here is either @@ -734,6 +805,22 @@ async function tryLoadLive( continue; } + // liveStatus: only computed when the spec declares live_activity. + // "unknown" wins whenever we can't tell (probe_ok says our side is + // broken, or the activity query returned no sample) — never falls + // through to "down" on ambiguous data, since a false red pill on + // a live provider is worse than a missing pill on a real outage. + let liveStatus: "healthy" | "down" | "unknown" | undefined; + if (q.live_activity) { + if (!trustLiveVerdicts || liveActivity == null) { + liveStatus = "unknown"; + } else if (liveActivity > 0) { + liveStatus = "healthy"; + } else { + liveStatus = "down"; + } + } + liveResults.push({ name: p.name, slug: p.slug, @@ -750,6 +837,7 @@ async function tryLoadLive( secondary: p.secondary, query: q.p50, formula: p.formula, + liveStatus, }); if (q.series) { @@ -758,6 +846,15 @@ async function tryLoadLive( prom.series(q.series, sevenDaysSec, 84), prom.series(q.series, thirtyDaysSec, 60), ]); + // Propagate short outages captured on the fine 24h grid up to + // the coarser 7d/30d grids. Only when the bench opted in via + // `live_activity` — other benches carry natural nulls (sparse + // scrapes, backfill edges) that shouldn't fire a "DATA MISSING" + // pill on the chart. + if (q.live_activity && s24 && s24.length > 0) { + propagateNullsToCoarser(s24, winSec, s7, sevenDaysSec); + propagateNullsToCoarser(s24, winSec, s30, thirtyDaysSec); + } if (s24 && s24.length > 0) series24h[p.slug] = s24; if (s7 && s7.length > 0) series7d[p.slug] = s7; if (s30 && s30.length > 0) series30d[p.slug] = s30; @@ -783,6 +880,11 @@ async function tryLoadLive( ); regions[p.slug] = points.map(({ region: rg, p50: v }) => ({ region: rg, p50: v })); for (const pt of points) { + // Same short-outage propagation as the global series above. + if (q.live_activity && pt.series24 && pt.series24.length > 0) { + propagateNullsToCoarser(pt.series24, winSec, pt.series7, sevenDaysSec); + propagateNullsToCoarser(pt.series24, winSec, pt.series30, thirtyDaysSec); + } if (pt.series24 && pt.series24.length > 0) { (seriesByRegion24h[p.slug] ??= {})[pt.region] = pt.series24; } diff --git a/src/lib/pm-stats.ts b/src/lib/pm-stats.ts index 5afd3552..fef81647 100644 --- a/src/lib/pm-stats.ts +++ b/src/lib/pm-stats.ts @@ -89,7 +89,9 @@ const PM_VENUES: VenueSeed[] = [ ]; const PM_DATA_FEEDS: DataFeedSeed[] = [ - { slug: "mobula", name: "Mobula", coverage: ["polymarket"], isReference: false }, + // Mobula dropped 2026-07-19: they stopped serving the PM WebSocket + // relay, so the hub cohort and pm-data-freshness bench no longer + // rank a dead endpoint. { slug: "codex", name: "Codex", coverage: ["polymarket", "kalshi"], isReference: false }, { slug: "predexon", name: "Predexon", coverage: ["polymarket", "kalshi", "limitless"], isReference: false }, ]; diff --git a/src/lib/pm-venue-context.ts b/src/lib/pm-venue-context.ts index 086d1baa..bf9c10fd 100644 --- a/src/lib/pm-venue-context.ts +++ b/src/lib/pm-venue-context.ts @@ -34,7 +34,6 @@ export const PM_VENUE_META: Record<string, { url: string; chainLabel: string }> // mobula, and predexon. export const PM_FEED_META: Record<string, { url?: string }> = { "polymarket-clob": { url: "https://polymarket.com" }, - mobula: { url: "https://mobula.io" }, codex: { url: "https://codex.io" }, predexon: { url: "https://predexon.com" }, }; diff --git a/src/lib/providers.ts b/src/lib/providers.ts index cd65780e..3ce54b77 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -12,6 +12,7 @@ import { cache } from "react"; import { unstable_cache } from "next/cache"; import { getBenchmarksSafe } from "@/data/benchmarks"; import { liveResults } from "@/lib/provider-filters"; +import { citationCandidates } from "@/lib/citation"; import { readBestPerChain } from "@/lib/per-chain-contract"; import type { Benchmark, ProviderResult } from "@/types/benchmark"; @@ -40,6 +41,17 @@ const PRODUCT_ALIASES: Record<string, string> = { "avalanche-official": "avalanche", "base-official": "base", "optimism-official": "optimism", + // Solana chain-official RPC collapses to the Solana chain brand. + // Legacy slugs `solana-official` (rpc-capabilities) and `solana-labs` + // (ws-head-latency) resolve to the same canonical entry so old inbound + // links keep working while the harnesses re-emit under provider="solana". + "solana-official": "solana", + "solana-labs": "solana", + // Polkadot's "official" endpoint is Parity-operated, so the product + // page for the endpoint slug collapses to the Parity brand rather + // than a synthetic "Polkadot Foundation RPC" entry (there is no such + // separate operator). + "polkadot-official": "parity", // Oracle pair → underlying chain / asset "eth-usd": "ethereum", "sol-usd": "solana", @@ -74,6 +86,10 @@ const CANONICAL_NAMES: Record<string, string> = { arbitrum: "Arbitrum", base: "Base", optimism: "Optimism", + polkadot: "Polkadot", + parity: "Parity", + onfinality: "OnFinality", + dwellir: "Dwellir", // NFT bench 040 providers — explicit brand casing. opensea: "OpenSea", // Other brand casings frequently referenced. @@ -212,7 +228,15 @@ export type ProviderProfile = { }; function rankProviders(b: Benchmark): ProviderResult[] { - const live = liveResults(b.results); + // Drives the /products hub `wins` count and per-chain leadership + // chips. Shares the reliability + insufficient-sample filters with + // `leader()` (citation.ts) so a provider that is filtered from the + // bench headline never gets credited as a bench winner on its + // product page. Falls back to the raw live pool when every + // candidate is filtered so a totally degraded bench still surfaces + // a best-of-bad-options ranking. + const pool = citationCandidates(b); + const live = pool.length > 0 ? pool : liveResults(b.results); return [...live].sort((a, c) => b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50, ); diff --git a/src/lib/removed-benches.ts b/src/lib/removed-benches.ts index d7823ee9..6e2ae915 100644 --- a/src/lib/removed-benches.ts +++ b/src/lib/removed-benches.ts @@ -32,10 +32,33 @@ export const REMOVED_BENCH_SLUGS = new Set([ // duplicate of solana-tx-landing (bench 016); the 027 active-probe // variant never got data on prod and shows an empty placeholder "solana-tx-landing-latency", + // indexer-latency (084) dropped entirely 2026-07-16: HyperSync + The + // Graph providers require paid credentials for the sustained cadence + // (Mobula alone left the leaderboard single-provider). Spec + harness + // removed; kept in the 410 list so any indexed URL returns Gone + // instead of 404. + "indexer-latency", + // oracle-freshness (082) spec dropped 2026-07-16: apples vs oranges + // (push oracle Chainlink vs pull oracles Pyth/RedStone measure + // different things; splitting into 2 sub-benches would leave each + // with 1-2 providers). Redundant with 025 oracle-deviation which + // measures the same feeds via CEX deviation, more actionable signal. + // Harness code retained in oracle-deviation (metrics still emit, no + // reader on the site). + "oracle-freshness", + // tokenized-stock-arb-latency (080) dropped 2026-07-16 after ship- + // then-revert: Robinhood Chain tokenized equity pools are too + // illiquid (5 of 11 tickers had frozen pool prices, remaining 6 had + // p99 pinned at the 32min histogram cap because arbs rarely close + // within the settle window). Rob Chain launched Mar 2026, market + // still too young for an arb-latency bench. Spec deleted + arb_tracker + // removed from tokenized-stock-peg harness. Kept in 410 list for any + // indexed URL. Bench 077 tokenized-stock-peg + 079 weekend-drift + // (static peg measurements) stay on prod as the honest signal. + "tokenized-stock-arb-latency", // staging pipeline, held back until validated / announced "indexing-freshness", "rpc-keyed-latency", "explorer-chain-coverage", - "solana-rpc", "portfolio-chain-coverage", ]); diff --git a/src/lib/rpc-hub-stats.ts b/src/lib/rpc-hub-stats.ts index 8575e3ce..426cd5c1 100644 --- a/src/lib/rpc-hub-stats.ts +++ b/src/lib/rpc-hub-stats.ts @@ -30,6 +30,7 @@ import { } from "@/lib/materialize/load"; import { REMOVED_BENCH_SLUGS } from "@/lib/removed-benches"; import { readMaterialized, storeConfigured } from "@/lib/materialize/store"; +import { loadSnapshotFromBlob } from "@/lib/bench-blob"; import { chainLabelForSlug } from "@/lib/chains"; import { matchesChainSlug } from "@/lib/chain-aliases"; import type { Benchmark, ProviderResult } from "@/types/benchmark"; @@ -173,7 +174,10 @@ function liveRows(bench: Benchmark): ProviderResult[] { } async function buildChain(spec: Spec): Promise<RpcHubChain | null> { - const snap = await readMaterialized(spec.slug, ""); + // Try CDN blob first (Phase 3), fall back to Redis via SRH. + const snap = + (await loadSnapshotFromBlob(spec.slug, "")) ?? + (await readMaterialized(spec.slug, "")); if (!snap) return null; const bench = snap.bench; const rows = liveRows(bench); @@ -193,9 +197,14 @@ async function buildChain(spec: Spec): Promise<RpcHubChain | null> { } } const variantSnaps = await Promise.all( - RPC_REGION_KEYS.map((region) => - readMaterialized(spec.slug, filterSig({ region })).catch(() => null), - ), + RPC_REGION_KEYS.map(async (region) => { + const sig = filterSig({ region }); + // Try CDN blob first, fall back to Redis via SRH. + return ( + (await loadSnapshotFromBlob(spec.slug, sig).catch(() => null)) ?? + (await readMaterialized(spec.slug, sig).catch(() => null)) + ); + }), ); for (let i = 0; i < RPC_REGION_KEYS.length; i++) { const region = RPC_REGION_KEYS[i]; diff --git a/src/lib/search-featured.ts b/src/lib/search-featured.ts index 8345d860..483cc0ba 100644 --- a/src/lib/search-featured.ts +++ b/src/lib/search-featured.ts @@ -15,6 +15,7 @@ import { getBenchmarks } from "@/data/benchmarks"; import { readMaterialized } from "@/lib/materialize/store"; +import { loadSnapshotFromBlob } from "@/lib/bench-blob"; import { leader, fieldValue } from "@/lib/citation"; const FEATURED_BENCH_SLUGS = [ @@ -104,7 +105,10 @@ export async function buildFeaturedLeadersFromStore(): Promise<FeaturedLeadersBl const snapshots = await Promise.all( ALL_SLUGS.map(async (slug) => { try { - const snap = await readMaterialized(slug, ""); + // Try CDN blob first (Phase 3), fall back to Redis via SRH. + const snap = + (await loadSnapshotFromBlob(slug, "")) ?? + (await readMaterialized(slug, "")); return snap ? snap.bench : null; } catch { return null; diff --git a/src/lib/snapshot.ts b/src/lib/snapshot.ts index 89e658bd..bc397e3d 100644 --- a/src/lib/snapshot.ts +++ b/src/lib/snapshot.ts @@ -86,6 +86,7 @@ const ProviderResultSchema = z.object({ meta: StalenessMetaSchema.optional(), query: z.string().optional(), formula: z.string().optional(), + liveStatus: z.enum(["healthy", "down", "unknown"]).optional(), }); const RegionPointSchema = z.object({ diff --git a/src/lib/spec-schema.ts b/src/lib/spec-schema.ts index 47fc2eeb..36bbcd87 100644 --- a/src/lib/spec-schema.ts +++ b/src/lib/spec-schema.ts @@ -80,6 +80,14 @@ const queries = z success: promql.optional(), sample_size: promql.optional(), series: promql.optional(), + /** Short-window "is this provider's live feed producing new events + * right now" probe. Instant query returning a scalar count > 0 when + * fresh events arrived in the past few minutes, 0 when the source + * is silent. Distinct from `success` (24h rolling reliability), + * which is too slow to move on a 30-60 min outage. Rendered as a + * "Feed down" pill + top-of-page banner when 0 and probe_ok + * confirms our end is fine. */ + live_activity: promql.optional(), /** Optional slot-level companion queries. Solana-native benches set * these to surface slot_delta p50/p99 alongside the ms columns. The * ms numbers are wall-clock derived; slot_delta is the canonical @@ -158,6 +166,7 @@ export const Category = z.enum([ "RPCs", "NFT APIs", "Explorers", + "RWA", ]); // Em-dash (—) and en-dash (–) are the classic "AI tells" that hurt our brand @@ -308,6 +317,15 @@ export const SpecSchema = z .string() .regex(/^[a-zA-Z_:][a-zA-Z0-9_:]*$/, "Must be a bare metric name") .optional(), + /** Bench-level sanity check for the per-provider `live_activity` + * probe: an instant query that must be > 0 for the "Feed down" + * UI to trust its per-provider verdicts. Meant to answer "are + * ANY of our probes still emitting". Zero means our end + * (harness / Prom scrape) is broken, not the provider — so the + * UI suppresses all per-provider down badges to avoid falsely + * blaming every source at once. When omitted, per-provider + * activity is trusted unconditionally. */ + probe_ok: promql.optional(), }) .optional(), @@ -356,6 +374,20 @@ export const SpecSchema = z }) .optional(), + /** + * Optional per-provider chip labels rendered next to the provider + * name in the ranking row. Keys are provider slugs, values are the + * short chip text (max ~20 chars so it fits inline without wrapping). + * Used for scan-time context on comparisons that would otherwise read + * as apples-to-oranges (e.g. validator-yield: Solana includes MEV, + * Ethereum excludes it, Hyperliquid has no MEV layer). Only providers + * with a note render a chip; benches without provider_notes render + * unchanged. + */ + provider_notes: z + .record(slug, z.string().min(1).max(30).refine(noAiDashes, noAiDashesMsg)) + .optional(), + /* Optional single PromQL returning one instant sample per * (provider[, chain][, region]) — e.g. * avg by (provider, chain, region) (quantile_over_time(0.50, m[24h])) diff --git a/src/lib/spec.ts b/src/lib/spec.ts index 7f9ea805..38071a47 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -10,7 +10,7 @@ */ import { cache } from "react"; -import { unstable_cache } from "next/cache"; +import { unstable_cache, unstable_noStore } from "next/cache"; import type { Benchmark } from "@/types/benchmark"; import type { Spec } from "@/lib/spec-schema"; // Imported from the isolated alias module (NOT @/lib/chains) to avoid @@ -26,6 +26,8 @@ import { type BenchmarkFilters, } from "@/lib/materialize/load"; import { readMaterialized } from "@/lib/materialize/store"; +import { loadAggregateFromBlob } from "@/lib/aggregate-blob"; +import { loadBenchFromBlob, loadVariantFromBlob } from "@/lib/bench-blob"; export type { Spec } from "@/lib/spec-schema"; export type { BenchmarkFilters } from "@/lib/materialize/load"; @@ -63,7 +65,7 @@ async function benchFromStore( * preserved from the store so the snapshot's measurement payload is * untouched. */ -function overlayEditorial(stored: Benchmark, spec: Spec): Benchmark { +export function overlayEditorial(stored: Benchmark, spec: Spec): Benchmark { // Reconcile stale provider entries in the stored snapshot against the // current spec. The materialize worker may have written a snapshot // BEFORE a chain rename rolled through the YAMLs (e.g. ton → gram). @@ -124,6 +126,11 @@ function overlayEditorial(stored: Benchmark, spec: Spec): Benchmark { // and the bench page filters without waiting on the materialise // worker to rewrite the snapshot. dimensions: spec.dimensions ?? stored.dimensions, + // provider_notes is a YAML editorial declaration: drives the + // per-provider chip rendered next to the name in the ranking row. + // Overlay so newly added notes surface immediately without waiting + // on the materialize worker to rewrite the snapshot. + providerNotes: spec.provider_notes ?? stored.providerNotes, // expected_n is a YAML editorial declaration too: drives the // sample-health badge logic on the page + the citable APIs. A // freshly added/edited value must take effect immediately, before @@ -165,7 +172,7 @@ function overlayEditorial(stored: Benchmark, spec: Spec): Benchmark { // /api/series serves these on demand, CDN-cached (60 s s-maxage + 300 s // SWR) so the cache miss only hits Prom once per (bench, range) per // minute regardless of concurrent visitor count. -function slimBenchmarkForCache(b: Benchmark): Benchmark { +export function slimBenchmarkForCache(b: Benchmark): Benchmark { const slimPanels = b.metricPanels?.map((panel) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { seriesByProvider7d, seriesByProvider30d, ...rest } = panel; @@ -194,13 +201,25 @@ const loadBenchmarkUnfilteredCached = unstable_cache( const specs = await loadSpecs(); const spec = specs.find((s) => s.slug === slug); if (!spec) return undefined; - // Blob-only: serve whatever the worker last published. Missing or - // unparseable blobs fall through to the aggregator's draft - // placeholder instead of fanning out a Prom build at render time — - // that fan-out is what saturated Prom's query queue under Vercel - // ISR re-render bursts. + // Fast path: per-bench blob written by the materialize worker + // after every tier A sweep. One CDN fetch (~20 ms edge, ~1 s cold) + // replaces the 2-3 SRH GETs this used to run. See bench-blob.ts + // and worker/publish-aggregate.ts. + const fromBlob = await loadBenchFromBlob(slug); + if (fromBlob) return slimBenchmarkForCache(overlayEditorial(fromBlob, spec)); + // Fallback: Redis-via-SRH. Kept until Phase 3 fully retires SRH. const stored = await benchFromStore(slug, ""); if (stored) return slimBenchmarkForCache(overlayEditorial(stored, spec)); + if (spec.status === "live") { + // A live spec with no readable blob is a transient store failure + // (proxy timeout, worker mid-write), not a real state. Returning + // undefined would cache the miss for the whole revalidate window: + // home row + /api/stat then serve "Awaiting samples" while the + // bench page reads fine (bnb-rpc incident, 2026-07-13). Throwing + // makes unstable_cache keep the last good entry; cold-cache + // callers already catch and fall back to the draft placeholder. + throw new Error(`store blob missing for live bench ${slug}`); + } return undefined; }, // Version key bumped when Benchmark shape changes so stale cache entries @@ -273,7 +292,19 @@ const loadBenchmarkUnfilteredCached = unstable_cache( // buckets (gap rendering fix). Cached v26 entries hold the old // hole-compressed arrays whose indices no longer map onto the nominal // step grid the chart back-computes timestamps from. - ["bench-unfiltered-v27", process.env.VERCEL_ENV === "production" ? "prod" : "all"], +// v28: bench vague 2 ships 081-085 (ws-head-latency, oracle-freshness, + // rpc-reliability, indexer-latency, evm-block-builders). Bench SET changed. + // v30: 081 slug renamed ws-head-latency -> ws-head-latency-ethereum for + // parity with the base + solana siblings + ungated on prod. Bench SET + // changed (old slug gone, new slug live). Bump to purge cached v29 that + // still keys the old slug. + // v31: 084 indexer-latency dropped entirely (spec + harness deleted; + // HyperSync + The Graph required paid credentials for sustained + // cadence). Bench SET shrank. + // v38: mobula dropped from pm-data-freshness (they stopped serving 2026-07-19). + // v39: bench 091 osmosis-rpc ship (Cosmos SDK Kind added to harness). + // v40: benches 092 hyperliquid-rpc + 093 tron-rpc ship (bench SET grew by 2). + ["bench-unfiltered-v40", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] }, ); @@ -310,11 +341,56 @@ export class AllBenchmarksDraftError extends Error { * suite can exercise the error path without standing up Prometheus or * the Next cache backend. */ +/** Concurrency cap for the parallel per-bench Redis fan-out below. + * + * The naive `Promise.allSettled(specs.map(loadOne))` fires ~68 read + * chains at once (each does GET pointer → GET blob → optional GET lkg = + * ~150-200 Redis ops), which reliably saturated SRH's request pool and + * starved the alphabetically-later specs (`perp-open-interest` → `zksync-rpc`) + * into 8s timeouts — they then rendered as draft placeholders on the + * homepage even though their blobs were sitting in Redis the whole time. + * + * Cap at 12 in-flight chains so the total concurrent Redis ops stay + * well under the pool ceiling. The aggregate is behind unstable_cache + * (revalidate 300 s) so the slightly-longer worst-case wall time on + * cache miss is invisible to end users. */ +const AGGREGATE_LOAD_CONCURRENCY = 12; + +async function limitedAllSettled<T, R>( + items: T[], + concurrency: number, + fn: (item: T) => Promise<R>, +): Promise<PromiseSettledResult<R>[]> { + const results: PromiseSettledResult<R>[] = new Array(items.length); + let cursor = 0; + const worker = async () => { + while (true) { + const i = cursor++; + if (i >= items.length) return; + try { + results[i] = { status: "fulfilled", value: await fn(items[i]) }; + } catch (reason) { + results[i] = { status: "rejected", reason }; + } + } + }; + const workers = Array.from( + { length: Math.min(concurrency, items.length) }, + () => worker(), + ); + await Promise.all(workers); + return results; +} + export async function aggregateBenchmarks( specs: Spec[], loadOne: (slug: string) => Promise<Benchmark | undefined>, ): Promise<Benchmark[]> { - const settled = await Promise.allSettled(specs.map((s) => loadOne(s.slug))); + const settled = await limitedAllSettled( + specs, + AGGREGATE_LOAD_CONCURRENCY, + (s) => loadOne(s.slug), + ); const benchmarks: Benchmark[] = []; for (let i = 0; i < specs.length; i++) { const spec = specs[i]; @@ -428,7 +504,14 @@ const loadAllBenchmarksCached = unstable_cache( // gated catalog to /products for 30+ min after the deploy). // v29: bumped with bench-unfiltered-v26 (monad-rpc + megaeth-rpc ship). // v30: bumped with bench-unfiltered-v27 (dense series with nulls). - ["all-benchmarks-v30", process.env.VERCEL_ENV === "production" ? "prod" : "all"], +// v31: bumped with bench-unfiltered-v28 (bench vague 2, 081-085). + // v32: bumped with bench-unfiltered-v30 (081 slug rename ws-head-latency + // -> ws-head-latency-ethereum + ungate on prod). + // v34: bumped with bench-unfiltered-v31 (084 indexer-latency dropped). + // v41: bumped in lockstep with bench-unfiltered-v38 (mobula drop). + // v42: bumped in lockstep with bench-unfiltered-v39 (osmosis-rpc ship). + // v43: bumped in lockstep with bench-unfiltered-v40 (hyperliquid-rpc + tron-rpc ship). + ["all-benchmarks-v43", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] }, ); export const loadAllBenchmarks = cache(loadAllBenchmarksCached); @@ -451,10 +534,34 @@ export const loadAllBenchmarks = cache(loadAllBenchmarksCached); */ export const loadAllBenchmarksSafe = cache( async (): Promise<Benchmark[]> => { + // Fast path: CDN-cached aggregate blob written by the materialize + // worker every ~60 s (see src/lib/aggregate-blob.ts and + // worker/publish-aggregate.ts). One HTTP fetch replaces the ~150 + // concurrent SRH GETs the per-bench aggregator used to fan out on + // every homepage revalidate — that fan-out is what saturated SRH's + // pool and starved alphabetically-later specs into draft + // placeholders. Any failure (network, schema, quorum) falls through + // to the Redis-via-SRH path below, so the switch is safe: the worst + // case is what we had before this landed. + const fromBlob = await loadAggregateFromBlob().catch(() => null); + if (fromBlob && fromBlob.length >= 40) { + return fromBlob; + } + try { return await loadAllBenchmarksCached(); } catch (err) { if (err instanceof AllBenchmarksDraftError) { + // Opt this render out of every cache layer (ISR + CDN edge). When + // the aggregate quorum is lost (SRH stuck, Redis blackout, worker + // starved) the placeholder response is a degraded surrogate, NOT + // ground truth. Without noStore the placeholder HTML would sit at + // Vercel Edge for `revalidate` seconds and — under stale-while- + // revalidate — for hours after backend recovery, since SWR only + // refreshes on cache miss. Marking the response no-store forces + // every subsequent hit to re-render, which immediately picks up + // real data as soon as SRH is back — no cache-key bump, no deploy. + unstable_noStore(); const specs = await loadSpecs(); return specs .map((s) => draftPlaceholderForSpec(s)) @@ -481,11 +588,12 @@ const loadBenchmarkFiltered = unstable_cache( const specs = await loadSpecs(); const spec = specs.find((s) => s.slug === slug); if (!spec) return undefined; - // Blob-only: variant blobs are published by the worker's tier-B - // sweep. A missing variant blob means the worker has not covered - // this filter combination yet — return undefined so the caller can - // fall back to the unfiltered "All" view rather than spinning up a - // render-time Prom build. + // Fast path: per-variant blob written by the worker's tier B + // sweep. Missing blob → this filter combo hasn't been covered yet + // (or the tier B has never run since worker start) — fall through + // to Redis-via-SRH. + const fromBlob = await loadVariantFromBlob(slug, sig); + if (fromBlob) return slimBenchmarkForCache(overlayEditorial(fromBlob, spec)); const stored = await benchFromStore(slug, sig); if (stored) return slimBenchmarkForCache(overlayEditorial(stored, spec)); return undefined; @@ -507,7 +615,8 @@ const loadBenchmarkFiltered = unstable_cache( // v16: bumped with the bench 074 ship (lockstep rule, see all-benchmarks-v28). // v17: bumped with the monad-rpc + megaeth-rpc ship (lockstep rule). // v18: bumped with bench-unfiltered-v27 (dense series with nulls). - ["bench-filters-v18", process.env.VERCEL_ENV === "production" ? "prod" : "all"], + // v19: bumped with bench-unfiltered-v28 (bench vague 2, 081-085). + ["bench-filters-v20", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] } ); diff --git a/src/types/benchmark.ts b/src/types/benchmark.ts index c213c3ef..d7ef0e4e 100644 --- a/src/types/benchmark.ts +++ b/src/types/benchmark.ts @@ -94,6 +94,20 @@ export type ProviderResult = { * headline value is computed. Rendered as the leaderboard-row * hover tooltip. Authored per-bench in YAML (provider.formula). */ formula?: string; + /** Short-window liveness verdict derived at load time from the spec's + * `queries.live_activity` scalar and the bench-level `probe_ok` + * gate. Only populated when the spec declares those queries. + * - "healthy": recent events arrived (activity > 0). + * - "down": no recent events AND probe_ok confirmed our end is + * fine — the provider's live feed is silent, values + * shown are last-known. Renderers should badge it. + * - "unknown": probe_ok reports our side is broken (harness or + * Prom scrape), OR the activity query itself + * returned no sample. Suppress the badge either way + * so a probe hiccup can't fake-flag every provider. + * Absent when the spec doesn't declare live_activity; UI must + * behave identically to today for those benches. */ + liveStatus?: "healthy" | "down" | "unknown"; }; /** One provider's standing inside a (chain, region) ranking cell. */ @@ -215,7 +229,7 @@ export type Benchmark = { kind?: { value: string; label: string }[]; venue?: { value: string; label: string }[]; }; - category: "Aggregators" | "Bridges" | "Blockchains" | "Trading" | "Wallets" | "RPCs" | "NFT APIs" | "Explorers"; + category: "Aggregators" | "Bridges" | "Blockchains" | "Trading" | "Wallets" | "RPCs" | "NFT APIs" | "Explorers" | "RWA"; results: ProviderResult[]; /** Per-chain leader, computed only on the unfiltered ("All chains") view * when the spec declares `dimensions.chain`. Key = chain slug from the @@ -252,6 +266,13 @@ export type Benchmark = { * read as the global chain leader). Cells make the honest claim * ("#1 on BNB Chain from Singapore") computable. */ cellRanks?: Record<string, CellRankEntry[]>; + /** Optional per-provider chip labels keyed by provider slug. When set, + * the ranking row renders a small pill next to the provider name so + * scan-readers see context on comparisons that would otherwise be + * apples-to-oranges (validator-yield: Solana "MEV inc.", Ethereum + * "MEV excl.", Hyperliquid "no MEV"). Absent on benches whose spec + * does not declare `provider_notes` — UI renders unchanged. */ + providerNotes?: Record<string, string>; findings: string[]; methodology: string[]; source: string; diff --git a/worker/index.ts b/worker/index.ts index ddac2b5f..e5f2745b 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -58,6 +58,7 @@ import { CHAINS } from "@/lib/chains"; import { buildFeaturedLeadersFromStore } from "@/lib/search-featured"; import type { Benchmark, MetricPanel } from "@/types/benchmark"; import type { Spec } from "@/lib/spec-schema"; +import { publishAggregate, publishVariants } from "./publish-aggregate"; const SWEEP_SEC = Number(process.env.SWEEP_SEC ?? 60); const VARIANT_EVERY = Number(process.env.VARIANT_EVERY ?? 5); @@ -208,7 +209,7 @@ async function materializeOne( await publishSnapshot(snap); } -function variantCombos(spec: Spec): BenchmarkFilters[] { +export function variantCombos(spec: Spec): BenchmarkFilters[] { const dims = spec.dimensions ?? {}; const chains = (dims.chain ?? []).map((d) => d.value).filter((v) => v !== "all"); const regions = (dims.region ?? []).map((d) => d.value).filter((v) => v !== "all"); @@ -352,6 +353,26 @@ async function sweep(iteration: number): Promise<void> { (e) => noteHeartbeat(false, e), ); + // Broadcast the fresh aggregate to a static file that Caddy serves + // publicly. The site then reads that URL from the CDN edge instead of + // fanning out ~150 concurrent Redis GETs per homepage render. See + // worker/publish-aggregate.ts for the full rationale. No-op when + // AGGREGATE_OUTPUT_PATH is unset (staged rollout). + if (process.env.AGGREGATE_OUTPUT_PATH) { + const pubStart = Date.now(); + const result = await publishAggregate(specs); + const elapsedSec = ((Date.now() - pubStart) / 1000).toFixed(1); + if (result.ok) { + console.log( + `[worker] aggregate published in ${elapsedSec}s (${result.bytes} bytes, ${result.liveCount}/${result.total} live, revalidated=${result.revalidated})`, + ); + } else { + console.warn( + `[worker] aggregate publish failed in ${elapsedSec}s: ${result.error}`, + ); + } + } + // Cohort snapshots used by the hub pages and the search dialog. Each // builder hits Prom directly (via the in-network http://ocb-prom:9090 // URL), so they don't add load on the public reverse proxy. Failures @@ -439,6 +460,26 @@ async function sweep(iteration: number): Promise<void> { } }); console.log(`[worker] tierB done in ${((Date.now() - tB0) / 1000).toFixed(1)}s (${jobs.length} variants)`); + + // Broadcast per-variant blobs so the site's filter-tab reads + // (/benchmarks/[slug]/[chain] etc.) go to the CDN edge instead of + // fanning out through SRH. Mirrors the tier-A per-bench publish + // in publishAggregate. + if (process.env.AGGREGATE_OUTPUT_PATH) { + const vpStart = Date.now(); + const result = await publishVariants(specs, variantCombos); + const elapsedSec = ((Date.now() - vpStart) / 1000).toFixed(1); + if (result.ok) { + console.log( + `[worker] variants published in ${elapsedSec}s (${result.count} files, ${result.bytes} bytes, ${result.errors ?? 0} errors)`, + ); + } else { + console.warn( + `[worker] variants publish failed in ${elapsedSec}s: ${result.error}`, + ); + } + } + await warmUrls( jobs.map(({ spec, filters }) => variantPath(spec.slug, filters)), "variants", diff --git a/worker/publish-aggregate.ts b/worker/publish-aggregate.ts new file mode 100644 index 00000000..d36a8d40 --- /dev/null +++ b/worker/publish-aggregate.ts @@ -0,0 +1,264 @@ +/** + * Aggregate + per-bench + per-variant snapshot broadcaster (VPS-served). + * + * Layout of the files this module writes into `AGGREGATE_OUTPUT_PATH`, + * which Caddy exposes publicly at `https://kv.openchainbench.com/aggregate/*`: + * + * latest.json All 68 unfiltered benches + * (homepage source of truth, + * 4-5 MB, ~1.5 MB gzipped). + * benches/<slug>.json One full unfiltered bench + * (per-bench detail pages, + * /api/series/<slug>). + * variants/<slug>/<sig>.json One filtered variant + * (per-chain, per-region, + * per-kind, per-venue pages). + * + * Every write is atomic (`.tmp` → `rename`) so a Caddy `file_server` GET + * never observes a torn read. + * + * Purpose: eliminate the SRH HTTP-to-Redis proxy from the site's read + * path entirely. The site fetches these files from the CDN edge instead + * of fanning out Redis GETs through SRH, which was chronically bursting + * timeouts (~27 auto-heal incidents / 24 h before this shipped). + * + * Fail-soft: any single file write failure is warned and swallowed; + * one bad bench doesn't block the rest of the cycle. + */ + +import { mkdir, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { Benchmark } from "@/types/benchmark"; +import type { Spec } from "@/lib/spec-schema"; +import { + draftPlaceholderForSpec, + filterSig, + type BenchmarkFilters, +} from "@/lib/materialize/load"; +import { readMaterialized } from "@/lib/materialize/store"; + +export type PublishResult = { + ok: boolean; + filePath?: string; + bytes?: number; + total?: number; + liveCount?: number; + draftCount?: number; + perBenchOk?: number; + perBenchBytes?: number; + revalidated?: boolean; + error?: string; +}; + +export type VariantsPublishResult = { + ok: boolean; + count?: number; + bytes?: number; + errors?: number; + error?: string; +}; + +const AGGREGATE_FILENAME = "latest.json"; +const BENCHES_SUBDIR = "benches"; +const VARIANTS_SUBDIR = "variants"; + +async function atomicWrite(finalPath: string, body: string): Promise<void> { + const tmpPath = `${finalPath}.tmp`; + await writeFile(tmpPath, body, "utf-8"); + await rename(tmpPath, finalPath); +} + +/** + * Tier A publish: aggregate blob + per-bench blobs. + * + * Reads every unfiltered bench from Redis (worker uses TCP direct via + * `OCB_REDIS_URL`, never SRH), assembles a slim aggregate envelope, and + * ALSO writes one file per live bench so the site's detail pages can + * read straight from the CDN instead of round-tripping SRH. + */ +export async function publishAggregate(specs: Spec[]): Promise<PublishResult> { + const outputDir = process.env.AGGREGATE_OUTPUT_PATH; + if (!outputDir) { + return { ok: false, error: "AGGREGATE_OUTPUT_PATH not set" }; + } + + const benches: Benchmark[] = []; + let liveCount = 0; + let draftCount = 0; + + for (const spec of specs) { + let bench: Benchmark | null = null; + try { + const snap = await readMaterialized(spec.slug, ""); + bench = snap?.bench ?? null; + } catch (err) { + console.warn( + `[publish-aggregate] read ${spec.slug} failed: ${err instanceof Error ? err.message : err}`, + ); + } + if (bench) { + benches.push(bench); + if (bench.status === "live") liveCount += 1; + else draftCount += 1; + } else { + benches.push(draftPlaceholderForSpec(spec)); + draftCount += 1; + } + } + + benches.sort((a, b) => (a.number ?? "").localeCompare(b.number ?? "")); + + const builtAt = Date.now(); + const envelope = { + v: 1, + builtAt, + total: benches.length, + liveCount, + draftCount, + benches, + }; + const body = JSON.stringify(envelope); + const bytes = Buffer.byteLength(body); + + const aggregatePath = path.join(outputDir, AGGREGATE_FILENAME); + const benchesDir = path.join(outputDir, BENCHES_SUBDIR); + + try { + await mkdir(outputDir, { recursive: true }); + await mkdir(benchesDir, { recursive: true }); + await atomicWrite(aggregatePath, body); + } catch (err) { + return { + ok: false, + error: `aggregate write failed: ${err instanceof Error ? err.message : String(err)}`, + bytes, + total: benches.length, + liveCount, + draftCount, + }; + } + + // Per-bench files. Live benches only — draft placeholders would let + // the site render "Awaiting" on a per-bench page, which is what the + // draft-placeholder path already does anyway. Skip the write so stale + // files don't outlive their benches; the reader falls back to the + // aggregate slice / SRH transparently. + let perBenchOk = 0; + let perBenchBytes = 0; + for (const bench of benches) { + if (bench.status !== "live") continue; + const benchPath = path.join(benchesDir, `${bench.slug}.json`); + const benchBody = JSON.stringify({ + v: 1, + builtAt, + slug: bench.slug, + bench, + }); + perBenchBytes += Buffer.byteLength(benchBody); + try { + await atomicWrite(benchPath, benchBody); + perBenchOk += 1; + } catch (err) { + console.warn( + `[publish-aggregate] per-bench ${bench.slug} write failed: ${err instanceof Error ? err.message : err}`, + ); + } + } + + let revalidated = false; + const siteUrl = (process.env.SITE_URL ?? "").replace(/\/+$/, ""); + const revalidateToken = process.env.REVALIDATE_TOKEN; + if (siteUrl && revalidateToken) { + try { + const res = await fetch(`${siteUrl}/api/internal/revalidate-aggregate`, { + method: "POST", + headers: { Authorization: `Bearer ${revalidateToken}` }, + signal: AbortSignal.timeout(5_000), + }); + revalidated = res.ok; + if (!res.ok) { + console.warn( + `[publish-aggregate] revalidate hook returned ${res.status} ${res.statusText}`, + ); + } + } catch (err) { + console.warn( + `[publish-aggregate] revalidate hook failed: ${err instanceof Error ? err.message : err}`, + ); + } + } + + return { + ok: true, + filePath: aggregatePath, + bytes, + total: benches.length, + liveCount, + draftCount, + perBenchOk, + perBenchBytes, + revalidated, + }; +} + +/** + * Tier B publish: per-variant blobs (chain / region / kind / venue combos). + * + * Called after the tier B materialize loop finishes writing every + * variant to Redis. Enumerates the same (spec, filters) space, reads + * each variant back, and writes one file per (slug, sig). + */ +export async function publishVariants( + specs: Spec[], + variantsFor: (spec: Spec) => BenchmarkFilters[], +): Promise<VariantsPublishResult> { + const outputDir = process.env.AGGREGATE_OUTPUT_PATH; + if (!outputDir) { + return { ok: false, error: "AGGREGATE_OUTPUT_PATH not set" }; + } + + const variantsDir = path.join(outputDir, VARIANTS_SUBDIR); + await mkdir(variantsDir, { recursive: true }); + + const builtAt = Date.now(); + let count = 0; + let bytes = 0; + let errors = 0; + + for (const spec of specs) { + let benchDirEnsured = false; + for (const filters of variantsFor(spec)) { + const sig = filterSig(filters); + if (!sig) continue; + + try { + const snap = await readMaterialized(spec.slug, sig); + if (!snap?.bench) continue; + + const benchDir = path.join(variantsDir, spec.slug); + if (!benchDirEnsured) { + await mkdir(benchDir, { recursive: true }); + benchDirEnsured = true; + } + const filePath = path.join(benchDir, `${sig}.json`); + const body = JSON.stringify({ + v: 1, + builtAt, + slug: spec.slug, + sig, + bench: snap.bench, + }); + bytes += Buffer.byteLength(body); + await atomicWrite(filePath, body); + count += 1; + } catch (err) { + errors += 1; + console.warn( + `[publish-variants] ${spec.slug}/${sig}: ${err instanceof Error ? err.message : err}`, + ); + } + } + } + + return { ok: true, count, bytes, errors }; +}