diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml index df277d5e..8eae1513 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -46,7 +46,11 @@ jobs: - name: Deploy Preview id: deploy run: | - url=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}) + # --archive=tgz uploads ONE tarball instead of thousands of + # files: the team's api-upload-paid quota (40k reqs / rolling + # 24h) was exhausted twice on busy merge days (2026-07-06/07), + # blocking every deploy for hours. + url=$(vercel deploy --prebuilt --archive=tgz --token=${{ secrets.VERCEL_TOKEN }}) echo "url=$url" >> "$GITHUB_OUTPUT" echo "Preview ready at $url" diff --git a/alternatives/README.md b/alternatives/README.md index f741760e..d6bdd593 100644 --- a/alternatives/README.md +++ b/alternatives/README.md @@ -41,6 +41,7 @@ status: live # optional, default live | `bitquery` | Bitquery | network-coverage | | `birdeye` | Birdeye | metadata-coverage (Solana only) | | `relay` | Relay | bridge-quote-latency | +| `zerion` | Zerion | portfolio-chain-coverage | ## License diff --git a/alternatives/jupiter.yml b/alternatives/jupiter.yml new file mode 100644 index 00000000..3288f0e9 --- /dev/null +++ b/alternatives/jupiter.yml @@ -0,0 +1,13 @@ +slug: jupiter +target_product: Jupiter +target_url: https://jup.ag +description: Solana DEX aggregator with multi-venue routing +benchmark: solana-dex-quote-latency + +intro: | + Jupiter is a Solana DEX aggregator that routes swaps across most major venues on the network, exposing a public quote endpoint at `lite-api.jup.ag/swap/v1/quote`. Quote latency sits on the hot path of every swap a wallet, trading UI or routing front-end builds: a 600 ms response pushes a perceptible delay onto the user, while an 80 ms response feels instant. This benchmark measures the wall-clock round-trip latency of Solana DEX aggregator quote APIs every 60 seconds from three Railway regions (us-east, eu-west, sgp), against tokens that are actually trending on Solana rather than the canonical SOL to USDC pair that every provider's edge cache already memorises. The harness maintains a 30-minute sliding window of bonded tokens from Mobula Pulse V2's WebSocket feed (post-bonding-curve graduates from Pump.fun, Meteora, Raydium LaunchLab and friends), picks one at random each tick and sends an identical 100 USDC to tokenOut quote request at 1 percent slippage to each provider in parallel. The HTTP client reuses TCP and TLS connections across ticks so the recorded latency reflects steady-state cost, not a cold-start handshake. Liquidity-gap failures (a provider that cannot route the picked token) land on a separate `solana_quote_no_route_total` counter and are excluded from the latency histogram, so a provider that fails fast on long-tail coverage is not penalised on the percentiles. Histogram buckets cover 10 ms to 5 s. Providers measured alongside Jupiter are Mobula, OpenOcean and Raydium; per-region tabs at the top of the page surface where each provider's infrastructure actually sits. + +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 diff --git a/alternatives/zerion.yml b/alternatives/zerion.yml new file mode 100644 index 00000000..8db68e39 --- /dev/null +++ b/alternatives/zerion.yml @@ -0,0 +1,11 @@ +slug: zerion +target_product: Zerion +target_url: https://zerion.io +description: Wallet and portfolio API for EVM chains +benchmark: portfolio-chain-coverage + +intro: | + Zerion powers wallets and portfolio trackers with a single API for balances, positions and transactions across EVM chains. When sizing alternatives, one practical axis is chain coverage that actually works. how many chains does each portfolio API return real balances on, versus how many it lists in its own catalog? Below is the live daily probe. identical public test addresses for every provider, a chain counts once the returned balance clears $1, and the vendor's self-declared list is published next to the verified number. + +seo_title: "Zerion alternatives: live benchmark" +seo_description: "Zerion alternatives on portfolio API chain coverage. Probe-verified chains vs self-declared catalogs, refreshed daily." diff --git a/answers/which-evm-aggregator-has-the-fastest-quote.yml b/answers/which-evm-aggregator-has-the-fastest-quote.yml new file mode 100644 index 00000000..b1d85c8b --- /dev/null +++ b/answers/which-evm-aggregator-has-the-fastest-quote.yml @@ -0,0 +1,42 @@ +slug: which-evm-aggregator-has-the-fastest-quote +question: "Which EVM swap quote API is the fastest in 2026?" +short_answer: | + {{best_name}} currently returns EVM swap quotes the fastest at {{best_p50}} (p50, 24h) across Mobula, KyberSwap, Bebop, CoW, Enso, LI.FI and OpenOcean on Ethereum, Base, Arbitrum and BSC, measured live by OpenChainBench from 3 regions. + +benchmark: evm-quote-latency + +intro: | + EVM swap aggregator APIs all promise sub second quotes. This page measures how often they actually deliver one. Every wallet integration, intent layer and routing front end is bound by the quote API it sits behind, and the gap between a 200 ms and a 1500 ms p99 is the difference between a swap UI that feels native and one that needs a loading spinner on every input change. OpenChainBench rotates a 5 pair basket across Ethereum, Base, Arbitrum and BSC (1 ETH to USDT on Ethereum, 1000 USDC to USDT on Ethereum, 0.5 ETH to USDC on Base, 100 USDC to ETH on Arbitrum, 1 BNB to USDT on BSC), fires a quote request at every supported provider in parallel every 60 seconds, and records the wall clock round trip. The leaderboard sorts by p50 latency over the last 24 hours. A geographic split is available with the same probe running independently from us-east, eu-west and Singapore, so an integrator can pick the provider whose edge gateway is closest to their backend. + +methodology: | + Each provider's public quote endpoint is hit on the 5 fixed pairs above, rotating one pair per 60 second tick. Per (provider, chain) the bench lands roughly 288 samples in 24 hours per region, enough to put p90 and p99 buckets on a solid base. Latency is wall clock from request dispatch to last byte received, observed only on the happy path (HTTP 2xx with a parseable output amount). Failures land on dedicated counters by class (auth via 401/403, throttle via 429, no route via empty or zero output amount on 200, other for everything else) and pull the success gauge to 0. Native asset sentinel is the canonical `0xEeeeeEeee` form; Odos uses the all zero form and Bebop substitutes the wrapped equivalent on the sell side, the adapter normalises per provider. The three regions are separate Railway services writing the same metric family with a `region` label, so the page can filter to a single edge or aggregate across all three. + +limitations: + - "Speed only, quote quality is not scored. Two providers can return the same time to first byte while one quotes a noticeably worse price. Converting all outputs to a comparable USD value introduces an oracle bias the leaderboard does not want to bake into a latency ranking. The fee benchmark measures realised cost separately." + - "Each provider declares its supported chain set. CoW Protocol skips BSC because CoW Settlement does not deploy there; the harness skips probes for chains a provider does not claim to cover so the failure counter is not inflated by structural non coverage." + - "Enso uses a global anonymous bucket at roughly 1 request per second, so about 20% of probes hit 429 and are dropped. Remaining samples keep p50 and p90 stable but the success rate is capped near 80%, which the leaderboard surfaces honestly." + - "The 5 pair basket favours liquid USDC and ETH routes. A long tail token route (low cap memecoin, RWA, niche bridge wrapper) has different route search complexity and the latency curves on this leaderboard do not generalise to those cases." + - "Polygon and Optimism are slated for a v2 expansion once they reach a comparable sample volume; the current Ethereum, Base, Arbitrum and BSC coverage is the integration grade core." + +faq: + - q: "Which EVM swap quote API has the lowest latency right now?" + a: "{{best_name}} currently leads at {{best_p50}} (p50 over the last 24 hours) on the active tab, across {{count}} measured providers. The leaderboard re-sorts every minute on fresh Prometheus samples, so the answer reflects measured latency on the live basket, not a marketing claim. Per chain leaders can differ from the cross chain aggregate; the chain tabs at the top of the bench page show each ranking separately." + - q: "Does this measure quote quality or just speed?" + a: "Speed only. Latency is wall clock round trip from request to last byte, recorded only on a successful parseable response. Output amount in USD is not scored on this bench because providers measure it differently (Bebop is net of fees on a gasless RFQ, others are gross), and converting all outputs to a comparable USD value introduces an oracle bias the leaderboard does not want to bake into a latency comparison." + - q: "Why are some providers not on every chain?" + a: "Each provider declares its supported chain set. The harness skips probes for chains a provider does not claim to cover so the failure counter is not inflated by structural non coverage. CoW skips BSC because CoW Settlement does not deploy there; LI.FI and OpenOcean cover 30+ chains each. The per chain tab on the bench page shows which providers actually run on each chain." + - q: "What happens when a provider rate limits the harness?" + a: "Each adapter classifies the response. 429 lands on a throttle counter and the success gauge drops to 0. 401 and 403 land on an auth counter. Empty or zero output amounts on a 200 land on a no route counter. None of those failure modes contribute to the latency histogram, so the p50 number stays representative of healthy quotes." + - q: "How is the geographic split implemented?" + a: "Three identical Railway services run the harness with different `MONITOR_REGION` env vars (us-east, eu-west, sgp). Each writes the same metric family with a `region` label. The bench page region filter selects which slice of the histogram is queried; the per chain x per region grid shows a 4 chain by 3 region cell ranking for each provider." + - q: "Why does Bebop sometimes win on latency despite being RFQ?" + a: "Bebop's RFQ model resolves price through a maker network rather than via on chain simulation. The output amount it quotes is already net of fees because settlement is gasless for the taker. The latency the bench measures is the maker network resolving the price, not a public mempool route search; on liquid pairs that path can clock comparably to or below pre warmed aggregator graphs." + +related: + - which-solana-dex-aggregator-is-the-fastest + - which-bridge-has-the-fastest-quote-api + - which-crypto-price-api-is-the-fastest + +seo_title: "Which EVM swap quote API is the fastest in 2026?" +seo_description: "{{best_name}} leads EVM swap quote latency at {{best_p50}} (p50, 24h) across Mobula, KyberSwap, Bebop, CoW, Enso, LI.FI and OpenOcean on Ethereum, Base, Arbitrum and BSC." +status: live diff --git a/answers/which-solana-dex-aggregator-is-the-fastest.yml b/answers/which-solana-dex-aggregator-is-the-fastest.yml new file mode 100644 index 00000000..11c45532 --- /dev/null +++ b/answers/which-solana-dex-aggregator-is-the-fastest.yml @@ -0,0 +1,42 @@ +slug: which-solana-dex-aggregator-is-the-fastest +question: "Which Solana DEX aggregator returns the fastest swap quote?" +short_answer: | + {{best_name}} currently returns Solana swap quotes the fastest at {{best_p50}} (p50, 24h) across Jupiter, Mobula, OpenOcean and Raydium on a rotating trending token basket, measured live by OpenChainBench from 3 regions. + +benchmark: solana-dex-quote-latency + +intro: | + Solana DEX aggregators sit on the hot path of every swap a wallet, trading UI or routing front end builds. A quote API at 80 ms feels instant to the user; a quote API at 600 ms pushes a perceptible delay between input change and updated output. Most "fastest aggregator" comparisons hardcode the canonical SOL to USDC pair, which Jupiter's lite endpoint serves from a CloudFront edge in 30 to 50 ms, measuring cache hit rather than routing. This page answers the question integrators actually need answered. Which Solana DEX aggregator returns the fastest quote when the target token is one nobody has cached, against the long tail tokens actually trending right now. OpenChainBench rotates the target every tick by picking a fresh bonded token from Mobula Pulse V2's live WebSocket feed (post bonding curve graduates from Pump.fun, Meteora, Raydium LaunchLab and friends), then fires a 100 USDC to tokenOut quote at 1% slippage against each provider in parallel from us-east, eu-west and Singapore. + +methodology: | + Every 60 seconds, in each of three Railway regions, the harness picks one Solana token from a sliding 30 minute window of bonded tokens emitted by Mobula Pulse V2 over WebSocket, then sends an identical 100 USDC to tokenOut quote (1% slippage, no fee, no referrer) to each provider. Latency is wall clock from HTTP request dispatch to the first byte of a response body containing a usable quoted output amount (Jupiter `outAmount`, Mobula `data.amountOutTokens`, OpenOcean `data.outAmount`, Raydium `data.outputAmount`). The HTTP client reuses TCP and TLS connections across ticks, so the recorded number is the steady state round trip a long lived backend integration sees, not the one off cold start handshake. Histogram buckets are 10, 25, 50, 100, 200, 500, 1000, 2000 and 5000 ms; p50, p90 and p99 are computed per region via Prometheus `histogram_quantile` over 24h. Failure classes (HTTP 429 throttled, 401/403 auth error, no route, other) each land on their own counter and are excluded from the latency histogram. + +limitations: + - "Raydium's compute API is single venue (Raydium AMM v4, CPMM, CLMM only) and does not multi hop via SOL like Jupiter or Mobula. On the Pulse fed rotation (mostly Pump.fun graduates living on PumpSwap, plus Meteora and Orca pools) Raydium returns INSUFFICIENT_LIQUIDITY on roughly 80% of picks. Its latency cells go blank below 50 successful quotes per 24h; the success rate column is its honest score." + - "Jupiter serves quotes from regional pods behind CloudFront (`x-region: eu-central-1` observed from EU, `x-pod-name: jupiter-core-*`), so each probe region reaches a nearby replica and its cross region spread is flat by infrastructure, not by caching. Repeated identical requests return `x-cache: Miss` every time; CloudFront does not cache this endpoint." + - "Quote quality (output amount, net of gas) is not scored here. Two providers can return the same time to first byte while one quotes a noticeably worse price. Converting all outputs to a comparable USD value introduces an oracle bias the leaderboard does not want to bake into a latency ranking." + - "Long tail token rotation defeats per pair edge caches but biases the comparison toward providers whose route engine handles fresh mints quickly. A pure SOL to USDC trader would see different absolute numbers; relative ordering on the cohort is the integration grade signal." + - "Direct egress from Railway, no residential proxy. The bench measures backend to backend latency as a real integration sees it; a wallet calling the API from a mobile network adds the mobile network's RTT on top." + +faq: + - q: "Which Solana DEX has the fastest quote API right now?" + a: "{{best_name}} currently returns quotes the fastest at {{best_p50}} (p50, 24h) across {{count}} measured providers. The leaderboard re-sorts every 60 seconds against fresh samples and rotates the target token each tick so no provider can serve from edge cache. The answer reflects 24 hours of measured latency across us-east, eu-west and Singapore." + - q: "What is quote latency on a DEX aggregator?" + a: "Quote latency is the wall clock time between a swap UI asking a DEX aggregator 'what would I get if I traded 100 USDC for this token right now' and the aggregator answering with a usable routed price. A quote API at 80 ms feels instant; a quote API at 600 ms introduces a visible delay between input change and updated output. The number sets the floor on how live a swap UI can feel before any other latency (RPC, signing, broadcast) is added." + - q: "Why does the bench rotate the target token instead of always quoting SOL to USDC?" + a: "SOL to USDC is the most cached pair on Solana. Jupiter's lite endpoint serves it from a CloudFront edge in 30 to 50 ms, but that measures cache hit, not routing. Rotating the target every tick against the tokens actually trending right now via Mobula Pulse V2's bonded WebSocket feed defeats every per pair edge cache and forces each provider to actually run a routing search. The metric becomes a fair comparison of routing engines, not CDN configurations." + - q: "What if a provider cannot quote a particular long tail token?" + a: "It is counted as a no route, not as a slow quote. Each provider has a recognisable 'I have no path for this pair' signal (Jupiter NO_ROUTES_FOUND, Mobula 'No route found', Raydium INSUFFICIENT_LIQUIDITY, OpenOcean payload level). When the harness sees it, the tick lands on `solana_quote_no_route_total` and is excluded from the latency histogram entirely, so providers that fail fast on coverage gaps are not penalised on the percentiles." + - q: "Why does Raydium have a much lower success rate than the aggregators?" + a: "Raydium's compute endpoint is single venue: it only routes against Raydium's own AMM v4, CPMM and CLMM pools and does not multi hop via SOL the way Jupiter or Mobula do. Because the rotation pulls from Mobula Pulse V2's bonded feed (most of which are Pump.fun graduates living on PumpSwap, plus Meteora and Orca), Raydium returns INSUFFICIENT_LIQUIDITY or ROUTE_NOT_FOUND on roughly 80% of picks. The Raydium latency column is the conditional p50 over the small subset of bonded tokens that happen to have a Raydium pool." + - q: "Are authentication errors and rate limits counted as slow?" + a: "No. HTTP 401, 403 and 429 responses are excluded from the latency histogram entirely. They are counted in separate counters (`solana_quote_auth_error_total`, `solana_quote_throttled_total`) which show up on the success rate column. A provider that is fast but rate limits at the configured cadence loses points on success rate, not on latency." + +related: + - which-evm-aggregator-has-the-fastest-quote + - which-solana-rpc-lands-the-most-transactions + - which-crypto-price-api-is-the-fastest + +seo_title: "Which Solana DEX aggregator returns the fastest swap quote in 2026?" +seo_description: "{{best_name}} leads Solana DEX quote latency at {{best_p50}} (p50, 24h) across Jupiter, Mobula, OpenOcean and Raydium on a rotating trending token basket. Measured live by OpenChainBench from 3 regions." +status: live diff --git a/answers/which-solana-rpc-lands-the-most-transactions.yml b/answers/which-solana-rpc-lands-the-most-transactions.yml new file mode 100644 index 00000000..6921b2f1 --- /dev/null +++ b/answers/which-solana-rpc-lands-the-most-transactions.yml @@ -0,0 +1,38 @@ +slug: which-solana-rpc-lands-the-most-transactions +question: "Which Solana RPC provider lands the most transactions in 2026?" +short_answer: | + {{best_name}} currently leads Solana transaction landing latency at {{best_p50}} (p50, 24h), the lowest slot-delta between transaction submission and confirmation across the measured RPC field. + +benchmark: solana-tx-landing-latency + +intro: | + Solana trading bots, MEV searchers and on-chain settlement all live or die on the same metric: how reliably and quickly does the RPC endpoint actually land the transaction on a leader's block. Marketing pages publish landing-rate numbers; almost none publish methodology or a live, neutral comparison. This page answers the question that wallet integrations, agent infrastructure and trading desks ask before pasting a URL into production. Which RPC provider is actually landing transactions the fastest right now, measured in slot delta between submission and confirmation, with a probe that runs continuously from multiple regions against the same canonical leader schedule. + +methodology: | + The harness submits a self-signed compute-unit-cheap transaction every few seconds through each RPC provider's submission endpoint, then watches a canonical archive node for the resulting confirmation. The landing latency is the wall-clock slot delta between submission and confirmation, expressed in milliseconds at Solana's 400 ms slot interval. The p50 over 24h is the headline metric; p99 captures the worst 1 percent of cases, where a provider's regional infrastructure or leader proximity surfaces clearly. Probes run from US-East, EU-West and Singapore against the same canonical archive node so any geographic asymmetry shows up as a per-region split, not as a noise floor on the aggregate. + +limitations: + - "Slot delta is not the same as fee. A provider can land transactions fastest while charging a per-transaction priority fee through Jito or a similar bundler; cost-per-landed-transaction is a composite metric the leaderboard does not currently surface." + - "Self-signed test transactions do not exercise the full priority-fee mempool. A real production transaction with a high priority fee and CU budget lands faster than the probes shown here, and the relative ordering can shift when paying for inclusion." + - "Provider landing performance shifts with Solana validator leader schedule. A provider with relayers physically close to today's leader can outperform on this window and lose its lead next epoch when the schedule rotates." + - "This is not a stake-weighted measurement. The harness measures wall-clock landing time at the canonical archive node level, not the share of stake reached at each submission." + +faq: + - q: "What does landing latency actually measure?" + a: "Wall-clock milliseconds between the moment a probe submits a self-signed transaction to a Solana RPC and the moment a canonical archive node sees the same transaction in a confirmed block. Lower is faster. The number is the time from your code calling send to the network treating the transaction as included." + - q: "Why is this different from Solana block time?" + a: "Solana block time is the chain's slot interval, fixed at 400 milliseconds. Landing latency is the time your transaction takes to reach the leader plus the leader's time to include it plus the propagation back to a canonical observer. The chain produces a slot every 400 milliseconds whether or not your transaction lands in it; the question this page answers is which provider's path gets you into the next available slot most consistently." + - q: "Does Jito's bundler beat raw RPC landing?" + a: "On the measured probes, Jito bundling is treated as a provider option, not as a separate metric. When the harness submits through a Jito-aware provider with bundle inclusion enabled, the path includes the bundler. The leaderboard surfaces both Jito and non-Jito providers in the same field so the relative cost of bundling is visible." + - q: "What regions are the probes from?" + a: "US-East, EU-West and Singapore. Cross-region probes catch providers whose landing performance is asymmetric across geography (an RPC fast from EU but slow from APAC is common). The leaderboard reports the cross-region p50; the per-region breakdown is on the bench page." + - q: "Why not measure with my own real workload?" + a: "Real workloads are the ground truth, but they are not comparable across providers because they carry different priority fees, different program calls, and run from different infrastructure. The harness controls for those variables to publish a fair cross-provider comparison; for your specific workload, run the same harness yourself (it is open source) and compare." + +related: + - which-blockchain-has-cheapest-transaction-fees + - which-l1-has-the-fastest-finality + +seo_title: "Which Solana RPC provider lands the most transactions in 2026?" +seo_description: "{{best_name}} leads Solana transaction landing at {{best_p50}} slot delta (p50, 24h) measured live by OpenChainBench. Methodology, regional probes and limitations on this page." +status: live diff --git a/benchmarks/arbitrum-rpc.yml b/benchmarks/arbitrum-rpc.yml index c7cb15f7..227088f7 100644 --- a/benchmarks/arbitrum-rpc.yml +++ b/benchmarks/arbitrum-rpc.yml @@ -4,8 +4,8 @@ slug: arbitrum-rpc number: "045" title: Fastest free Arbitrum RPC, live no-key endpoint latency seo_title: "Fastest free Arbitrum RPC 2026" -seo_description: "{{best_name}} leads free Arbitrum RPC at {{best_p50}} (eth_blockNumber p50, 24h). 8 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Arbitrum RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Arbitrum RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 8 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Arbitrum RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -14,14 +14,14 @@ unit: ms higher_is_better: false seo_intro: | - Arbitrum is the second-largest cohort in the cluster: 8 no-key providers including the Arbitrum Foundation's own `arb1.arbitrum.io/rpc`, and one of the few chains where Lava and MeowRPC still compete on a free tier. Foundation endpoints are documented best-effort, and our data shows what that means in practice: a respectable median with a p99 roughly ten times worse. Every provider answers the identical `eth_blockNumber` probe every 15 seconds from us-east, eu-west and Singapore. + Arbitrum is the second-largest cohort in the cluster: 8 no-key providers including the Arbitrum Foundation's own `arb1.arbitrum.io/rpc`, and one of the few chains where Lava and MeowRPC still compete on a free tier. Foundation endpoints are documented best-effort, and our data shows what that means in practice: a respectable median with a p99 roughly ten times worse. Every provider answers the identical `eth_getBlockByNumber` probe every 15 seconds from us-east, eu-west and Singapore. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Arbitrum endpoint that sustains continuous probing, 8 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Arbitrum-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Arbitrum endpoint that sustains continuous probing, 8 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Arbitrum-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,20 +29,20 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"arbitrum\". Provider coverage: 8 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Nodies, Lava, MeowRPC, Arbitrum). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Arbitrum RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 8 measured providers." + - "{{best_name}} currently leads free Arbitrum RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 8 measured providers." - "The Arbitrum Foundation endpoint is the textbook best-effort profile: usable median, heavy tail. Its p99 routinely runs ~10x its p50, which matters if your product retries on timeout." - "Arbitrum is one of only two chains (with Ethereum) where {{name:lava}} and {{name:meowrpc}} qualify no-key, both providers key-gate or skip most other chains." - "{{name:drpc}} at {{p50:drpc}} and {{name:publicnode}} at {{p50:publicnode}} anchor the multi-chain gateway tier; regional splits between them flip depending on probe origin." faq: - q: "What is the fastest free Arbitrum RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 8 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 8 no-key providers probed every 15 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 Arbitrum RPCs work without an API key?" a: "The 8 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Nodies, Lava, MeowRPC, Arbitrum. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Arbitrum RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Arbitrum RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Should I use the official Arbitrum Foundation RPC in production?" a: "The Foundation documents `arb1.arbitrum.io/rpc` as best-effort and rate-limited, intended for development. Our continuous measurement confirms the profile: acceptable p50 with a p99 tail several times worse than the leading gateways. For read-heavy production paths a gateway with a tighter distribution is the safer default; keep the official endpoint as a fallback rather than a primary." @@ -68,7 +68,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Arbitrum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Arbitrum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="arbitrum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="arbitrum"}) @@ -91,7 +91,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Arbitrum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Arbitrum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="arbitrum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="arbitrum"}) @@ -114,7 +114,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Arbitrum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Arbitrum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="arbitrum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="arbitrum"}) @@ -137,7 +137,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, 9 chains, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Arbitrum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Arbitrum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="arbitrum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="arbitrum"}) @@ -160,7 +160,7 @@ providers: - slug: nodies name: Nodies tag: POKT Network's decentralized public RPC successor, 7+ chains - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Arbitrum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Arbitrum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="arbitrum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nodies", chain="arbitrum"}) @@ -183,7 +183,7 @@ providers: - slug: lava name: Lava tag: Decentralized permissionless RPC mesh (ETH + Arbitrum no-key) - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Lava's no-key Arbitrum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Lava's no-key Arbitrum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="arbitrum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="lava", chain="arbitrum"}) @@ -206,7 +206,7 @@ providers: - slug: meowrpc name: MeowRPC tag: Free public RPC, no registration - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to MeowRPC's no-key Arbitrum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to MeowRPC's no-key Arbitrum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="meowrpc", chain="arbitrum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="meowrpc", chain="arbitrum"}) @@ -229,7 +229,7 @@ providers: - slug: arbitrum-official name: Arbitrum tag: Arbitrum Foundation public RPC, Arbitrum One only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Arbitrum's no-key Arbitrum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Arbitrum's no-key Arbitrum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="arbitrum-official", chain="arbitrum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="arbitrum-official", chain="arbitrum"}) diff --git a/benchmarks/avalanche-rpc.yml b/benchmarks/avalanche-rpc.yml index 19c24ba5..662fb2fd 100644 --- a/benchmarks/avalanche-rpc.yml +++ b/benchmarks/avalanche-rpc.yml @@ -4,8 +4,8 @@ slug: avalanche-rpc number: "048" title: Fastest free Avalanche RPC, live no-key endpoint latency seo_title: "Fastest free Avalanche RPC 2026" -seo_description: "{{best_name}} leads free Avalanche RPC at {{best_p50}} (eth_blockNumber p50, 24h). 6 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Avalanche RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Avalanche RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 6 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Avalanche RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -17,11 +17,11 @@ seo_intro: | Avalanche's C-Chain field combines Ava Labs' official `api.avax.network` with 5 no-key multi-chain gateways. The official endpoint shows one of the tightest distributions among foundation RPCs, a contrast with the best-effort profiles on Arbitrum and Optimism. Every provider answers the identical probe every 15 seconds from us-east, eu-west and Singapore, with stale-head detection flagging anything more than 20 blocks behind the cross-provider tip. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Avalanche endpoint that sustains continuous probing, 6 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Avalanche-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Avalanche endpoint that sustains continuous probing, 6 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Avalanche-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,19 +29,19 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"avalanche\". Provider coverage: 6 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Nodies, Avalanche). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Avalanche RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 6 measured providers." + - "{{best_name}} currently leads free Avalanche RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 6 measured providers." - "Unlike the Arbitrum and Optimism foundation endpoints, `api.avax.network` keeps a tight p50-to-p99 ratio, an official endpoint that behaves like managed infrastructure rather than a best-effort courtesy." - "{{name:publicnode}} ({{p50:publicnode}}) and {{name:drpc}} ({{p50:drpc}}) give the C-Chain the same reliable gateway floor they provide on every EVM chain we measure." faq: - q: "What is the fastest free Avalanche RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 6 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 6 no-key providers probed every 15 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 Avalanche RPCs work without an API key?" a: "The 6 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Nodies, Avalanche. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Avalanche RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Avalanche RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Is the official Avalanche RPC good enough for production reads?" a: "Among chain-official endpoints it is one of the strongest we measure: tight latency distribution and a high success rate rather than the best-effort tail seen on some other foundation RPCs. The usual free-tier caveats still apply (shared rate limits, no SLA), but as a read path it holds up unusually well against the commercial gateways." @@ -67,7 +67,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Avalanche endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Avalanche endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="avalanche"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="avalanche"}) @@ -90,7 +90,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Avalanche endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Avalanche endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="avalanche"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="avalanche"}) @@ -113,7 +113,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Avalanche endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Avalanche endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="avalanche"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="avalanche"}) @@ -136,7 +136,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, 9 chains, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Avalanche endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Avalanche endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="avalanche"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="avalanche"}) @@ -159,7 +159,7 @@ providers: - slug: nodies name: Nodies tag: POKT Network's decentralized public RPC successor, 7+ chains - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Avalanche endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Avalanche endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="avalanche"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nodies", chain="avalanche"}) @@ -182,7 +182,7 @@ providers: - slug: avalanche-official name: Avalanche tag: Ava Labs C-Chain public RPC, Avalanche C-Chain only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Avalanche's no-key Avalanche endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Avalanche's no-key Avalanche endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="avalanche-official", chain="avalanche"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="avalanche-official", chain="avalanche"}) diff --git a/benchmarks/base-rpc.yml b/benchmarks/base-rpc.yml index fc4c1a88..86c05f45 100644 --- a/benchmarks/base-rpc.yml +++ b/benchmarks/base-rpc.yml @@ -4,8 +4,8 @@ slug: base-rpc number: "046" title: Fastest free Base RPC, live no-key endpoint latency seo_title: "Fastest free Base RPC 2026" -seo_description: "{{best_name}} leads free Base RPC at {{best_p50}} (eth_blockNumber p50, 24h). 6 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Base RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Base RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 6 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Base RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -14,14 +14,14 @@ unit: ms higher_is_better: false seo_intro: | - Base offers the cleanest official-versus-gateway comparison in the cluster: Coinbase operates both the sequencer and the chain-official `mainnet.base.org`, so the house endpoint has every locational advantage, and it still has to beat PublicNode, dRPC, Tenderly, Nodies and Merkle on a level probe. 6 providers, the same `eth_blockNumber` call every 15 seconds, three regions, stale-head detection against the cross-provider tip. + Base offers the cleanest official-versus-gateway comparison in the cluster: Coinbase operates both the sequencer and the chain-official `mainnet.base.org`, so the house endpoint has every locational advantage, and it still has to beat PublicNode, dRPC, Tenderly, Nodies and Merkle on a level probe. 6 providers, the same `eth_getBlockByNumber` call every 15 seconds, three regions, stale-head detection against the cross-provider tip. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Base endpoint that sustains continuous probing, 6 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Base-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Base endpoint that sustains continuous probing, 6 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Base-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,19 +29,19 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"base\". Provider coverage: 6 no-key endpoints (PublicNode, dRPC, Tenderly, Nodies, Merkle, Base). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Base RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 6 measured providers." + - "{{best_name}} currently leads free Base RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 6 measured providers." - "Base is one of only two chains where {{name:merkle}} qualifies no-key (with BNB); its distribution is among the tightest in the whole cluster, p99 barely above p50." - "The official `mainnet.base.org` and the multi-chain gateways trade the lead depending on region, a reminder that \"fastest\" is a per-origin question, not a global one." faq: - q: "What is the fastest free Base RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 6 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 6 no-key providers probed every 15 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 Base RPCs work without an API key?" a: "The 6 providers on this page: PublicNode, dRPC, Tenderly, Nodies, Merkle, Base. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Base RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Base RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Is Coinbase's official Base RPC faster than third-party gateways?" a: "Not consistently. Despite being operated by the same team that runs the sequencer, `mainnet.base.org` trades the lead with PublicNode, dRPC and Tenderly depending on which region the request originates from. Check the region tabs on this page for the origin closest to your deployment; the cross-region average hides these flips." @@ -67,7 +67,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Base endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Base endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="base"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="base"}) @@ -90,7 +90,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Base endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Base endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="base"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="base"}) @@ -113,7 +113,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, 9 chains, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Base endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Base endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="base"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="base"}) @@ -136,7 +136,7 @@ providers: - slug: nodies name: Nodies tag: POKT Network's decentralized public RPC successor, 7+ chains - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Base endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Base endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="base"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nodies", chain="base"}) @@ -159,7 +159,7 @@ providers: - slug: merkle name: Merkle tag: Base + BSC public no-key gateway (Ethereum hit by Cloudflare 20-min lockout, excluded) - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Merkle's no-key Base endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Merkle's no-key Base endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="merkle", chain="base"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="merkle", chain="base"}) @@ -182,7 +182,7 @@ providers: - slug: base-official name: Base tag: Coinbase-operated, Base mainnet RPC - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Base's no-key Base endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Base's no-key Base endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="base-official", chain="base"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="base-official", chain="base"}) diff --git a/benchmarks/berachain-rpc.yml b/benchmarks/berachain-rpc.yml index 989217d1..b871745c 100644 --- a/benchmarks/berachain-rpc.yml +++ b/benchmarks/berachain-rpc.yml @@ -4,8 +4,8 @@ slug: berachain-rpc number: "062" title: Fastest free Berachain RPC, live no-key endpoint latency seo_title: "Fastest free Berachain RPC 2026" -seo_description: "{{best_name}} leads free Berachain RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Berachain RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Berachain RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Berachain RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -17,11 +17,11 @@ seo_intro: | Berachain's proof-of-liquidity L1 fields 4 no-key providers: the Foundation's `rpc.berachain.com` plus PublicNode, dRPC and Tenderly. A thin cohort is itself a signal, several sibling chains failed the cluster's four-keyless-provider bar entirely, so each endpoint that qualifies here carries more of the redundancy burden. Identical probes every 15 seconds, three regions, full response classification. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Berachain endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Berachain-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Berachain endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Berachain-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,20 +29,20 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"berachain\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, Tenderly, Berachain). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Berachain RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free Berachain RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." - "{{name:drpc}} ({{p50:drpc}}) extends its expansion-wide consistency run to Berachain, the anycast profile that takes 10 of the 12 long-tail chains on the 3-region average." - "{{name:tenderly}} shows the long-tail single-origin signature again, roughly 330 ms from every region, a sharp contrast with its performance on the chains its edge network actually fronts." - "The Foundation endpoint gives the chain a credible house baseline; whether it beats the gateways depends on your origin, which is exactly what the per-region tabs are for." faq: - q: "What is the fastest free Berachain RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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 Berachain RPCs work without an API key?" a: "The 4 providers on this page: PublicNode, dRPC, Tenderly, Berachain. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Berachain RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Berachain RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Are free Berachain RPCs ready for production traffic?" a: "The four qualifying endpoints all sustain our 15-second cadence with high measured success rates, which is the floor for production reads. The real constraint is redundancy: with 4 providers, one incident removes a quarter of your options, so run the current leader ({{best_name}}, {{best_p50}}) as primary with the runner-up wired as fallback and let this page arbitrate after incidents." @@ -68,7 +68,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Berachain endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Berachain endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="berachain"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="berachain"}) @@ -91,7 +91,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Berachain endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Berachain endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="berachain"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="berachain"}) @@ -114,7 +114,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Berachain endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Berachain endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="berachain"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="berachain"}) @@ -137,7 +137,7 @@ providers: - slug: berachain-official name: Berachain tag: Berachain Foundation public RPC, Berachain only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Berachain's no-key Berachain endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Berachain's no-key Berachain endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="berachain-official", chain="berachain"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="berachain-official", chain="berachain"}) diff --git a/benchmarks/blast-rpc.yml b/benchmarks/blast-rpc.yml index 8ef26c83..96159861 100644 --- a/benchmarks/blast-rpc.yml +++ b/benchmarks/blast-rpc.yml @@ -4,8 +4,8 @@ slug: blast-rpc number: "060" title: Fastest free Blast RPC, live no-key endpoint latency seo_title: "Fastest free Blast RPC 2026" -seo_description: "{{best_name}} leads free Blast RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Blast RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Blast RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Blast RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -17,11 +17,11 @@ seo_intro: | Blast is the expansion's measurement cautionary tale. The chain-official `rpc.blast.io` answers in roughly 2 ms from all three probe regions at once, which no single origin can do: Virginia, Amsterdam and Singapore are separated by 80+ ms round trips at the speed of light. The endpoint terminates at an edge network. Our stale-head detection confirms the blocks it serves are fresh, but a sub-5 ms number measures the edge handshake, not the chain. 4 no-key providers, identical probes, three regions. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Blast endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Blast-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Blast endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Blast-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,20 +29,20 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"blast\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, Tenderly, Blast). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Blast RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free Blast RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." - "`rpc.blast.io` posts ~2 ms in every region simultaneously, physically impossible for one origin. Read it as an edge-terminated endpoint: heads are fresh per our stale detection, but the latency column measures CDN termination rather than a node round trip, the same class of caution we document for Cloudflare-eth." - "{{name:drpc}} ({{p50:drpc}}) is the honest-infrastructure comparison point: anycast consistency across the three regions with real node round trips behind it, the profile that wins it 10 of the 12 expansion chains." - "{{name:tenderly}} sits at the expansion's familiar flat ~330 ms in all regions on Blast, single-origin routing on a gateway that is genuinely quick on the major chains." faq: - q: "What is the fastest free Blast RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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 Blast RPCs work without an API key?" a: "The 4 providers on this page: PublicNode, dRPC, Tenderly, Blast. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Blast RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Blast RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Is rpc.blast.io really that fast, or is something else going on?" a: "Something else. Two milliseconds simultaneously from Virginia, Amsterdam and Singapore is below the physical round-trip floor for any single origin, so the endpoint is answering at an anycast/CDN edge. Our stale-head detection shows the blocks it returns are current, so it is not serving a stale cache today, but edge termination means the latency figure describes the edge network, not node processing. We keep it ranked with the caveat documented, exactly as we do for Cloudflare's fast-but-permissioned Ethereum endpoint." @@ -68,7 +68,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Blast endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Blast endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="blast"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="blast"}) @@ -91,7 +91,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Blast endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Blast endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="blast"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="blast"}) @@ -114,7 +114,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Blast endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Blast endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="blast"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="blast"}) @@ -137,7 +137,7 @@ providers: - slug: blast-official name: Blast tag: Chain-official RPC, edge-terminated (see findings) - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Blast's no-key Blast endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Blast's no-key Blast endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blast-official", chain="blast"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="blast-official", chain="blast"}) diff --git a/benchmarks/bnb-rpc.yml b/benchmarks/bnb-rpc.yml index 7a12f651..c87a4de3 100644 --- a/benchmarks/bnb-rpc.yml +++ b/benchmarks/bnb-rpc.yml @@ -4,8 +4,8 @@ slug: bnb-rpc number: "049" title: Fastest free BNB Chain RPC, live no-key endpoint latency seo_title: "Fastest free BNB Chain RPC 2026" -seo_description: "{{best_name}} leads free BNB Chain RPC at {{best_p50}} (eth_blockNumber p50, 24h). 5 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public BNB Chain RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free BNB Chain RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 5 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public BNB Chain RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -17,11 +17,11 @@ seo_intro: | BNB Chain is the incumbent's chain: Binance's `bsc-dataseed1.binance.org` has been the copy-paste default since 2020, and from some regions it is still the single fastest RPC response we measure anywhere in the cluster. The catch is that it serves exactly one chain, while PublicNode, dRPC, Nodies and Merkle bring multi-chain coverage with increasingly competitive latency from EU origins. 5 providers, identical probes, three regions. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public BNB Chain endpoint that sustains continuous probing, 5 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 BNB Chain-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public BNB Chain endpoint that sustains continuous probing, 5 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 BNB Chain-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,19 +29,19 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"bnb\". Provider coverage: 5 no-key endpoints (PublicNode, dRPC, Nodies, Merkle, Binance). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free BNB Chain RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 5 measured providers." + - "{{best_name}} currently leads free BNB Chain RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 5 measured providers." - "Binance's dataseed is a single-chain specialist: blisteringly fast near its home regions (single-digit milliseconds from us-east at times) and 10x slower from Singapore, the widest regional spread in the cluster." - "{{name:merkle}} qualifies here (BNB is one of its two stable no-key chains) and brings its signature tight distribution to a field otherwise dominated by the dataseed's regional extremes." faq: - q: "What is the fastest free BNB Chain RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 5 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 5 no-key providers probed every 15 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 BNB Chain RPCs work without an API key?" a: "The 5 providers on this page: PublicNode, dRPC, Nodies, Merkle, Binance. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest BNB Chain RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 BNB Chain RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Is bsc-dataseed still the best RPC for BNB Chain?" a: "It depends entirely on where your requests originate. From regions near Binance's infrastructure the dataseed is often the fastest single response in our whole dataset; from Singapore it can be 10x slower than the gateway tier. Check the region tabs above, the cross-region average is meaningless for an endpoint with this much geographic variance." @@ -67,7 +67,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key BNB Chain endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key BNB Chain endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="bnb"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="bnb"}) @@ -90,7 +90,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key BNB Chain endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key BNB Chain endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="bnb"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="bnb"}) @@ -113,7 +113,7 @@ providers: - slug: nodies name: Nodies tag: POKT Network's decentralized public RPC successor, 7+ chains - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key BNB Chain endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key BNB Chain endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="bnb"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nodies", chain="bnb"}) @@ -136,7 +136,7 @@ providers: - slug: merkle name: Merkle tag: Base + BSC public no-key gateway (Ethereum hit by Cloudflare 20-min lockout, excluded) - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Merkle's no-key BNB Chain endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Merkle's no-key BNB Chain endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="merkle", chain="bnb"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="merkle", chain="bnb"}) @@ -159,7 +159,7 @@ providers: - slug: binance name: Binance tag: BNB Chain dataseed RPC, Binance-operated - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Binance's no-key BNB Chain endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Binance's no-key BNB Chain endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="binance", chain="bnb"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="binance", chain="bnb"}) diff --git a/benchmarks/bridge-revenue.yml b/benchmarks/bridge-revenue.yml new file mode 100644 index 00000000..4228868b --- /dev/null +++ b/benchmarks/bridge-revenue.yml @@ -0,0 +1,188 @@ +# OpenChainBench. Bench № 028 + +slug: bridge-revenue +number: "028" +title: Cross-chain bridge implied protocol revenue +seo_title: "Cross-chain bridge revenue tracker 2026" +seo_description: "Live cross-chain bridge implied protocol revenue. (USD in − out − gas − fees) summed daily, ranked in USD." +subtitle: Implied margin in USD that each cross-chain bridge plus its solver network collectively retain per swap, summed over rolling 24h / 7d / 30d windows. Upper bound, not exact. Read methodology before citing. +category: Bridges +status: live +metric: Implied revenue USD (24h) +unit: usd +higher_is_better: true + +disclaimer: | + This is an UPPER BOUND, not an exact protocol-revenue number. The margin = USD-in minus USD-out minus gas minus disclosed app fees. Some of that margin Relay shares with solvers in ways we cannot distinguish from the public swap data, so the bench attributes the full amount to "Relay plus solvers collectively". Treat the headline as a ceiling on Relay's own take, not a P&L line. Unpriced swaps (token without a USD reference at swap time) are excluded from the sum and counted separately. + +seo_intro: | + This benchmark answers a question that has no honest public + answer today. how much money does Relay.link, the leading + cross-chain swap routing protocol, actually retain from the + flow it processes. The bench computes implied margin per swap + as (USD value the user paid) minus (USD value the user + received) minus (gas total in USD) minus (any external app + fees the API discloses), then sums that margin over rolling + 24-hour, 7-day and 30-day windows. The result is the smallest + possible number consistent with the on-chain swap data, but + not necessarily the smallest possible number consistent with + Relay's books. Relay shares revenue with the solver network + in ways the public API does not break out, so the bench + attributes the full margin to "Relay plus solvers collectively". + Treat the headline as a ceiling on what Relay alone keeps. + Why publish an upper bound. Because no lower bound exists in + public data. Relay does not publish a fee schedule mapping + volume to revenue. Solver-rebate arrangements are private. + Community-tracked volume ($80M-$150M per day per public + disclosures) times a market-implied 1-5 bps blended take + rate yields an expected band of $20k-$100k per day, or + $0.75M-$2.5M per month. This bench tightens that band by + measuring directly from the swap stream and discloses every + methodology choice so readers can reproduce or argue with + the number. + How it works. A Go program polls the Relay.link public swap + API every 60 s, joins each completed swap against a USD + reference at the swap timestamp, subtracts gas and disclosed + external app fees, and emits the resulting margin as a + Prometheus gauge labeled by window. Unpriced swaps are + excluded from the sum and counted separately. Headline + numbers render as p50 / p90 / p99 on the bench page; those + slots are repurposed for 24h / 7d / 30d windows of the same + margin metric. Take rate in bps is published alongside. + +abstract: | + Relay.link is a cross-chain swap routing protocol that + publishes per-swap detail through a public API. This + benchmark consumes that API on a fixed cadence, computes + per-swap implied margin in USD as (input USD value) minus + (output USD value) minus (gas USD) minus (disclosed external + app fees), and sums the margin over rolling 24-hour, 7-day + and 30-day windows. The result is published as the protocol- + revenue upper bound. Volume USD, swap count and the implied + take rate in basis points are surfaced alongside as context. + The bench does not have access to Relay's internal solver- + rebate accounting, so the number is interpreted as the + margin retained collectively by Relay plus its solver + network, not Relay's own bottom-line revenue. Cross- + validation. Public disclosures put Relay volume at $80M to + $150M per day. Bridge / aggregator take rates cluster at 1 + to 5 basis points blended. Multiplying yields an expected + protocol-revenue band of $20k to $100k per day, or $0.75M + to $2.5M per month, which the live bench number should fall + inside. Significant deviation is either a data-quality issue + the methodology section flags (price-feed gap, unpriced + swap surge, API change) or genuine signal that solver share + has shifted. Limitations. The margin definition is an upper + bound. Solver rebates would lower it; data-quality gaps + (missing USD reference at swap time, gas estimation drift, + undisclosed app fees) would lower the published headline + relative to true protocol revenue. The harness publishes + unpriced-swap count and last-poll timestamp as freshness + signals so readers can spot data-quality regressions. + +methodology: + - "Data source. The Relay.link public swap API is polled on a fixed cadence (default 60 s). The harness consumes the swap stream incrementally, deduping by swap ID, and emits each completed swap as a single contribution to the running window sums. Only swaps with status `success` are counted; pending or failed swaps are tracked separately and excluded from the revenue sum." + - "Margin definition. per_swap_margin_usd = input_usd - output_usd - gas_usd - external_app_fees_usd. input_usd and output_usd are computed by joining the on-chain token amounts against a USD reference at the swap timestamp from CoinGecko or equivalent. gas_usd is the chain-native gas cost (gas_used * gas_price) converted to USD at the same timestamp. external_app_fees_usd is the sum of any frontend or referrer fees the swap payload discloses." + - "Window sums. relay_revenue_usd{window=\"24h\"} is the rolling 24-hour sum of per-swap margin. relay_revenue_usd{window=\"7d\"} is the rolling 7-day sum. relay_revenue_usd{window=\"30d\"} is the rolling 30-day sum. relay_volume_usd and relay_swap_count are computed the same way over the same windows. The bench page binds the three timescales to the p50 / p90 / p99 slots respectively, so the leaderboard surfaces all three at a glance." + - "Take rate. relay_take_rate_bps = (relay_revenue_usd{window=\"24h\"} / relay_volume_usd{window=\"24h\"}) * 10000. Reported on the 24-hour window as the most recent operational signal. Expected band per cross-validation is 1-5 bps blended; a take-rate reading outside that band is either a data-quality issue or a regime change worth investigating." + - "Pricing coverage. relay_swaps_priced_total counts swaps where every leg had a USD reference at swap time. relay_swaps_unpriced_total counts swaps where at least one leg did not (long-tail token, missing reference, price-feed gap). The success column on the bench page is priced / (priced + unpriced), a data-quality ratio rather than a service-uptime ratio. A low ratio means the published headline understates true volume and margin." + - "Why margin is an upper bound on protocol revenue. Relay shares revenue with its solver network through arrangements the public API does not break out. Per-swap margin therefore captures what Relay AND solvers collectively retain, not Relay alone. Without access to internal rebate accounting, the bench cannot decompose the split. Headline is published as 'Relay implied protocol revenue (upper bound)'. Readers building a P&L model should apply their own solver-share assumption to the headline." + - "Freshness. relay_last_poll_timestamp_seconds is a Unix-timestamp gauge of the most recent successful API poll. The bench page's success indicator and any cron alerter use this to detect API outages or harness stalls. expected_freshness_seconds is set to 300 (5 min) which is 5x the 60 s poll cadence, so one missed cycle is tolerated and two missed cycles fire." + - "Cross-validation expected band. Public disclosures put Relay daily volume at $80M to $150M per day. Industry-standard cross-chain take rates cluster at 1 to 5 basis points blended. Multiplying the two ranges yields an expected revenue band of $20k to $100k per day, or $0.75M to $2.5M per month. The live number should fall inside this band. Significant deviation is either a data-quality issue or genuine signal that solver share has shifted." + - "Reproducibility. The full harness source is published in the OpenChainBench harnesses directory (Go). Anyone can clone, set the Relay.link API endpoint env var, run the binary against a Prometheus scraper, and reproduce these metrics. The bench does not rely on any private Mobula service for measurement, the only external dependency is the Relay.link public API and a public USD price reference (CoinGecko or equivalent)." + - "Methodology versioning. Any change to the margin definition, window sizes, pricing source, or excluded-swap rules ships as a public PR with a 14-day comment window. Major changes (e.g. switching from upper-bound margin to a solver-net-decomposed model once private data becomes available) run a 30-day shadow period publishing old and new metric series in parallel." + +findings: + - "Relay implied protocol revenue (24h, upper bound) is currently {{p50:relay}}. Sum of (USD paid - USD received - gas - disclosed app fees) across every successful swap in the last 24 hours. This is the smallest number consistent with the public on-chain swap data and an upper bound on what Relay alone retains after solver-share rebates." + - "The 7-day rolling sum sits at {{p90:relay}}. Smoothing across a week removes day-of-week volume effects and the noise from a single large arbitrage swap that can dominate a 24-hour window. Read this number as the steady-state protocol-revenue ceiling." + - "The 30-day rolling sum is {{p99:relay}}. Monthly recurring revenue ceiling, useful for comparing against public revenue disclosures from comparable cross-chain protocols and against the $0.75M-$2.5M per month expected band derived from volume times take-rate cross-validation." + - "Implied take rate over the last 24 hours is approximately (relay_revenue_usd / relay_volume_usd) * 10000 basis points. Expected blended band per industry cross-validation is 1 to 5 bps. A reading outside this band is either a data-quality issue (priced-swap ratio dropping, gas-estimation drift) or genuine signal that solver share has shifted." + - "Pricing coverage (priced swaps / total swaps over 24h) controls how much of true volume the headline captures. A 100 % ratio means every swap was joined against a USD reference and the headline reflects the full flow. A lower ratio means long-tail tokens are dropping out of the margin sum and the published number understates true protocol revenue proportionally." + +faq: + - q: "Is this Relay.link's actual revenue?" + a: "No. It is an upper bound. Implied margin equals USD paid minus USD received minus gas minus disclosed app fees, summed over completed swaps. Relay shares an unknown fraction of that margin with its solver network through arrangements the public API does not break out. The headline therefore captures what Relay AND its solver network collectively retain, not Relay's own bottom-line revenue. Without access to internal rebate accounting, the bench cannot decompose the split. Treat the headline as a ceiling and apply your own solver-share assumption if you need a Relay-only number." + - q: "Why publish an upper bound instead of an exact number?" + a: "Because no exact number exists in public data and an upper bound is the most rigorous estimate the public swap stream supports. Relay does not publish a fee schedule mapping volume to protocol revenue, and solver-rebate arrangements are private. The alternatives are either to publish nothing, or to publish a guess. An upper bound computed directly from the swap data is more useful than a guess, and is honest about the residual uncertainty. The methodology section spells out exactly what the upper-bound assumption is so readers can apply their own adjustments." + - q: "What does the expected band of $20k-$100k per day come from?" + a: "Cross-validation. Public disclosures put Relay daily volume at $80M to $150M per day. Industry-standard cross-chain take rates cluster at 1 to 5 basis points blended (bridge aggregators and routing protocols typically sit in this range). Multiplying the two ranges yields an expected revenue band of $20k to $100k per day, or $0.75M to $2.5M per month. The live bench number should fall inside this band. A reading meaningfully outside it is either a data-quality issue the methodology flags, or a signal that the cross-validation assumptions need to be revisited." + - q: "Why are 24h, 7d and 30d shown in the p50 / p90 / p99 columns?" + a: "OpenChainBench's standard leaderboard renders p50 / p90 / p99 of a latency-like metric. For a revenue benchmark there is no native percentile interpretation, so the three slots are repurposed to surface the same metric at three timescales: 24h in p50, 7d in p90, 30d in p99. This preserves the at-a-glance comparability of the leaderboard while making the multi-window nature of the metric explicit. The methodology section calls this out so readers do not misread the columns as actual percentiles." + - q: "What happens to swaps whose tokens have no USD price at swap time?" + a: "They are excluded from the margin sum and the volume sum, and counted separately as relay_swaps_unpriced_total. The success column on the bench page is priced / (priced + unpriced), so a low ratio is visible. Causes of an unpriced swap include long-tail token without a CoinGecko entry, price-feed gap, or a brand-new token launched within minutes of the swap. The published headline understates true volume and margin proportionally to the unpriced share, so a 95 % priced ratio means the headline is about 5 % low." + - q: "Why is the freshness threshold 5 minutes?" + a: "The harness polls Relay's public API every 60 seconds. expected_freshness_seconds is set to 5 minutes which is 5x the poll cadence, so one missed cycle is tolerated and roughly two missed cycles fire the cron alerter. Setting it tighter (e.g. 90 s) would cause spurious alerts on transient API hiccups; setting it looser (e.g. 30 min) would let a multi-cycle outage go unnoticed. 5 min is the standard balance for a 1-min-cadence harness in this repo." + - q: "Could solver rebates make the real number much lower than the headline?" + a: "Yes. If Relay rebates half of every basis point of margin back to the solver that filled the swap, the Relay-only revenue is half the headline. Public disclosures from comparable protocols (e.g. CowSwap, 1inch Fusion) suggest solver-share fractions in the 30 % to 70 % range depending on intent design. The bench does not pick a number for Relay because that would be a guess. Readers building a P&L model should apply their own solver-share assumption to the headline. The headline itself is the ceiling, the floor is open." + - q: "Why is this bench in the Bridges category?" + a: "Relay.link is a cross-chain swap routing protocol, the closest fit in the OpenChainBench category list is Bridges. Other benches in this category measure bridge fees, bridge quote latency and bridge-monitor finality. A Trading or Aggregators classification would also be defensible since Relay is structurally closer to an intent-settlement layer than a classic bridge, but Bridges is the most discoverable bucket for the readers most likely to want this number." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/relay-revenue +# The Relay harness lives in the private mobula-api/miniapps tree for +# now; this spec self-links until the harness is ported to the public +# OCB repo. Additional bridges (across-revenue, socket-revenue) will +# land as sibling harnesses and feed their own provider entries into +# this same bench spec. + +prometheus: + window: 24h + expected_freshness_seconds: 300 + +# Real metrics emitted by the Relay.link revenue harness: +# relay_revenue_usd{window} gauge (window: 24h | 7d | 30d) +# relay_volume_usd{window} gauge (window: 24h | 7d | 30d) +# relay_swap_count{window} gauge (window: 24h | 7d | 30d) +# relay_take_rate_bps gauge (24h window only, derived margin / volume * 10000) +# relay_swaps_priced_total counter +# relay_swaps_unpriced_total counter +# relay_last_poll_timestamp_seconds gauge +# +# Mapping to leaderboard columns: +# p50 = relay_revenue_usd{window="24h"} (latest 24h window) +# p90 = relay_revenue_usd{window="7d"} (rolling 7d window) +# p99 = relay_revenue_usd{window="30d"} (rolling 30d window) +# mean = relay_revenue_usd{window="7d"} / 7 (daily avg over 7d) +# series = relay_revenue_usd{window="24h"} (chart, evolves over time) +# sample_size = relay_swap_count{window="24h"} +# success = priced / (priced + unpriced) (data-quality ratio) +# +# Note: the p50 / p90 / p99 slots here are repurposed for the 24h / 7d / 30d +# windows because a revenue gauge has no native percentile interpretation. +# This is called out in the methodology and the FAQ. + +providers: + - slug: relay + name: Relay + tag: Cross-chain swap routing protocol + formula: "Default headline = ceiling: sum over 24h of (input USD − output USD − gas USD − app fees) per successful swap. Toggle Floor for the on-chain solver-wallet stablecoin delta." + type: relay + secondary: + label: Take rate (24h, bps) + value: "see relay_take_rate_bps" + queries: + p50: relay_revenue_usd{window="24h"} + p90: relay_revenue_usd{window="7d"} + p99: relay_revenue_usd{window="30d"} + mean: relay_revenue_usd{window="7d"} / 7 + success: sum(increase(relay_swaps_priced_total[24h])) / (sum(increase(relay_swaps_priced_total[24h])) + sum(increase(relay_swaps_unpriced_total[24h]))) + sample_size: relay_swap_count{window="24h"} + series: relay_revenue_usd{window="24h"} + +# Two switchable metric panels above the chart so readers can flip between +# the public-API ceiling (the default headline) and the on-chain solver- +# wallet floor. The actual revenue Relay retains lives between the two. +metric_panels: + - id: ceiling + label: Ceiling (implied margin, 24h) + metric: relay_revenue_usd{window="24h"} + label_key: provider + unit: usd + higher_is_better: true + description: "Upper bound from the public Relay swap API. Sum over 24h of input minus output minus gas minus disclosed app fees. Includes solver and integrator share." + - id: floor + label: Floor (solver stablecoin delta, 24h) + metric: relay_solver_balance_delta_usd{kind="stables",window="24h"} + label_key: provider + unit: usd + higher_is_better: true + description: "Lower bound from the Relay solver EOA stablecoin balance change over 24h, sampled from Mobula's wallet portfolio every 5 min. What the system actually retained before any sweep to ops." diff --git a/benchmarks/celo-rpc.yml b/benchmarks/celo-rpc.yml index 6ff76e77..5967ae4c 100644 --- a/benchmarks/celo-rpc.yml +++ b/benchmarks/celo-rpc.yml @@ -4,8 +4,8 @@ slug: celo-rpc number: "057" title: Fastest free Celo RPC, live no-key endpoint latency seo_title: "Fastest free Celo RPC 2026" -seo_description: "{{best_name}} leads free Celo RPC at {{best_p50}} (eth_blockNumber p50, 24h). 5 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Celo RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Celo RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 5 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Celo RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -14,14 +14,14 @@ unit: ms higher_is_better: false seo_intro: | - Celo brings 5 no-key providers anchored by Forno (`forno.celo.org`), cLabs' public endpoint that predates most of the gateway industry. Since Celo's migration to an Ethereum L2 the RPC surface is standard EVM, so the multi-chain gateways (PublicNode, dRPC, 1RPC, Tenderly) compete directly with the house endpoint on the identical `eth_blockNumber` probe every 15 seconds from three regions. + Celo brings 5 no-key providers anchored by Forno (`forno.celo.org`), cLabs' public endpoint that predates most of the gateway industry. Since Celo's migration to an Ethereum L2 the RPC surface is standard EVM, so the multi-chain gateways (PublicNode, dRPC, 1RPC, Tenderly) compete directly with the house endpoint on the identical `eth_getBlockByNumber` probe every 15 seconds from three regions. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Celo endpoint that sustains continuous probing, 5 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Celo-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Celo endpoint that sustains continuous probing, 5 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Celo-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,20 +29,20 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"celo\". Provider coverage: 5 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Celo (Forno)). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Celo RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 5 measured providers." + - "{{best_name}} currently leads free Celo RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 5 measured providers." - "{{name:drpc}} ({{p50:drpc}}) extends its long-tail run here, 10 of the 12 expansion chains fall to it on the 3-region average, a consistency win built on anycast rather than any single-region record." - "Forno remains a serviceable default years after launch, but it is one origin: at least two of our three probe regions always see it with an ocean in the path, which the region tabs make visible." - "{{name:tenderly}} shows the same long-tail collapse measured across this expansion: ~330 ms flat in all three regions, single-origin routing behind a gateway that is genuinely fast on the majors." faq: - q: "What is the fastest free Celo RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 5 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 5 no-key providers probed every 15 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 Celo RPCs work without an API key?" a: "The 5 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Celo (Forno). Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Celo RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Celo RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Is Forno still the right default RPC for Celo?" a: "Forno is stable and honest, but it is a single origin, so at least two of our three probe regions always pay cross-ocean latency to reach it. The current leader above ({{best_name}} at {{best_p50}}) reflects the 3-region average; if your traffic is single-region, open that region's tab, Forno's ranking moves markedly by origin." @@ -68,7 +68,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Celo endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Celo endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="celo"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="celo"}) @@ -91,7 +91,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Celo endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Celo endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="celo"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="celo"}) @@ -114,7 +114,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Celo endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Celo endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="celo"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="celo"}) @@ -137,7 +137,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Celo endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Celo endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="celo"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="celo"}) @@ -160,7 +160,7 @@ providers: - slug: celo-official name: Celo (Forno) tag: cLabs Forno public RPC, Celo mainnet only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Celo (Forno)'s no-key Celo endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Celo (Forno)'s no-key Celo endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="celo-official", chain="celo"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="celo-official", chain="celo"}) diff --git a/benchmarks/cronos-rpc.yml b/benchmarks/cronos-rpc.yml index b6408bc6..15499959 100644 --- a/benchmarks/cronos-rpc.yml +++ b/benchmarks/cronos-rpc.yml @@ -4,8 +4,8 @@ slug: cronos-rpc number: "064" title: Fastest free Cronos RPC, live no-key endpoint latency seo_title: "Fastest free Cronos RPC 2026" -seo_description: "{{best_name}} leads free Cronos RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Cronos RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Cronos RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Cronos RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -17,11 +17,11 @@ seo_intro: | Cronos fields 4 no-key providers and encodes two integration traps from our sweep: PublicNode serves the chain at `cronos-evm-rpc.publicnode.com` (the intuitive `cronos-rpc` subdomain resolves but returns non-JSON), and 1RPC uses the ticker path `1rpc.io/cro`. Tenderly's public gateway does not reach Cronos, making this one of the few pages in the cluster without it. Probes every 15 seconds from three regions. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Cronos endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Cronos-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Cronos endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Cronos-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,20 +29,20 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"cronos\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, 1RPC, Cronos). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Cronos RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free Cronos RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." - "{{name:publicnode}} ({{p50:publicnode}}) hides behind a naming trap: the working subdomain is `cronos-evm-rpc`, while `cronos-rpc` resolves and then returns non-JSON, a failure mode that looks like an outage if you guessed the URL." - "{{name:drpc}} ({{p50:drpc}}) delivers its usual three-region steadiness, the anycast pattern behind its 10-of-12 record across this long-tail expansion." - "MeowRPC, historically listed for Cronos in RPC directories, is absent by measurement: its long-tail DNS is gone and the provider appears defunct outside a handful of legacy chains." faq: - q: "What is the fastest free Cronos RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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 Cronos RPCs work without an API key?" a: "The 4 providers on this page: PublicNode, dRPC, 1RPC, Cronos. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Cronos RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Cronos RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Why isn't MeowRPC listed on Cronos?" a: "We tried it. MeowRPC's long-tail endpoints no longer resolve (DNS gone), and the provider appears defunct outside a few legacy chains, so it failed the live verification, eth_chainId match plus sustained probing, that gates admission to this cluster. Directories still listing it are copying stale metadata; this bench only ranks endpoints that answer." @@ -68,7 +68,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Cronos endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Cronos endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="cronos"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="cronos"}) @@ -91,7 +91,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Cronos endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Cronos endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="cronos"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="cronos"}) @@ -114,7 +114,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Cronos endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Cronos endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="cronos"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="cronos"}) @@ -137,7 +137,7 @@ providers: - slug: cronos-official name: Cronos tag: Cronos Labs public RPC, Cronos EVM only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Cronos's no-key Cronos endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Cronos's no-key Cronos endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cronos-official", chain="cronos"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="cronos-official", chain="cronos"}) diff --git a/benchmarks/ethereum-rpc.yml b/benchmarks/ethereum-rpc.yml index 168e280d..6cc3d1a4 100644 --- a/benchmarks/ethereum-rpc.yml +++ b/benchmarks/ethereum-rpc.yml @@ -4,8 +4,8 @@ slug: ethereum-rpc number: "044" title: Fastest free Ethereum RPC, live no-key endpoint latency seo_title: "Fastest free Ethereum RPC 2026" -seo_description: "{{best_name}} leads free Ethereum RPC at {{best_p50}} (eth_blockNumber p50, 24h). 9 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Ethereum RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Ethereum RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 9 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Ethereum RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -14,14 +14,14 @@ unit: ms higher_is_better: false seo_intro: | - Ethereum carries the largest free-RPC cohort we measure: 9 no-key providers answering the same `eth_blockNumber` probe every 15 seconds from three regions. It is also the chain where reliability analysis earns its keep. Cloudflare-eth answers HTTP 200 in well under a second while an increasing share of calls resolve to a JSON-RPC error body (`-32046 Cannot fulfill request`), and Merkle is excluded outright after recurring Cloudflare lockouts that froze our probes for 20 minutes after a single request. If you paste a free RPC URL into an Ethereum dapp, this page is the live answer to which one deserves it. + Ethereum carries the largest free-RPC cohort we measure: 9 no-key providers answering the same `eth_getBlockByNumber` probe every 15 seconds from three regions. It is also the chain where reliability analysis earns its keep. Cloudflare-eth answers HTTP 200 in well under a second while an increasing share of calls resolve to a JSON-RPC error body (`-32046 Cannot fulfill request`), and Merkle is excluded outright after recurring Cloudflare lockouts that froze our probes for 20 minutes after a single request. If you paste a free RPC URL into an Ethereum dapp, this page is the live answer to which one deserves it. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Ethereum endpoint that sustains continuous probing, 9 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Ethereum-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Ethereum endpoint that sustains continuous probing, 9 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Ethereum-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,20 +29,20 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"ethereum\". Provider coverage: 9 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Nodies, Lava, MeowRPC, Flashbots, Cloudflare). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads the free Ethereum RPC field at {{best_p50}} (`eth_blockNumber` p50, 24h) across 9 measured providers, the largest cohort of any chain in the cluster." + - "{{best_name}} currently leads the free Ethereum RPC field at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 9 measured providers, the largest cohort of any chain in the cluster." - "Cloudflare-eth is the resident cautionary tale: sub-second HTTP 200s that increasingly carry a JSON-RPC error instead of a block number. The success-rate column, not the latency column, tells the real story." - "The p50-to-p99 spread separates the tiers. {{name:1rpc}} sits at {{p50:1rpc}} median but its p99 regularly runs an order of magnitude higher, while {{name:drpc}} ({{p50:drpc}}) keeps a much tighter distribution." - "Merkle is excluded on Ethereum by design: its endpoint sits behind an aggressive bot filter that locks out programmatic clients for ~20 minutes after one request, invisible on any status page." faq: - q: "What is the fastest free Ethereum RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 9 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 9 no-key providers probed every 15 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 Ethereum RPCs work without an API key?" a: "The 9 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Nodies, Lava, MeowRPC, Flashbots, Cloudflare. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Ethereum RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Ethereum RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Why is Cloudflare's Ethereum RPC marked unreliable here?" a: "Cloudflare's public Ethereum gateway switched to a permissioned mode for many JSON-RPC methods. The endpoint still responds fast with HTTP 200, but the body is increasingly a JSON-RPC error (`-32046`) rather than a usable result. We classify a call as `ok` only when the HTTP status is 200 AND the body carries a usable `result` field, so Cloudflare's real success rate is visible in the reliability column instead of hiding behind fast error responses." @@ -68,7 +68,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Ethereum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Ethereum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="ethereum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="ethereum"}) @@ -91,7 +91,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Ethereum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Ethereum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="ethereum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="ethereum"}) @@ -114,7 +114,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Ethereum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Ethereum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="ethereum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="ethereum"}) @@ -137,7 +137,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, 9 chains, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Ethereum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Ethereum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="ethereum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="ethereum"}) @@ -160,7 +160,7 @@ providers: - slug: nodies name: Nodies tag: POKT Network's decentralized public RPC successor, 7+ chains - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Ethereum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Ethereum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="ethereum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nodies", chain="ethereum"}) @@ -183,7 +183,7 @@ providers: - slug: lava name: Lava tag: Decentralized permissionless RPC mesh (ETH + Arbitrum no-key) - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Lava's no-key Ethereum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Lava's no-key Ethereum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="ethereum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="lava", chain="ethereum"}) @@ -206,7 +206,7 @@ providers: - slug: meowrpc name: MeowRPC tag: Free public RPC, no registration - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to MeowRPC's no-key Ethereum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to MeowRPC's no-key Ethereum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="meowrpc", chain="ethereum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="meowrpc", chain="ethereum"}) @@ -229,7 +229,7 @@ providers: - slug: flashbots name: Flashbots tag: Private-mempool RPC, anti-sandwich - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Flashbots's no-key Ethereum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Flashbots's no-key Ethereum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="flashbots", chain="ethereum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="flashbots", chain="ethereum"}) @@ -252,7 +252,7 @@ providers: - slug: cloudflare name: Cloudflare tag: Permissioned-mode for many JSON-RPC methods - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Cloudflare's no-key Ethereum endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Cloudflare's no-key Ethereum endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cloudflare", chain="ethereum"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="cloudflare", chain="ethereum"}) diff --git a/benchmarks/evm-quote-latency.yml b/benchmarks/evm-quote-latency.yml new file mode 100644 index 00000000..efdf1b81 --- /dev/null +++ b/benchmarks/evm-quote-latency.yml @@ -0,0 +1,194 @@ +# OpenChainBench. Bench № 033 + +slug: evm-quote-latency +number: "033" +title: Fastest EVM swap quote API +seo_title: "Fastest EVM swap quote API 2026" +seo_description: "Live p50/p90/p99 latency for major EVM swap quote APIs measured every minute on a rotating token basket." +subtitle: Wall clock p50 latency of GET /quote per provider, measured every minute on a 5 pair basket across 4 chains. + +per_chain_explainer: + - slug: ethereum + h2: "Fastest Ethereum swap quote API" + body: | + {{best_name:chain:ethereum}} currently leads the Ethereum swap-quote field at {{best_p50:chain:ethereum}} (p50, 24h) across the 7 measured providers. Ethereum mainnet holds the deepest EVM liquidity (Uniswap v3 tick-range pools, Curve stable pools, Maker PSM), so route-search complexity is the dominant component of quote latency: aggregators trading off depth-of-search against time-to-first-byte show their architecture here more than on any other chain. Probes run every 60 seconds on a 5-pair basket from us-east, eu-west and Singapore. + - slug: base + h2: "Fastest Base swap quote API" + body: | + {{best_name:chain:base}} currently leads the Base swap-quote field at {{best_p50:chain:base}} (p50, 24h). Base liquidity is dominated by Aerodrome's ve(3,3) gauges and Uniswap v3, with a thinner long tail than Ethereum, so quote engines that pre-warm a per-chain pool graph (KyberSwap, Mobula) have a structural edge against bridge-first aggregators that re-resolve routes per request. Probes run from us-east, eu-west and Singapore on a 0.5 ETH to USDC pair. + - slug: arbitrum + h2: "Fastest Arbitrum swap quote API" + body: | + {{best_name:chain:arbitrum}} currently leads the Arbitrum swap-quote field at {{best_p50:chain:arbitrum}} (p50, 24h). Arbitrum One concentrates liquidity in Camelot v3, Uniswap v3 and Curve, with sub-second sequencer blocks that let quote engines using `eth_call` simulation refresh their price source between every probe. Bebop's RFQ model resolves price through a maker network rather than via on-chain simulation. Probes run every 60s on a 100 USDC to ETH pair from three regions. + - slug: bsc + h2: "Fastest BSC swap quote API" + body: | + {{best_name:chain:bsc}} currently leads the BSC swap-quote field at {{best_p50:chain:bsc}} (p50, 24h). BSC liquidity is concentrated in PancakeSwap v2 and v3, with a long memecoin tail that pushes aggregators toward exhaustive route enumeration. CoW Protocol is absent because CoW Settlement does not deploy on BSC. Probes fire every 60 seconds on a 1 BNB to USDT pair from us-east, eu-west and Singapore against the public sequencer RPC. + +category: Aggregators +status: live +metric: Quote latency +unit: ms +higher_is_better: false + +seo_intro: | + EVM swap aggregator APIs all promise sub second quotes. This benchmark + measures how often they actually deliver one. The harness rotates a 5 + pair basket across Ethereum, Base, Arbitrum and BSC, fires a quote + request at every supported provider in parallel every 60 seconds, and + records the wall clock round trip. The leaderboard sorts by p50 latency + over the last 24 hours, lower is better. A geographic split is also + available, with the same probe running independently from us east, eu + west and Singapore so a wallet integrator can pick the provider whose + edge gateway is closest to their backend. + +abstract: | + Each provider's public quote endpoint is hit on five fixed pairs + (1 ETH to USDT on Ethereum, 1000 USDC to USDT on Ethereum, 0.5 ETH to + USDC on Base, 100 USDC to ETH on Arbitrum, 1 BNB to USDT on BSC), + rotating one pair per 60 second tick. Per (provider, chain) we land + roughly 288 samples in 24 hours, enough to put p90 and p99 buckets on a + solid base. Quote quality (output amount, net of gas) is not scored, + only latency. + +methodology: + - "Five fixed pairs across four chains. Polygon and Optimism will be added in v2 once they reach a comparable sample volume." + - "60 second tick, one pair per tick, round robin. Every (provider, chain) sees a quote every 5 minutes." + - "Three regions (us east, eu west, sgp). Each is a separate Railway service writing the same metric family with a region label, so the page can filter to a single edge or aggregate across all three." + - "Latency is wall clock from request dispatch to last byte received, observed only on the happy path (HTTP 2xx and a parseable output amount). Failures land on dedicated counters (auth, throttle, no route, other) and pull the success gauge to 0." + - "Native asset sentinel is the canonical 0xEeeeeEeee form. Odos uses 0x0000... and Bebop substitutes the wrapped equivalent on the sell side. The adapter normalises per provider." + - "Provider auth: Mobula requires the sponsor key in an Authorization header. KyberSwap, Bebop, LI.FI and OpenOcean accept anonymous traffic." + +findings: + - "{{best_name}} currently leads at {{best_p50}} on the active chain tab, across {{count}} measured providers. Rankings shift per chain tab: a provider's edge placement and per-chain engine split matter more than its global average." + - "{{name:kyberswap}} returns {{p50:kyberswap}} (p50, 24 h). Its aggregator engine is split per chain and the no-auth path keeps the HTTP round trip lean." + - "{{name:openocean}} ({{p50:openocean}}) and {{name:lifi}} ({{p50:lifi}}) trade latency for breadth: both cover 30+ chains, and the route search across many underlying venues dominates their latency curve." + - "Bebop is RFQ. The output amount it quotes is already net of fees because settlement is gasless for the taker. The latency you see is the maker network resolving the price, not a public mempool route search." + +faq: + - q: "Which EVM swap quote API has the lowest latency right now?" + a: "{{best_name}} currently leads at {{best_p50}} (p50 over the last 24 hours) on the active tab, across {{count}} measured providers. The leaderboard re sorts every minute on fresh Prometheus samples, so the answer reflects measured latency on the live basket, not a marketing claim." + - q: "Does this measure quote quality or just speed?" + a: "Speed only. Latency is wall clock round trip from request to last byte, recorded only on a successful parseable response. Output amount in USD is not scored on this bench because providers measure it differently (Bebop is net of fees on a gasless RFQ, others are gross), and converting all outputs to a comparable USD value introduces an oracle bias we do not want to bake into a latency leaderboard." + - q: "Why are some providers not on every chain?" + a: "Each provider declares its supported chain set. The harness skips probes for chains a provider does not claim to cover so the failure counter is not inflated by structural non coverage." + - q: "What happens when a provider rate limits the harness?" + a: "Each adapter classifies the response. 429 lands on a throttle counter and the success gauge drops to 0. 401 and 403 land on an auth counter. Empty or zero output amounts on a 200 land on a no route counter. None of those failure modes contribute to the latency histogram, so the p50 number stays representative of healthy quotes." + - q: "How is the geographic split implemented?" + a: "Three identical Railway services run the harness with different MONITOR_REGION env vars (us east, eu west, sgp). Each writes the same metric family with a region label. The bench page filter on region selects which slice of the histogram is queried." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/evm-swap-quoting + +prometheus: + window: 24h + expected_freshness_seconds: 600 + freshness_metric: evm_swap_quote_latency_ms_count + +dimensions: + chain: + - { value: all, label: All chains } + - { value: ethereum, label: Ethereum } + - { value: base, label: Base } + - { value: arbitrum, label: Arbitrum } + - { value: bsc, label: BSC } + region: + - { value: all, label: All regions } + - { value: us-east, label: US East } + - { value: eu-west, label: EU West } + - { value: sgp, label: Singapore } + +# Exact per-cell (chain x region) rankings for scoped badges. One query, +# one sample per (provider, chain, region) cell. +rank_matrix_query: avg by (provider, chain, region) (ocb:evm_swap_quote_latency_ms:p50_24h) + +providers: + - slug: mobula + name: Mobula + tag: API key required, dedicated edge + formula: "Avg of per-(chain, region) 24h medians of evm_swap_quote_latency_ms{provider=mobula} over the last 24 hours, only successful 2xx responses with a non zero output amount." + queries: + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="mobula"}) + p90: avg(ocb:evm_swap_quote_latency_ms:p90_24h{provider="mobula"}) + p99: avg(ocb:evm_swap_quote_latency_ms:p99_24h{provider="mobula"}) + mean: sum(ocb:evm_swap_quote_latency_ms:sum_rate_24h{provider="mobula"}) / sum(ocb:evm_swap_quote_latency_ms:count_rate_24h{provider="mobula"}) + success: avg(ocb:evm_swap_quote_success:avg_24h{provider="mobula"}) + sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="mobula"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="mobula"}[1h])) by (le)) + + - slug: kyberswap + name: KyberSwap + tag: No auth, per chain aggregator engine + formula: "Avg of per-(chain, region) 24h medians of evm_swap_quote_latency_ms{provider=kyberswap} over the last 24 hours." + queries: + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="kyberswap"}) + p90: avg(ocb:evm_swap_quote_latency_ms:p90_24h{provider="kyberswap"}) + p99: avg(ocb:evm_swap_quote_latency_ms:p99_24h{provider="kyberswap"}) + mean: sum(ocb:evm_swap_quote_latency_ms:sum_rate_24h{provider="kyberswap"}) / sum(ocb:evm_swap_quote_latency_ms:count_rate_24h{provider="kyberswap"}) + success: avg(ocb:evm_swap_quote_success:avg_24h{provider="kyberswap"}) + sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="kyberswap"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="kyberswap"}[1h])) by (le)) + + - slug: bebop + name: Bebop + tag: RFQ gasless, EIP 55 checksum required + formula: "Avg of per-(chain, region) 24h medians of evm_swap_quote_latency_ms{provider=bebop} over the last 24 hours." + queries: + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="bebop"}) + p90: avg(ocb:evm_swap_quote_latency_ms:p90_24h{provider="bebop"}) + p99: avg(ocb:evm_swap_quote_latency_ms:p99_24h{provider="bebop"}) + mean: sum(ocb:evm_swap_quote_latency_ms:sum_rate_24h{provider="bebop"}) / sum(ocb:evm_swap_quote_latency_ms:count_rate_24h{provider="bebop"}) + success: avg(ocb:evm_swap_quote_success:avg_24h{provider="bebop"}) + sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="bebop"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="bebop"}[1h])) by (le)) + + - slug: lifi + name: LI.FI + tag: 30+ chain coverage, same chain via fromChain equal toChain + formula: "Avg of per-(chain, region) 24h medians of evm_swap_quote_latency_ms{provider=lifi} over the last 24 hours." + queries: + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="lifi"}) + p90: avg(ocb:evm_swap_quote_latency_ms:p90_24h{provider="lifi"}) + p99: avg(ocb:evm_swap_quote_latency_ms:p99_24h{provider="lifi"}) + mean: sum(ocb:evm_swap_quote_latency_ms:sum_rate_24h{provider="lifi"}) / sum(ocb:evm_swap_quote_latency_ms:count_rate_24h{provider="lifi"}) + success: avg(ocb:evm_swap_quote_success:avg_24h{provider="lifi"}) + sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="lifi"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="lifi"}[1h])) by (le)) + + - slug: openocean + name: OpenOcean + tag: 40+ chain coverage, numeric chainId in URL + formula: "Avg of per-(chain, region) 24h medians of evm_swap_quote_latency_ms{provider=openocean} over the last 24 hours." + queries: + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="openocean"}) + p90: avg(ocb:evm_swap_quote_latency_ms:p90_24h{provider="openocean"}) + p99: avg(ocb:evm_swap_quote_latency_ms:p99_24h{provider="openocean"}) + mean: sum(ocb:evm_swap_quote_latency_ms:sum_rate_24h{provider="openocean"}) / sum(ocb:evm_swap_quote_latency_ms:count_rate_24h{provider="openocean"}) + success: avg(ocb:evm_swap_quote_success:avg_24h{provider="openocean"}) + sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="openocean"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="openocean"}[1h])) by (le)) + + - slug: cow + name: CoW Protocol + tag: No auth, RFQ batch auction, no BSC + formula: "Avg of per-(chain, region) 24h medians of evm_swap_quote_latency_ms{provider=cow} over the last 24 hours. BSC is skipped since CoW Settlement does not deploy there." + queries: + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="cow"}) + p90: avg(ocb:evm_swap_quote_latency_ms:p90_24h{provider="cow"}) + p99: avg(ocb:evm_swap_quote_latency_ms:p99_24h{provider="cow"}) + mean: sum(ocb:evm_swap_quote_latency_ms:sum_rate_24h{provider="cow"}) / sum(ocb:evm_swap_quote_latency_ms:count_rate_24h{provider="cow"}) + success: avg(ocb:evm_swap_quote_success:avg_24h{provider="cow"}) + sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="cow"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="cow"}[1h])) by (le)) + + - slug: enso + name: Enso + tag: Anonymous shared bucket, success rate capped near 80 percent + formula: "Avg of per-(chain, region) 24h medians of evm_swap_quote_latency_ms{provider=enso}. Enso uses a global 1rps anonymous bucket so ~20% of probes hit 429 and are dropped. Remaining samples keep p50 and p90 stable." + queries: + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="enso"}) + p90: avg(ocb:evm_swap_quote_latency_ms:p90_24h{provider="enso"}) + p99: avg(ocb:evm_swap_quote_latency_ms:p99_24h{provider="enso"}) + mean: sum(ocb:evm_swap_quote_latency_ms:sum_rate_24h{provider="enso"}) / sum(ocb:evm_swap_quote_latency_ms:count_rate_24h{provider="enso"}) + success: avg(ocb:evm_swap_quote_success:avg_24h{provider="enso"}) + sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="enso"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="enso"}[1h])) by (le)) diff --git a/benchmarks/explorer-chain-coverage.yml b/benchmarks/explorer-chain-coverage.yml new file mode 100644 index 00000000..993f6572 --- /dev/null +++ b/benchmarks/explorer-chain-coverage.yml @@ -0,0 +1,139 @@ +# OpenChainBench. Bench № 068 + +slug: explorer-chain-coverage +number: "068" +title: Block explorer chain coverage, registered vs fresh-indexed +seo_title: "Best block explorer API chains 2026" +seo_description: "Block explorer APIs ranked by chains with a working indexer, probe-verified daily against each vendor's own registry. Blockscout, Etherscan, Routescan, Blockchair." +subtitle: Chains where each block explorer family serves a working indexer, probed daily against the vendor's own machine-readable registry, with a separate view over the 50 most active mainnets. +category: Explorers +status: live +metric: Chain coverage +unit: count +higher_is_better: true + +seo_intro: | + This benchmark measures how many blockchains each block explorer + family actually serves, not how many its marketing page claims. + Every family gets the same daily probe. one call to its own + machine-readable registry (the registered count) and one freshness + probe per registered mainnet, where a chain only counts as verified + when the latest indexed block is younger than 60 minutes. A + reachable web server with a stalled indexer does not count. Raw + chain counts reward hosting ghost rollups, so a third number is + published alongside. coverage of the 50 most economically active + mainnets, which is the question integrators actually ask. Marketing + claims in this space disagree wildly, the same vendor is cited at + 100+, 800+ or 3000+ chains depending on the page, and no other + source verifies any of these numbers on a live cadence. + +abstract: | + We benchmark block explorer APIs on three numbers per family, once + per day. registered, the mainnet count the vendor self-declares + through a machine-readable surface (Chainscout registry, Etherscan + chainlist, Routescan blockchains endpoint), verified, the number of + those chains whose explorer API returned a latest indexed block + younger than 60 minutes, and top-50, how many of the 50 most active + mainnets pass the same freshness gate. Registries rot and raw counts + can be inflated by ghost chains; the three numbers together separate + the claim, the catalog, and the working product. The cohort is + limited to explorers with free, reproducible API access. + +methodology: + - "registered = mainnet chains the vendor self-declares via a machine-readable surface: Blockscout's Chainscout registry, Etherscan's keyless chainlist, Routescan's blockchains endpoint, Blockchair's aggregate stats. Testnets are excluded everywhere; for Etherscan the mainnet set is a pinned chain-id allowlist rather than a name heuristic, and any new unclassified listing is excluded and logged until reviewed, so a devnet can never silently inflate the count." + - "verified = registered chains whose latest indexed block is younger than 60 minutes at probe time, read from each family's own API (Blockscout /api/v2/blocks, Routescan blocks, Etherscan getblocknobytime against its index, Blockchair best_block_time). A 200 from a stalled indexer does not count. Quiet chains get a second chance: a block older than 60 minutes still passes when its age is under 10x the chain's own average block time, so on-demand rollups are not punished for being idle." + - "A freshness ladder is measured from the same probes: verified (60 minutes) and verified_strict (5 minutes). Families converge at the loose rung and diverge at the tight one, which separates real-time indexers from batch pipelines at zero extra probing cost." + - "Vantage and denominators, disclosed: probes run from one EU vantage once per day, so a single transient network fault can cost a chain one cycle (publish-then-leave limits the blast radius). The top-50 list contains 17 non-EVM chains that EVM-only families structurally cannot serve; their per-family ceilings are roughly 33 of 50 for Blockscout, Etherscan and Routescan, and the raw counts should be read against those ceilings." + - "top-50 = of the 50 most economically active mainnets (pinned quarterly from a DefiLlama TVL + fees blend, list in the harness source), how many pass the same freshness gate on this family. Raw chain counts reward hosting ghost rollups; this column answers what integrators actually ask and is the one where breadth leaders can lose." + - "Operator attribution: many Blockscout instances are run by the chain teams themselves, not by Blockscout. The registry's hostedBy field is preserved in the harness so the distinction stays auditable; the count measures the software family's working footprint, which is the claim its marketing makes." + - "Cadence: one probe cycle per day, ~600 spaced upstream calls, every surface free. Blockscout's sweep talks to hundreds of distinct hosts through a small worker pool; single-host families are spaced 600ms. Quota-truncated cycles publish nothing. Every family is probed keyless except Etherscan's freshness calls, which use a free key; its registered count stays keyless." + - "Source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/explorer-chain-coverage" + +findings: + - "{{best_name}} currently serves {{best_p50}} fresh-indexed mainnet chains across {{count}} measured explorer families. Verified means the family's own API returned a block younger than 60 minutes on that chain today, not that a chain appears in a registry or on a pricing page." + - "{{name:blockscout}} verifies {{p50:blockscout}} chains against {{p90:blockscout}} registered in its own Chainscout registry. The gap is registry rot made visible: dead rollups, migrated chains and stalled indexers that a raw instance count would still be advertising." + - "{{name:etherscan}} verifies {{p50:etherscan}} chains. Its free tier no longer covers several of its highest-activity chains, which the top-50 column prices in: breadth and free access are different axes, and this bench publishes both." + - "The top-50 column is the anti-inflation view. A family can lead the raw count by hosting long-tail chains nobody uses and still trail where integrators live; reading the two columns together separates footprint from product." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/explorer-chain-coverage + +prometheus: + window: 24h + expected_freshness_seconds: 172800 + +faq: + - q: "Which block explorer supports the most blockchains?" + a: "{{best_name}} currently leads at {{best_p50}} fresh-indexed mainnet chains across {{count}} measured families. Fresh-indexed means the explorer's own API returned a latest block younger than 60 minutes on that chain within the last daily cycle, so the leaderboard reflects working indexers rather than registry entries or marketing claims, which for the same vendor range from 100+ to 3000+ chains depending on the page." + - q: "Why three numbers per explorer?" + a: "Because they answer different questions. registered is what the vendor self-declares through its own machine-readable surface and rots as chains die or migrate. verified is what the daily probe demonstrated: a latest indexed block younger than 60 minutes. top-50 restricts the same gate to the 50 most economically active mainnets, because raw chain counts reward hosting ghost rollups and the integrator question is whether the chains people actually use are covered." + - q: "Does a Blockscout instance run by a chain team count for Blockscout?" + a: "Yes, with the distinction kept auditable. Blockscout is open source and most instances are operated by chain teams; the registry's hostedBy field is preserved by the harness so anyone can split vendor-run from chain-run. The count measures the software family's working footprint, which is exactly the claim being verified, and the same rule benefits every family equally: Routescan and Etherscan run their own indexers and get credit for every chain those serve." + - q: "Why is a reachable explorer not automatically verified?" + a: "Because the failure mode that matters is the silent one: a web server that answers 200 while its indexer stalled hours or weeks ago. The probe reads the timestamp of the latest indexed block from each family's own API and only counts chains where it is younger than 60 minutes. The window tolerates slow block producers while still catching stalled pipelines, and it can be tightened as the cohort matures." + - q: "How often are the counts refreshed?" + a: "Once per day, roughly 600 spaced calls across the cohort, all on free surfaces. A failed or quota-truncated cycle publishes nothing and the previous values carry forward, so a temporary outage never zeroes a family's row." + +# Metrics exposed by the explorer-chain-coverage harness: +# explorer_chains_verified{provider} -> fresh-indexed mainnets (p50) +# explorer_chains_top50{provider} -> top-50 active mainnets covered (p99) +# explorer_chains_registered{provider, registered_source} -> self-declared (p90) +# explorer_probe_errors_total / explorer_probe_calls_total / explorer_last_probe_timestamp + +ledger_columns: + - { label: "Fresh-indexed chains", slot: p50, unit: count } + - { label: "Top-50 covered", slot: p99, unit: count } + - { label: "Registered chains", slot: p90, unit: count } + +providers: + - slug: blockscout + name: Blockscout + tag: Open-source explorer + formula: "Mainnets in its Chainscout registry whose instance returned a latest indexed block younger than 60 minutes; registered is the registry's mainnet count. Probed daily, keyless." + queries: + p50: last_over_time(explorer_chains_verified{provider="blockscout"}[48h]) + p90: last_over_time(explorer_chains_registered{provider="blockscout"}[48h]) + p99: last_over_time(explorer_chains_top50{provider="blockscout"}[48h]) + mean: last_over_time(explorer_chains_verified{provider="blockscout"}[48h]) + success: clamp_max(last_over_time(explorer_chains_verified{provider="blockscout"}[48h]) > bool 0, 1) + sample_size: last_over_time(explorer_chains_verified{provider="blockscout"}[48h]) + series: explorer_chains_verified{provider="blockscout"} + + - slug: etherscan + name: Etherscan + tag: Multichain explorer API + formula: "V2 chainlist mainnets where getblocknobytime found an indexed block in the last 60 minutes; registered is the keyless chainlist mainnet count. Probed daily with a free key." + queries: + p50: last_over_time(explorer_chains_verified{provider="etherscan"}[48h]) + p90: last_over_time(explorer_chains_registered{provider="etherscan"}[48h]) + p99: last_over_time(explorer_chains_top50{provider="etherscan"}[48h]) + mean: last_over_time(explorer_chains_verified{provider="etherscan"}[48h]) + success: clamp_max(last_over_time(explorer_chains_verified{provider="etherscan"}[48h]) > bool 0, 1) + sample_size: last_over_time(explorer_chains_verified{provider="etherscan"}[48h]) + series: explorer_chains_verified{provider="etherscan"} + + - slug: routescan + name: Routescan + tag: Multichain explorer + formula: "Chains in its public blockchains endpoint whose blocks API returned a latest block younger than 60 minutes. Fully keyless. Probed daily." + queries: + p50: last_over_time(explorer_chains_verified{provider="routescan"}[48h]) + p90: last_over_time(explorer_chains_registered{provider="routescan"}[48h]) + p99: last_over_time(explorer_chains_top50{provider="routescan"}[48h]) + mean: last_over_time(explorer_chains_verified{provider="routescan"}[48h]) + success: clamp_max(last_over_time(explorer_chains_verified{provider="routescan"}[48h]) > bool 0, 1) + sample_size: last_over_time(explorer_chains_verified{provider="routescan"}[48h]) + series: explorer_chains_verified{provider="routescan"} + + - slug: blockchair + name: Blockchair + tag: Multichain explorer + formula: "Chains in its aggregate stats whose best_block_time is younger than 60 minutes. Fully keyless. Probed daily." + queries: + p50: last_over_time(explorer_chains_verified{provider="blockchair"}[48h]) + p90: last_over_time(explorer_chains_registered{provider="blockchair"}[48h]) + p99: last_over_time(explorer_chains_top50{provider="blockchair"}[48h]) + mean: last_over_time(explorer_chains_verified{provider="blockchair"}[48h]) + success: clamp_max(last_over_time(explorer_chains_verified{provider="blockchair"}[48h]) > bool 0, 1) + sample_size: last_over_time(explorer_chains_verified{provider="blockchair"}[48h]) + series: explorer_chains_verified{provider="blockchair"} + diff --git a/benchmarks/fraxtal-rpc.yml b/benchmarks/fraxtal-rpc.yml index bfaaec28..4d06d344 100644 --- a/benchmarks/fraxtal-rpc.yml +++ b/benchmarks/fraxtal-rpc.yml @@ -4,8 +4,8 @@ slug: fraxtal-rpc number: "065" title: Fastest free Fraxtal RPC, live no-key endpoint latency seo_title: "Fastest free Fraxtal RPC 2026" -seo_description: "{{best_name}} leads free Fraxtal RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Fraxtal RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Fraxtal RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Fraxtal RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -17,11 +17,11 @@ seo_intro: | Fraxtal, Frax's OP Stack rollup, fields 4 no-key providers: PublicNode, dRPC, Tenderly and Frax's own `rpc.frax.com`. The four-provider floor it just clears is the cluster's deliberate admission bar, chains that could not field four keyless endpoints (Mode, Zora, Abstract) were left out of the expansion entirely rather than shipped as two-row leaderboards. Identical probes every 15 seconds, three regions. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Fraxtal endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Fraxtal-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Fraxtal endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Fraxtal-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,22 +29,22 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"fraxtal\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, Tenderly, Fraxtal). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Fraxtal RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free Fraxtal RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." - "{{name:drpc}} ({{p50:drpc}}) closes out its expansion pattern here, anycast consistency across us-east, eu-west and Singapore, the profile that wins it 10 of the 12 new chains on the 3-region average." - "{{name:tenderly}} posts the recurring long-tail flat line, ~330 ms from every origin at once, single-origin routing on a gateway whose edge network clearly does not front Fraxtal." - "Fraxtal sits exactly at the cluster's admission bar of 4 keyless providers, the fact that Mode, Zora and Abstract missed; a thin field makes the success-rate column the tiebreaker the median cannot show." faq: - q: "What is the fastest free Fraxtal RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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 Fraxtal RPCs work without an API key?" a: "The 4 providers on this page: PublicNode, dRPC, Tenderly, Fraxtal. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Fraxtal RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Fraxtal RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Why are chains like Mode, Zora or Sei missing from this cluster?" - a: "Each failed a specific admission test. Mode, Zora and Abstract could not field 4 keyless providers, below the bar for a meaningful leaderboard. Sei was excluded because dRPC caches `eth_blockNumber` there, poisoning the exact probe we rank on. opBNB fell out because 1RPC returns 429 at our 15-second cadence, leaving only 3 solid providers. The cluster only ships chains where the comparison is honest." + a: "Each failed a specific admission test. Mode, Zora and Abstract could not field 4 keyless providers, below the bar for a meaningful leaderboard. Sei was excluded because dRPC caches `eth_getBlockByNumber` there, poisoning the exact probe we rank on. opBNB fell out because 1RPC returns 429 at our 15-second cadence, leaving only 3 solid providers. The cluster only ships chains where the comparison is honest." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities @@ -68,7 +68,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Fraxtal endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Fraxtal endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="fraxtal"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="fraxtal"}) @@ -91,7 +91,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Fraxtal endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Fraxtal endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="fraxtal"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="fraxtal"}) @@ -114,7 +114,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Fraxtal endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Fraxtal endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="fraxtal"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="fraxtal"}) @@ -137,7 +137,7 @@ providers: - slug: fraxtal-official name: Fraxtal tag: Frax-operated public RPC, Fraxtal mainnet only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Fraxtal's no-key Fraxtal endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Fraxtal's no-key Fraxtal endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="fraxtal-official", chain="fraxtal"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="fraxtal-official", chain="fraxtal"}) diff --git a/benchmarks/gnosis-rpc.yml b/benchmarks/gnosis-rpc.yml index 8b1902ad..59d74456 100644 --- a/benchmarks/gnosis-rpc.yml +++ b/benchmarks/gnosis-rpc.yml @@ -4,8 +4,8 @@ slug: gnosis-rpc number: "056" title: Fastest free Gnosis RPC, live no-key endpoint latency seo_title: "Fastest free Gnosis RPC 2026" -seo_description: "{{best_name}} leads free Gnosis RPC at {{best_p50}} (eth_blockNumber p50, 24h). 6 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Gnosis RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Gnosis RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 6 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Gnosis RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -14,14 +14,14 @@ unit: ms higher_is_better: false seo_intro: | - Gnosis is the cluster's clearest proof that official does not mean fast: the chain-official `rpc.gnosischain.com` is the slowest endpoint we measure on the chain, around 433 ms p50 on the 3-region average, while five third-party gateways beat it, including Nodies, whose POKT-backed infrastructure reaches Gnosis as its only chain in this 12-chain expansion. 6 no-key providers, the same `eth_blockNumber` call every 15 seconds, three regions. + Gnosis is the cluster's clearest proof that official does not mean fast: the chain-official `rpc.gnosischain.com` is the slowest endpoint we measure on the chain, around 433 ms p50 on the 3-region average, while five third-party gateways beat it, including Nodies, whose POKT-backed infrastructure reaches Gnosis as its only chain in this 12-chain expansion. 6 no-key providers, the same `eth_getBlockByNumber` call every 15 seconds, three regions. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Gnosis endpoint that sustains continuous probing, 6 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Gnosis-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Gnosis endpoint that sustains continuous probing, 6 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Gnosis-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,20 +29,20 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"gnosis\". Provider coverage: 6 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Nodies, Gnosis). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Gnosis RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 6 measured providers." + - "{{best_name}} currently leads free Gnosis RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 6 measured providers." - "The chain-official endpoint anchors the wrong end of the board: ~433 ms p50 with a similar profile from every region, slower than every gateway on this page. It is honest about its blocks; it is just slow." - "{{name:nodies}} ({{p50:nodies}}) is the quiet story of the expansion: Gnosis is the only long-tail chain it qualifies on, and it serves the chain well, a POKT-routed gateway beating the house endpoint by a wide margin." - "{{name:drpc}} ({{p50:drpc}}) shows its usual anycast consistency here, part of the pattern that has it leading 10 of the 12 long-tail chains on the 3-region average." faq: - q: "What is the fastest free Gnosis RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 6 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 6 no-key providers probed every 15 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 Gnosis RPCs work without an API key?" a: "The 6 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Nodies, Gnosis. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Gnosis RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Gnosis RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Should I use rpc.gnosischain.com as my Gnosis RPC?" a: "Only as a fallback. It is the slowest endpoint we measure on Gnosis, roughly 433 ms median across three regions, several times the gateway tier, though its reliability and head freshness are fine. The measured leaders above serve the same chain with a fraction of the round trip; keep the official endpoint in the rotation for redundancy rather than as primary." @@ -68,7 +68,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Gnosis endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Gnosis endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="gnosis"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="gnosis"}) @@ -91,7 +91,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Gnosis endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Gnosis endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="gnosis"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="gnosis"}) @@ -114,7 +114,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Gnosis endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Gnosis endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="gnosis"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="gnosis"}) @@ -137,7 +137,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Gnosis endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Gnosis endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="gnosis"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="gnosis"}) @@ -160,7 +160,7 @@ providers: - slug: nodies name: Nodies tag: POKT Network's decentralized public RPC successor, 7+ chains - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Gnosis endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Gnosis endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="gnosis"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nodies", chain="gnosis"}) @@ -183,7 +183,7 @@ providers: - slug: gnosis-official name: Gnosis tag: Gnosis chain-official RPC (rpc.gnosischain.com) - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Gnosis's no-key Gnosis endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Gnosis's no-key Gnosis endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="gnosis-official", chain="gnosis"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="gnosis-official", chain="gnosis"}) diff --git a/benchmarks/indexing-freshness.yml b/benchmarks/indexing-freshness.yml new file mode 100644 index 00000000..961254cf --- /dev/null +++ b/benchmarks/indexing-freshness.yml @@ -0,0 +1,139 @@ +# OpenChainBench. Bench № 070 + +slug: indexing-freshness +number: "070" +title: Freshest wallet data API. Zerion, Moralis, Allium, GoldRush, Mobula +seo_title: "Freshest wallet data API 2026" +seo_description: "How fast do wallet APIs index a new transaction? Zerion, Moralis, Allium, GoldRush, Mobula measured live on organic Base transfers, second by second." +subtitle: Seconds between an organic transfer confirming on Base and the moment each wallet data API first returns it, measured continuously on real user transactions. +category: Aggregators +status: live +metric: Indexing freshness +unit: sec +higher_is_better: false + +seo_intro: | + Every wallet app, portfolio tracker and exchange faces the same + question. when a user receives funds, how long before the API my + app is built on actually shows it. Providers advertise "real-time" + and "sub-second indexing"; none publish comparable numbers. This + benchmark measures it the only way that cannot be gamed. we pick a + random, organic native transfer from the newest Base block, real + user, different wallet every time, and poll each provider's wallet + transactions API until the tx hash appears. Because the ground + truth is a random real transaction there is no benchmark wallet a + provider could special-case, and every measurement is publicly + re-verifiable from the tx hash. Cohort. Zerion, Moralis, Allium, + GoldRush (Covalent) and Mobula, all probed with the identical + schedule from the same host. The timing context matters in 2026. + Dune Sim is sunsetting and SimpleHash is gone, so teams are + choosing a replacement wallet data API right now, and freshness is + the spec sheet line that separates them. + +abstract: | + We measure the visibility lag of wallet data APIs. the time between + an organic native transfer confirming on Base (T0 = the instant our + own RPC observes the containing block) and the first poll at which + each provider's wallet transactions endpoint returns that tx hash. + One probe event per 10 minutes; each event uses a fresh random + transaction from a fresh block, so wallets are cold and results + include any lazy, on-demand indexing path a provider runs for + never-before-queried addresses. Detection is parser-free (tx hash + substring in the raw response), the poll schedule is front-loaded + (1s to 120s, identical for every provider), and per-provider + monthly quota guards keep the probe inside every free tier. An + event a provider has not indexed within 120 seconds counts as + missed, which feeds the reliability column, because an API that + never shows the deposit is worse than a slow one. + +methodology: + - "Ground truth: one probe event per 10 minutes. The harness watches new Base blocks through its own RPC and picks one random plain native transfer (value > 0, empty calldata, OP-stack system deposit excluded). T0 is the instant the harness observes the containing block; the same T0 is used for every provider." + - "Anti-gaming by construction: the measured wallet is a random real user's address, different on every event, so no provider can whitelist or pre-warm a known benchmark wallet. Every measurement is re-verifiable by anyone from the public tx hash." + - "Poll schedule: each provider's wallet transactions endpoint is polled at 1, 2, 3, 4, 6, 8, 11, 15, 20, 26, 34, 45, 60, 80, 100 and 120 seconds after T0. Reported lag is the first poll at which the response contains the tx hash, an upper bound with resolution equal to the gap between consecutive polls." + - "Detection is parser-free: the raw response body is scanned for the tx hash substring. Every cohort API returns the hash verbatim, so no provider gains or loses from response schema differences." + - "Cold wallets by design: because each event uses a never-before-queried address, results include any on-demand indexing path a provider runs for new wallets. This mirrors the experience of a user opening an app on a fresh address, and it is disclosed here because warm, continuously-queried wallets may see lower lags." + - "Classification: found (lag recorded), missed (not indexed within 120s), api_error (provider errored on every poll of the event). Percentiles are computed from the histogram of found lags over 24h; the miss rate is published alongside because latency without reliability is a misleading ranking signal." + - "Quota fairness: per-provider monthly call budgets sized to each free tier with headroom, enforced by a guard that pauses probing at 90%. Allium participates in every third event (20k calls/month free tier); all other providers join every event. Sample sizes per provider are published." + - "Scope: Base mainnet, single probe region. Freshness lags are measured in seconds while cross-region network deltas are milliseconds, so multi-region probing would add cost without changing the ranking; this is revisited if two providers converge within one poll-step of each other." + +findings: + - "{{best_name}} currently leads at {{best_p50}} (p50 of found lags, 24h) across organic Base transfers." + - "{{name:zerion}} sits at {{p50:zerion}}. Early runs showed a bimodal pattern, some events indexed in about a second and others surfacing several seconds later, consistent with a caching layer in front of the wallet endpoint." + - "{{name:moralis}} runs at {{p50:moralis}} with a notably tight distribution across events." + - "{{name:goldrush}} trails at {{p50:goldrush}} on this probe. Its unified multi-chain schema trades freshness for breadth, a real trade-off teams should weigh explicitly." + - "Miss rates matter more than medians: an API that fails to show a deposit within two minutes breaks the user flow entirely, so read the reliability column before the latency one." + +faq: + - q: "Which wallet data API shows new transactions fastest?" + a: "Per the live leaderboard above: {{best_name}} at {{best_p50}} (p50 over 24h of organic Base transfers). The ranking re-sorts continuously as probe events land every 10 minutes. Check the miss-rate column too, a provider that occasionally never indexes a transfer is worse for a wallet app than one that is a second slower on median." + - q: "How is indexing freshness measured here?" + a: "The harness picks a random organic native transfer from the newest Base block, records T0 when its own RPC observes the block, then polls every provider's wallet transactions endpoint on an identical front-loaded schedule (1s to 120s) until the tx hash appears in the raw response. The lag is the first successful poll. Events a provider has not indexed within 120 seconds count as missed. The full harness is open source and each measurement can be re-verified from the public tx hash." + - q: "Why use random real transactions instead of a controlled test wallet?" + a: "Two reasons. First, integrity: a fixed benchmark wallet could be whitelisted or pre-warmed by a provider; a random real user's transfer, different every event, cannot. Second, realism: cold, never-before-queried addresses exercise any lazy indexing path a provider runs for new wallets, which is exactly what a new user experiences. The trade-off, disclosed in the methodology, is that continuously-queried warm wallets may see lower lags than reported here." + - q: "Does this benchmark cover more chains than Base?" + a: "Not yet. Base was chosen first for its high volume of plain native transfers (dense organic ground truth) and 2-second blocks. The harness is chain-agnostic and additional EVM chains join by adding an RPC endpoint, subject to each provider's free-tier quota budget. Chain coverage itself differs per provider and is part of what teams should evaluate." + - q: "Why does freshness matter when choosing a wallet API in 2026?" + a: "Because the market is consolidating. Dune Sim is sunsetting in August 2026 and SimpleHash shut down in 2025, so many teams are migrating to a new wallet data API right now. Providers advertise real-time indexing but publish no comparable numbers; deposit visibility lag is the difference between a user seeing their funds arrive and a support ticket. This page is the only continuously measured, provider-neutral comparison of that number." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/indexing-freshness + +prometheus: + window: 24h + freshness_metric: indexing_freshness_seconds + +providers: + - slug: mobula + name: Mobula + tag: Wallet + market data API, 50+ chains + formula: "Median (p50) over 24h of visibility lag in seconds between an organic Base transfer confirming and Mobula's wallet transactions endpoint first returning it, from the shared histogram estimator." + queries: + p50: histogram_quantile(0.5, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="mobula"}[24h]))) + p90: histogram_quantile(0.9, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="mobula"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="mobula"}[24h]))) + success: sum(increase(indexing_probe_total{provider="mobula", result="found"}[24h])) / sum(increase(indexing_probe_total{provider="mobula", result=~"found|missed"}[24h])) + sample_size: sum(increase(indexing_probe_total{provider="mobula", result=~"found|missed"}[24h])) + series: avg_over_time(indexing_freshness_seconds{provider="mobula"}[1h]) + - slug: zerion + name: Zerion + tag: Wallet API behind the Zerion app, 25+ chains + formula: "Median (p50) over 24h of visibility lag in seconds between an organic Base transfer confirming and Zerion's wallet transactions endpoint first returning it, from the shared histogram estimator." + queries: + p50: histogram_quantile(0.5, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="zerion"}[24h]))) + p90: histogram_quantile(0.9, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="zerion"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="zerion"}[24h]))) + success: sum(increase(indexing_probe_total{provider="zerion", result="found"}[24h])) / sum(increase(indexing_probe_total{provider="zerion", result=~"found|missed"}[24h])) + sample_size: sum(increase(indexing_probe_total{provider="zerion", result=~"found|missed"}[24h])) + series: avg_over_time(indexing_freshness_seconds{provider="zerion"}[1h]) + - slug: moralis + name: Moralis + tag: Web3 data API, wallet history across major EVM chains + formula: "Median (p50) over 24h of visibility lag in seconds between an organic Base transfer confirming and Moralis's wallet history endpoint first returning it, from the shared histogram estimator." + queries: + p50: histogram_quantile(0.5, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="moralis"}[24h]))) + p90: histogram_quantile(0.9, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="moralis"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="moralis"}[24h]))) + success: sum(increase(indexing_probe_total{provider="moralis", result="found"}[24h])) / sum(increase(indexing_probe_total{provider="moralis", result=~"found|missed"}[24h])) + sample_size: sum(increase(indexing_probe_total{provider="moralis", result=~"found|missed"}[24h])) + series: avg_over_time(indexing_freshness_seconds{provider="moralis"}[1h]) + - slug: goldrush + name: GoldRush + tag: Covalent's multi-chain wallet API, unified schema + formula: "Median (p50) over 24h of visibility lag in seconds between an organic Base transfer confirming and GoldRush's transactions endpoint first returning it, from the shared histogram estimator." + queries: + p50: histogram_quantile(0.5, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="goldrush"}[24h]))) + p90: histogram_quantile(0.9, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="goldrush"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="goldrush"}[24h]))) + success: sum(increase(indexing_probe_total{provider="goldrush", result="found"}[24h])) / sum(increase(indexing_probe_total{provider="goldrush", result=~"found|missed"}[24h])) + sample_size: sum(increase(indexing_probe_total{provider="goldrush", result=~"found|missed"}[24h])) + series: avg_over_time(indexing_freshness_seconds{provider="goldrush"}[1h]) + - slug: allium + name: Allium + tag: Enterprise realtime wallet APIs, 100+ chains + formula: "Median (p50) over 24h of visibility lag in seconds between an organic Base transfer confirming and Allium's wallet transactions endpoint first returning it. Allium joins every third event to respect its free-tier quota." + queries: + p50: histogram_quantile(0.5, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="allium"}[24h]))) + p90: histogram_quantile(0.9, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="allium"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="allium"}[24h]))) + success: sum(increase(indexing_probe_total{provider="allium", result="found"}[24h])) / sum(increase(indexing_probe_total{provider="allium", result=~"found|missed"}[24h])) + sample_size: sum(increase(indexing_probe_total{provider="allium", result=~"found|missed"}[24h])) + series: avg_over_time(indexing_freshness_seconds{provider="allium"}[1h]) diff --git a/benchmarks/linea-rpc.yml b/benchmarks/linea-rpc.yml index 760dfd7e..266815c7 100644 --- a/benchmarks/linea-rpc.yml +++ b/benchmarks/linea-rpc.yml @@ -4,8 +4,8 @@ slug: linea-rpc number: "051" title: Fastest free Linea RPC, live no-key endpoint latency seo_title: "Fastest free Linea RPC 2026" -seo_description: "{{best_name}} leads free Linea RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Linea RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Linea RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Linea RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -17,11 +17,11 @@ seo_intro: | The no-key field thins out on Linea: 4 providers qualify (PublicNode, dRPC, 1RPC, Tenderly), all multi-chain gateways. Thinner competition makes the reliability columns matter more than raw speed, a fast endpoint with a high stale or timeout rate is a worse default than a slightly slower consistent one. Probes run every 15 seconds from us-east, eu-west and Singapore with full response classification. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Linea endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Linea-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Linea endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Linea-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,19 +29,19 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"linea\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Linea RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free Linea RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." - "With only 4 qualifying providers, a single gateway having a bad day reshuffles the whole board; the success-rate column is the tiebreaker the median doesn't show." - "{{name:tenderly}} covers Linea in its 9-chain public gateway, one of the few non-major chains where its no-key tier reaches." faq: - q: "What is the fastest free Linea RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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 Linea RPCs work without an API key?" a: "The 4 providers on this page: PublicNode, dRPC, 1RPC, Tenderly. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Linea RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Linea RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Why do so few free RPCs support Linea?" a: "Free-tier coverage follows demand: gateways add no-key chains when traffic justifies the infrastructure. Linea's cohort (4 providers) is typical of newer L2s, compare with 9 on Ethereum and 8 on Arbitrum. The flip side is that the providers that do qualify are the disciplined multi-chain operators, so the reliability floor is high even where the field is thin." @@ -67,7 +67,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Linea endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Linea endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="linea"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="linea"}) @@ -90,7 +90,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Linea endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Linea endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="linea"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="linea"}) @@ -113,7 +113,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Linea endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Linea endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="linea"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="linea"}) @@ -136,7 +136,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, 9 chains, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Linea endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Linea endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="linea"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="linea"}) diff --git a/benchmarks/mantle-rpc.yml b/benchmarks/mantle-rpc.yml index 236139f0..99317af2 100644 --- a/benchmarks/mantle-rpc.yml +++ b/benchmarks/mantle-rpc.yml @@ -4,8 +4,8 @@ slug: mantle-rpc number: "053" title: Fastest free Mantle RPC, live no-key endpoint latency seo_title: "Fastest free Mantle RPC 2026" -seo_description: "{{best_name}} leads free Mantle RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Mantle RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Mantle RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Mantle RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -14,14 +14,14 @@ unit: ms higher_is_better: false seo_intro: | - Mantle rounds out the cluster's long tail with 4 qualifying no-key providers, all multi-chain gateways (PublicNode, dRPC, 1RPC, Tenderly). Like every chain in the family, the number that matters is a sustained median, the same `eth_blockNumber` call every 15 seconds from three regions over a rolling 24 hours, not a one-off marketing burst, and archive-depth support is audited separately every 5 minutes. + Mantle rounds out the cluster's long tail with 4 qualifying no-key providers, all multi-chain gateways (PublicNode, dRPC, 1RPC, Tenderly). Like every chain in the family, the number that matters is a sustained median, the same `eth_getBlockByNumber` call every 15 seconds from three regions over a rolling 24 hours, not a one-off marketing burst, and archive-depth support is audited separately every 5 minutes. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Mantle endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Mantle-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Mantle endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Mantle-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,19 +29,19 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"mantle\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Mantle RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free Mantle RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." - "Mantle's field mirrors Linea and Scroll: the disciplined multi-chain gateways and nobody else, so the leaderboard is a pure read on how each gateway's infrastructure reaches the chain." - "Thin cohorts amplify tail events, one regional incident at one gateway visibly moves the 24h aggregate, which is exactly why the page shows per-region breakdowns instead of only the average." faq: - q: "What is the fastest free Mantle RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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 Mantle RPCs work without an API key?" a: "The 4 providers on this page: PublicNode, dRPC, 1RPC, Tenderly. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Mantle RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Mantle RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Are free Mantle RPCs reliable enough to build on?" a: "The four qualifying gateways all maintain high measured success rates on Mantle, but a 4-provider field means less redundancy if one degrades. Use the current leader as primary and the runner-up as fallback, and re-check this page after incidents, the ranking is live and the honest answer moves." @@ -67,7 +67,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Mantle endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Mantle endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="mantle"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="mantle"}) @@ -90,7 +90,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Mantle endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Mantle endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="mantle"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="mantle"}) @@ -113,7 +113,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Mantle endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Mantle endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="mantle"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="mantle"}) @@ -136,7 +136,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, 9 chains, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Mantle endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Mantle endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="mantle"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="mantle"}) diff --git a/benchmarks/megaeth-rpc.yml b/benchmarks/megaeth-rpc.yml new file mode 100644 index 00000000..c3f8a798 --- /dev/null +++ b/benchmarks/megaeth-rpc.yml @@ -0,0 +1,169 @@ +# OpenChainBench. Bench № 072 + +slug: megaeth-rpc +number: "072" +title: Fastest free MegaETH RPC, live no-key endpoint latency +seo_title: "Fastest free MegaETH RPC 2026" +seo_description: "{{best_name}} leads free MegaETH RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 4 no-key providers measured every 30s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public MegaETH RPC endpoint, audited every 30 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + MegaETH markets itself as the real-time Ethereum L2. 10 ms + mini-blocks batched into 1 s EVM blocks, data availability on + EigenDA and ZK fraud proofs via Kailua, live on mainnet since + February 2026. Marketing aside, builders still reach it through + ordinary JSON-RPC, and the free tier of that surface is what this + page measures. the official mainnet.megaeth.com endpoint plus + 1RPC, dRPC and Tenderly Gateway, probed every 30 seconds from + three regions with the cluster's anti-cache payload. The chain's + 10 ms story lives on its WebSocket realtime API; the HTTP numbers + below are what a wallet, indexer or bot actually experiences when + polling. + +abstract: | + We measure the round-trip latency of a single, identical RPC call + (`eth_getBlockByNumber`) against every no-key public MegaETH + endpoint that sustains continuous probing, 4 providers, every 30 + seconds, from us-east, eu-west and Singapore. The harness + classifies every response (ok / http_err / jsonrpc_err / stale / + timeout) 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 + MegaETH-scoped answer with per-region breakdowns first-class. + +methodology: + - "Cadence: every 30 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}`. 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 answer eth_blockNumber without touching a node." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `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](/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain. Every endpoint on this page was live-verified (eth_chainId match + anti-cache probe) before inclusion on 2026-07-08." + - "Chain scope: every query on this page is pinned to chain=\"megaeth\" (chain id 4326, mainnet since February 2026). Provider coverage: 4 no-key endpoints (MegaETH Official, 1RPC, dRPC, Tenderly Gateway). Exclusions follow the cluster-wide rules documented on the parent benchmark." + +findings: + - "{{best_name}} currently leads the free MegaETH RPC field at {{best_p50}} (p50, 24h) across 4 measured providers." + - "{{name:megaeth-official}} answers at {{p50:megaeth-official}}. The official endpoint applies dynamic compute-unit limiting rather than a fixed rps cap; our 1 probe/30s/region load never approaches it." + - "{{name:drpc}} at {{p50:drpc}}, {{name:tenderly}} at {{p50:tenderly}} and {{name:1rpc}} at {{p50:1rpc}} give MegaETH multi-gateway coverage months after mainnet, a faster third-party adoption curve than most 2025-class chains." + - "A 1 s EVM block time means the stale classification window (20 blocks) is only 20 seconds here, the tightest honesty gate in the cluster." + +faq: + - q: "What is the fastest free MegaETH RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (p50 over 24h), measured against 4 no-key providers probed every 30 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples." + - q: "Which MegaETH RPCs work without an API key in 2026?" + a: "Four endpoints sustain continuous keyless probing: the official mainnet.megaeth.com/rpc (dynamic compute-unit limiting), 1RPC, dRPC and Tenderly Gateway. All were live-verified (eth_chainId 4326 + anti-cache probe) before inclusion. The 10 ms mini-block realtime API is WebSocket-only and out of scope for this HTTP latency page." + - q: "Does this page measure MegaETH's 10 ms real-time claim?" + a: "No, and deliberately so. The 10 ms figure refers to sequencer mini-blocks delivered over the WebSocket realtime API; this page measures the plain HTTP JSON-RPC surface that wallets, indexers and most tooling actually poll. Both numbers are real, they describe different transport layers. A WebSocket-level benchmark is a separate methodology." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="megaeth"}) + +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: megaeth-official + name: MegaETH Official + tag: Foundation endpoint, compute-unit limited + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 30s from 3 regions (us-east + eu-west + sgp) to MegaETH Official's no-key MegaETH endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="megaeth-official", chain="megaeth"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="megaeth-official", chain="megaeth"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="megaeth-official", chain="megaeth"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="megaeth-official", chain="megaeth"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="megaeth-official", chain="megaeth"}) / sum(ocb:rpc_call:rate_24h{provider="megaeth-official", chain="megaeth"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="megaeth-official", chain="megaeth"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="megaeth-official", chain="megaeth"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="megaeth-official", chain="megaeth", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="megaeth-official", chain="megaeth", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="megaeth-official", chain="megaeth", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="megaeth-official", chain="megaeth", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="megaeth-official", chain="megaeth", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="megaeth-official", chain="megaeth", region="sgp"}[1h]) + + - slug: 1rpc + name: 1RPC + tag: Privacy-preserving gateway by Automata Network + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 30s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key MegaETH endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="megaeth"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="megaeth"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="1rpc", chain="megaeth"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="1rpc", chain="megaeth"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="1rpc", chain="megaeth"}) / sum(ocb:rpc_call:rate_24h{provider="1rpc", chain="megaeth"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="1rpc", chain="megaeth"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="megaeth"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="megaeth", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="megaeth", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="megaeth", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="megaeth", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="megaeth", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="megaeth", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 30s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key MegaETH endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="megaeth"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="megaeth"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="megaeth"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="megaeth"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="megaeth"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="megaeth"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="megaeth"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="megaeth"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="megaeth", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="megaeth", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="megaeth", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="megaeth", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="megaeth", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="megaeth", region="sgp"}[1h]) + + - slug: tenderly + name: Tenderly Gateway + tag: Public gateway by Tenderly + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 30s from 3 regions (us-east + eu-west + sgp) to Tenderly Gateway's no-key MegaETH endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="megaeth"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="megaeth"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly", chain="megaeth"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly", chain="megaeth"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly", chain="megaeth"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly", chain="megaeth"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly", chain="megaeth"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="megaeth"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="megaeth", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="megaeth", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="megaeth", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="megaeth", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="megaeth", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="megaeth", region="sgp"}[1h]) + diff --git a/benchmarks/monad-rpc.yml b/benchmarks/monad-rpc.yml new file mode 100644 index 00000000..d6068b06 --- /dev/null +++ b/benchmarks/monad-rpc.yml @@ -0,0 +1,192 @@ +# OpenChainBench. Bench № 071 + +slug: monad-rpc +number: "071" +title: Fastest free Monad RPC, live no-key endpoint latency +seo_title: "Fastest free Monad RPC 2026" +seo_description: "{{best_name}} leads free Monad RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 5 no-key providers measured every 30s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Monad RPC endpoint, audited every 30 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Monad shipped its parallel-execution EVM mainnet in November 2025 + with 400 ms blocks and MonadBFT finality around 800 ms, and its + free RPC story is unusually rich for a chain this young. the + Foundation runs five official mirrors behind different infra + vendors (QuickNode, Alchemy, Goldsky, Ankr and its own nodes), and + the multi-chain gateways followed within weeks. We probe the + primary official endpoint plus dRPC, Tenderly Gateway, bloXroute + and OnFinality, every 30 seconds from three regions, with the same + anti-cache payload as every chain in the cluster. If you are + building on Monad and pasting a free RPC URL into your dapp, this + page is the live answer to which one deserves it. + +abstract: | + We measure the round-trip latency of a single, identical RPC call + (`eth_getBlockByNumber`) against every no-key public Monad endpoint + that sustains continuous probing, 5 providers, every 30 seconds, + from us-east, eu-west and Singapore. The harness classifies every + response (ok / http_err / jsonrpc_err / stale / timeout) 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 Monad-scoped answer + with per-region breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 30 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}`. 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 answer eth_blockNumber without touching a node." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `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](/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain. Every endpoint on this page was live-verified (eth_chainId match + anti-cache probe) before inclusion on 2026-07-08." + - "Chain scope: every query on this page is pinned to chain=\"monad\" (chain id 143, mainnet since November 2025). Provider coverage: 5 no-key endpoints (Monad Official, dRPC, Tenderly Gateway, bloXroute, OnFinality). Exclusions follow the cluster-wide rules documented on the parent benchmark." + +findings: + - "{{best_name}} currently leads the free Monad RPC field at {{best_p50}} (p50, 24h) across 5 measured providers." + - "{{name:monad-official}} answers at {{p50:monad-official}}. The Foundation fronts its primary endpoint with QuickNode infrastructure and rate-limits at 25 rps, generous for development traffic." + - "{{name:drpc}} sits at {{p50:drpc}} and {{name:tenderly}} at {{p50:tenderly}}: the multi-chain gateways reached Monad within weeks of mainnet, but route through their global meshes rather than chain-local nodes." + - "{{name:bloxroute}} at {{p50:bloxroute}} is the outlier entry: a BDN operator exposing a keyless endpoint, unusual for a company whose core product is paid low-latency access." + +faq: + - q: "What is the fastest free Monad RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (p50 over 24h), measured against 5 no-key providers probed every 30 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so this page is the answer right now, not a quarterly snapshot." + - q: "Which Monad RPCs work without an API key in 2026?" + a: "Five endpoints sustain continuous keyless probing: the official rpc.monad.xyz (QuickNode-backed, 25 rps; four sibling mirrors exist at rpc1-rpc3.monad.xyz and rpc-mainnet.monadinfra.com on Alchemy, Goldsky, Ankr and Foundation infra), dRPC, Tenderly Gateway, bloXroute and OnFinality. All were live-verified (eth_chainId 143 + anti-cache probe) before inclusion." + - q: "How is Monad RPC latency measured on OpenChainBench?" + a: "One identical JSON-RPC POST (eth_getBlockByNumber latest, rotating request id) every 30 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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 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 + +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="monad"}) + +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: monad-official + name: Monad + tag: Foundation endpoint (rpc.monad.xyz, QuickNode-backed, 25 rps) + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 30s from 3 regions (us-east + eu-west + sgp) to Monad Official's no-key Monad endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="monad-official", chain="monad"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="monad-official", chain="monad"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="monad-official", chain="monad"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="monad-official", chain="monad"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="monad-official", chain="monad"}) / sum(ocb:rpc_call:rate_24h{provider="monad-official", chain="monad"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="monad-official", chain="monad"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="monad-official", chain="monad"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="monad-official", chain="monad", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="monad-official", chain="monad", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="monad-official", chain="monad", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="monad-official", chain="monad", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="monad-official", chain="monad", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="monad-official", chain="monad", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 30s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Monad endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="monad"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="monad"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="monad"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="monad"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="monad"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="monad"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="monad"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="monad"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="monad", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="monad", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="monad", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="monad", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="monad", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="monad", region="sgp"}[1h]) + + - slug: tenderly + name: Tenderly Gateway + tag: Public gateway by Tenderly + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 30s from 3 regions (us-east + eu-west + sgp) to Tenderly Gateway's no-key Monad endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="monad"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="monad"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly", chain="monad"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly", chain="monad"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly", chain="monad"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly", chain="monad"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly", chain="monad"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="monad"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="monad", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="monad", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="monad", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="monad", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="monad", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="monad", region="sgp"}[1h]) + + - slug: bloxroute + name: bloXroute + tag: BDN operator's public Monad endpoint + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 30s from 3 regions (us-east + eu-west + sgp) to bloXroute's no-key Monad endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="bloxroute", chain="monad"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="bloxroute", chain="monad"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="bloxroute", chain="monad"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="bloxroute", chain="monad"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="bloxroute", chain="monad"}) / sum(ocb:rpc_call:rate_24h{provider="bloxroute", chain="monad"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="bloxroute", chain="monad"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="bloxroute", chain="monad"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="bloxroute", chain="monad", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="bloxroute", chain="monad", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="bloxroute", chain="monad", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="bloxroute", chain="monad", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="bloxroute", chain="monad", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="bloxroute", chain="monad", region="sgp"}[1h]) + + - slug: onfinality + name: OnFinality + tag: Public endpoint, 80+ networks + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 30s from 3 regions (us-east + eu-west + sgp) to OnFinality's no-key Monad endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="monad"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="onfinality", chain="monad"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="onfinality", chain="monad"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="onfinality", chain="monad"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="onfinality", chain="monad"}) / sum(ocb:rpc_call:rate_24h{provider="onfinality", chain="monad"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="onfinality", chain="monad"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="monad"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="monad", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="monad", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="monad", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="monad", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="monad", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="monad", region="sgp"}[1h]) + diff --git a/benchmarks/moonbeam-rpc.yml b/benchmarks/moonbeam-rpc.yml index 179d4266..6b030bec 100644 --- a/benchmarks/moonbeam-rpc.yml +++ b/benchmarks/moonbeam-rpc.yml @@ -4,8 +4,8 @@ slug: moonbeam-rpc number: "058" title: Fastest free Moonbeam RPC, live no-key endpoint latency seo_title: "Fastest free Moonbeam RPC 2026" -seo_description: "{{best_name}} leads free Moonbeam RPC at {{best_p50}} (eth_blockNumber p50, 24h). 5 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Moonbeam RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Moonbeam RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 5 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Moonbeam RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -17,11 +17,11 @@ seo_intro: | Moonbeam, Polkadot's EVM parachain, fields 5 no-key providers including the Moonbeam Foundation's `rpc.api.moonbeam.network`. One integration trap surfaced in our verification sweep: 1RPC addresses the chain by its token code, so the working path is `1rpc.io/glmr` and the intuitive `/moonbeam` returns HTTP 400. Probes run every 15 seconds from three regions with full response classification. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Moonbeam endpoint that sustains continuous probing, 5 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Moonbeam-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Moonbeam endpoint that sustains continuous probing, 5 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Moonbeam-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,20 +29,20 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"moonbeam\". Provider coverage: 5 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Moonbeam). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Moonbeam RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 5 measured providers." + - "{{best_name}} currently leads free Moonbeam RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 5 measured providers." - "{{name:drpc}} ({{p50:drpc}}) carries its anycast-consistency pattern onto a Polkadot parachain unchanged: same probe, same edge behavior, same 3-region steadiness that wins it 10 of the 12 long-tail chains." - "{{name:1rpc}} is reachable only at the token-code path `1rpc.io/glmr`; the obvious `/moonbeam` URL 400s, the kind of detail no status page documents and this bench exists to encode." - "{{name:tenderly}} posts the expansion's recurring flat ~330 ms in every region on Moonbeam too, single-origin routing rather than the edge network it runs for the major chains." faq: - q: "What is the fastest free Moonbeam RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 5 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 5 no-key providers probed every 15 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 Moonbeam RPCs work without an API key?" a: "The 5 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Moonbeam. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Moonbeam RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Moonbeam RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Why does 1RPC's Moonbeam endpoint use /glmr instead of /moonbeam?" a: "1RPC keys several chain paths on native-token tickers, and GLMR is Moonbeam's token, so the working endpoint is `1rpc.io/glmr` while `1rpc.io/moonbeam` returns HTTP 400. Our harness verified the chain identity behind the path (eth_chainId 1284) before admitting it, so the numbers above are guaranteed to be Moonbeam mainnet and not a lookalike." @@ -68,7 +68,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Moonbeam endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Moonbeam endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="moonbeam"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="moonbeam"}) @@ -91,7 +91,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Moonbeam endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Moonbeam endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="moonbeam"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="moonbeam"}) @@ -114,7 +114,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Moonbeam endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Moonbeam endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="moonbeam"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="moonbeam"}) @@ -137,7 +137,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Moonbeam endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Moonbeam endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="moonbeam"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="moonbeam"}) @@ -160,7 +160,7 @@ providers: - slug: moonbeam-official name: Moonbeam tag: Moonbeam Foundation public RPC, Moonbeam only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Moonbeam's no-key Moonbeam endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Moonbeam's no-key Moonbeam endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="moonbeam-official", chain="moonbeam"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="moonbeam-official", chain="moonbeam"}) diff --git a/benchmarks/optimism-rpc.yml b/benchmarks/optimism-rpc.yml index 5c895fd8..da396446 100644 --- a/benchmarks/optimism-rpc.yml +++ b/benchmarks/optimism-rpc.yml @@ -4,8 +4,8 @@ slug: optimism-rpc number: "047" title: Fastest free Optimism RPC, live no-key endpoint latency seo_title: "Fastest free Optimism RPC 2026" -seo_description: "{{best_name}} leads free Optimism RPC at {{best_p50}} (eth_blockNumber p50, 24h). 6 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Optimism RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Optimism RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 6 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Optimism RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -17,11 +17,11 @@ seo_intro: | Optimism pits the Foundation's `mainnet.optimism.io` against 5 multi-chain no-key gateways. As on Arbitrum, the official endpoint is documented best-effort, and the measured tail confirms it, while the gateway tier (PublicNode, dRPC, Tenderly, 1RPC, Nodies) competes on tighter distributions. Probes run every 15 seconds from three regions with full response classification, so an endpoint stuck on an old head is flagged `stale` rather than ranked fast. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Optimism endpoint that sustains continuous probing, 6 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Optimism-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Optimism endpoint that sustains continuous probing, 6 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Optimism-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,19 +29,19 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"optimism\". Provider coverage: 6 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Nodies, Optimism). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Optimism RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 6 measured providers." + - "{{best_name}} currently leads free Optimism RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 6 measured providers." - "The Optimism Foundation endpoint shows the same best-effort signature as its Arbitrum counterpart: fine median, p99 several multiples worse, exactly what \"documented best-effort\" looks like in continuous measurement." - "OP Stack symmetry check: comparing this page with the Base leaderboard shows how two chains sharing a stack diverge purely on operator infrastructure." faq: - q: "What is the fastest free Optimism RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 6 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 6 no-key providers probed every 15 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 Optimism RPCs work without an API key?" a: "The 6 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Nodies, Optimism. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Optimism RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Optimism RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Do Base and Optimism RPCs perform the same since both are OP Stack?" a: "No. The stack is shared but the infrastructure is not: different operators, different peering, different gateway coverage. Our measurements regularly show different leaders and different tail behavior on the two chains. If you deploy on both, pick the RPC per chain from each page rather than assuming OP Stack parity." @@ -67,7 +67,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Optimism endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Optimism endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="optimism"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="optimism"}) @@ -90,7 +90,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Optimism endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Optimism endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="optimism"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="optimism"}) @@ -113,7 +113,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Optimism endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Optimism endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="optimism"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="optimism"}) @@ -136,7 +136,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, 9 chains, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Optimism endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Optimism endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="optimism"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="optimism"}) @@ -159,7 +159,7 @@ providers: - slug: nodies name: Nodies tag: POKT Network's decentralized public RPC successor, 7+ chains - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Optimism endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Optimism endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="optimism"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nodies", chain="optimism"}) @@ -182,7 +182,7 @@ providers: - slug: optimism-official name: Optimism tag: Optimism Foundation public RPC, Optimism mainnet only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Optimism's no-key Optimism endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Optimism's no-key Optimism endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="optimism-official", chain="optimism"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="optimism-official", chain="optimism"}) diff --git a/benchmarks/perp-execution-quality.yml b/benchmarks/perp-execution-quality.yml new file mode 100644 index 00000000..095cc44a --- /dev/null +++ b/benchmarks/perp-execution-quality.yml @@ -0,0 +1,110 @@ +# OpenChainBench. Bench № 054 + +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. +category: Trading +status: live +metric: Effective slippage +unit: bps +higher_is_better: false + +seo_intro: | + $BOT is currently listed on two decentralized perp venues, Lighter and + Hyperliquid HIP-3 (as `xyz:BOT`, deployed by third-party operator xyz). + Rack-rate taker fees do not tell a trader which venue actually fills + their market order closer to mid; the answer depends on the depth of + the visible orderbook at the notional they want to trade, and both + books move continuously. This benchmark polls the two public + orderbook endpoints every 30 seconds, walks each side for a fixed set + 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 + likely to execute at market. CEX venues are not part of this bench; + DefiLlama covers the CEX side of the audit. + +abstract: | + We measure the live execution quality of a $BOT market order across + every DEX that lists $BOT as a perp. Every 30 seconds the harness + polls Lighter `/api/v1/orderBookOrders?market_id=185` and Hyperliquid + `POST /info {type:l2Book, coin:"xyz:BOT"}`, computes the top-of-book + mid, then walks each side for a set of USD notional buckets to derive + the size-weighted average execution price. Effective slippage is + reported in basis points relative to mid, per venue, per side and per + size. Books that lack the depth for a given size are excluded from + the slippage series and only reported through + `perp_execution_max_fillable_usd`. No trades are placed; every value + is derived from public, unauthenticated REST endpoints. + +methodology: + - "Cadence: every 30 seconds, in parallel across both venues, 10 second timeout per fetch." + - "Lighter: `GET https://mainnet.zklighter.elliot.ai/api/v1/orderBookOrders?market_id=185&limit=100` returns the top-100 asks and bids for the $BOT perp market." + - "Hyperliquid HIP-3: `POST https://api.hyperliquid.xyz/info {\"type\":\"l2Book\",\"coin\":\"xyz:BOT\"}` returns the L2 book for the xyz-deployed BOT perp under HIP-3 permissionless listings." + - "Mid price: (best_bid + best_ask) / 2 from the top of each book, per tick, per venue." + - "Walk: for each USD notional bucket, iterate the sorted side, consuming `min(level_notional_usd, remaining_target_usd)` until target is filled; size-weighted average execution price is target_usd / total_base_filled." + - "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." + - "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." + - "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." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/perp-execution-scanner + +prometheus: + window: 24h + expected_freshness_seconds: 300 + +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." + - 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?" + a: "HIP-3 is Hyperliquid's permissionless perp listing standard: a third party (here `xyz`) stakes HYPE and deploys a perp market under a namespaced coin symbol, e.g. `xyz:BOT`. The orderbook is settled on the same HyperBFT infrastructure as native Hyperliquid perps, but liquidity is bootstrapped by the deployer rather than by the flagship Hyperliquid market-making stack, so depth on smaller HIP-3 markets is typically thinner." + - q: "How is slippage computed here vs a raw quote?" + a: "Every 30 seconds the harness fetches the top of book on each venue, walks the ask side (for a buy) or the bid side (for a sell), and consumes levels until the requested USD notional is filled. Effective execution price is size-weighted; slippage is (avg_exec - mid) / mid in basis points. This is exactly what a market order would cross assuming the visible book at tick time, no impact model on top." + - q: "Why publish so many size buckets?" + a: "Because execution quality is not linear in size. A venue that fills $1k with 1 bps of slippage can charge 200 bps at $50k if its book thins out fast. The bench publishes eight size buckets from $100 to $500,000 on both sides so a trader can pick the row that matches their intended clip." + - q: "What about CEX venues like Binance or Bybit for $BOT?" + a: "Out of scope for this bench. CEX taker fees and orderbook depth are covered by DefiLlama's aggregated stats for the CEX side. This bench answers a narrower question: given that a user wants to execute onchain, which DEX will fill their $BOT order closer to mid right now." + - q: "What happens when the visible book cannot fill the requested size?" + a: "The slippage series for that (venue, side, size) is dropped for that tick and `perp_execution_max_fillable_usd{side}` reports how much notional the book could actually clear, so the leaderboard does not silently print a misleading number when depth runs out." + +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`." + 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]) + 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"} + + - slug: hyperliquid + name: Hyperliquid HIP-3 (xyz:BOT) + tag: HIP-3 permissionless perp on Hyperliquid L1 + 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\"}`." + 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]) + 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"} diff --git a/benchmarks/polygon-rpc.yml b/benchmarks/polygon-rpc.yml index f7e987e8..87e941aa 100644 --- a/benchmarks/polygon-rpc.yml +++ b/benchmarks/polygon-rpc.yml @@ -4,8 +4,8 @@ slug: polygon-rpc number: "050" title: Fastest free Polygon RPC, live no-key endpoint latency seo_title: "Fastest free Polygon RPC 2026" -seo_description: "{{best_name}} leads free Polygon RPC at {{best_p50}} (eth_blockNumber p50, 24h). 5 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Polygon RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Polygon RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 5 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Polygon RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -14,14 +14,14 @@ unit: ms higher_is_better: false seo_intro: | - Polygon has no chain-official endpoint in the free tier, so this is the purest gateway-versus-gateway comparison in the cluster: PublicNode, dRPC, 1RPC, Tenderly and Nodies, all answering the same `eth_blockNumber` probe every 15 seconds from three regions. With no house endpoint to anchor expectations, the regional flips between gateways decide the ranking, and they flip often. + Polygon has no chain-official endpoint in the free tier, so this is the purest gateway-versus-gateway comparison in the cluster: PublicNode, dRPC, 1RPC, Tenderly and Nodies, all answering the same `eth_getBlockByNumber` probe every 15 seconds from three regions. With no house endpoint to anchor expectations, the regional flips between gateways decide the ranking, and they flip often. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Polygon endpoint that sustains continuous probing, 5 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Polygon-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Polygon endpoint that sustains continuous probing, 5 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Polygon-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,19 +29,19 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"polygon\". Provider coverage: 5 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Nodies). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Polygon RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 5 measured providers." + - "{{best_name}} currently leads free Polygon RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 5 measured providers." - "No foundation endpoint means no single-chain specialist skewing the field: every provider here also serves 4+ other chains, making Polygon the cleanest read on pure gateway quality." - "Regional leadership flips are the norm: the gateway that wins from us-east is regularly beaten from Singapore, so the region tabs above are not decoration, they are the actual answer." faq: - q: "What is the fastest free Polygon RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 5 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 5 no-key providers probed every 15 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 Polygon RPCs work without an API key?" a: "The 5 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Nodies. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Polygon RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Polygon RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Why is there no official Polygon RPC in this benchmark?" a: "Polygon's historically documented public endpoint (`polygon-rpc.com`) is operated by a third party and has moved in and out of key-gating and rate-limit regimes that break our 15-second probe cadence. The bench includes only endpoints that sustain continuous no-key probing; the multi-chain gateways above all pass that bar on Polygon." @@ -67,7 +67,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Polygon endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Polygon endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="polygon"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="polygon"}) @@ -90,7 +90,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Polygon endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Polygon endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="polygon"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="polygon"}) @@ -113,7 +113,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Polygon endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Polygon endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="polygon"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="polygon"}) @@ -136,7 +136,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, 9 chains, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Polygon endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Polygon endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="polygon"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="polygon"}) @@ -159,7 +159,7 @@ providers: - slug: nodies name: Nodies tag: POKT Network's decentralized public RPC successor, 7+ chains - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Polygon endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Polygon endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="polygon"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nodies", chain="polygon"}) diff --git a/benchmarks/portfolio-chain-coverage.yml b/benchmarks/portfolio-chain-coverage.yml new file mode 100644 index 00000000..c190fb77 --- /dev/null +++ b/benchmarks/portfolio-chain-coverage.yml @@ -0,0 +1,156 @@ +# OpenChainBench. Bench № 067 + +slug: portfolio-chain-coverage +number: "067" +title: Wallet portfolio API chain coverage, listed vs probe-verified +seo_title: "Best wallet portfolio API chains 2026" +seo_description: "Wallet portfolio APIs ranked by chains that actually return balances, probe-verified daily against the vendors' own claims." +subtitle: Chains where each wallet-portfolio API actually returns balances for identical public test addresses, probed daily and published next to the vendor's own self-declared chain list. +category: Aggregators +status: live +metric: Chain coverage +unit: count +higher_is_better: true + +seo_intro: | + This benchmark measures how many blockchains each wallet-portfolio + API actually covers, not how many it claims. Every provider gets the + same daily probe. one call to its own machine-readable chain catalog + (the listed count) and a portfolio sweep over a shared set of public + test addresses that hold balances on many chains, one per non-EVM + chain plus one EVM address (the probe-verified count). A chain is verified when the API returns a + balance worth more than $1 on it. The gap between the two numbers is + the story. a chain in a marketing list is not the same as a chain + where the balance indexer actually works. On the first full sweep of + the shared address set (2026-07-06), CoinStats demonstrated 94 of + its 151 listed chains, Mobula 27 of 79 and Zerion 17 of 65. + Verified is an address-dependent lower bound. the test wallets do + not hold assets on every chain in existence, so a provider can cover + more chains than the probe observes, but every verified chain is one + where the API demonstrably returned real balances today. + +abstract: | + We benchmark wallet-portfolio APIs on two numbers per provider, once + per day. listed, the chain count the vendor self-declares through its + own catalog endpoint, and probe-verified, the number of distinct + chains where the vendor's portfolio API returned a balance above $1 + for canonical public test addresses (a Binance-labeled EVM hot + wallet plus fixed Solana and Bitcoin addresses, identical across + providers). Both numbers are published side by side because + self-declared lists can be inflated while verified is a conservative, + address-dependent lower bound. The cohort is currently limited to + providers with free, reproducible API access; expansions are welcome. + +methodology: + - "Identical test addresses for every provider: one shared EVM address (0xF977814e90dA44bFA03b6295A0616a897441aceC, Binance 8 hot wallet, covers every EVM chain in one sweep) plus a pinned set of ~63 public high-balance non-EVM addresses, one per chain (exchange cold wallets, protocol treasuries, Cosmos community-pool accounts). The full list and sourcing rules live in the harness source; every provider that accepts an address type receives the identical address." + - "Verified threshold: a chain counts when the returned balance value exceeds $1; when a response carries no USD pricing at all, a native token amount above 0 counts instead. This filters dust and spam-token noise." + - "listed = chains the vendor self-declares via its own machine-readable catalog endpoint. verified = chains where the probe observed a real balance. The two are never mixed; both are published because self-declared lists can be inflated and verified is an address-dependent lower bound." + - "probed = chains tested with an address known to hold a real balance (validated against the chain's own explorer when pinned) where the API answered definitively. A miss only counts against a vendor when the wallet's target chain appears in the vendor's OWN catalog, so nobody is debited for chains they never claimed. verified/probed is the demonstrable indexer success rate on the vendor's claimed surface; listed minus probed is the residue no funded public address could be sourced for." + - "Providers without a catalog endpoint (Moralis) publish listed with listed_source=probe: the count of chains their portfolio API acknowledged during the probe, bounded by the harness candidate list. It is a floor on the vendor's surface, not a self-declared claim, and the metric label keeps the two comparable but distinguishable." + - "Invocation policy: every vendor is probed through its most precise documented invocation, never a convenience shortcut. CoinStats gets one call per catalog connectionId, Mobula gets explicit blockchains= targeting over its own catalog, Zerion gets one portfolio call per EVM probe wallet (45s timeout: it computes on demand), Moralis gets one net-worth call per accepted chain. A vendor is never penalized for a weakness of the probe protocol." + - "Cadence: one probe cycle every one to two days. Multi-chain sweeps space calls by 1.5s so no vendor sees a burst; per-call timeout is 20s (45s for vendors that compute on demand) with a single retry on 5xx or timeout only, never on 4xx. CoinStats long-tail probes rerun every ~9 days to respect its credit budget; its catalog and EVM sweep refresh every cycle and cached long-tail results are merged, so published numbers stay complete each cycle." + - "Failures (timeouts, rate limits, auth errors) leave the provider's previous gauges in place and increment portfolio_probe_errors_total, so a temporary outage does not silently drop a provider off the leaderboard." + - "Cohort limited to providers with free, reproducible API access; expansions welcome. Zapper is excluded pending an API key, DeBank offers no free tier, and Codex gates its balances query behind the paid Growth plan." + - "Source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/portfolio-chain-coverage" + +findings: + - "{{best_name}} currently leads at {{best_p50}} probe-verified chains across {{count}} measured providers. The number counts chains where the portfolio API returned a balance above $1 for the shared test addresses today, not chains that appear in a catalog or on a marketing page." + - "{{name:coinstats}} verifies {{p50:coinstats}} chains against a self-declared catalog of 151, notably more than the 120+ its marketing quotes. No vendor demonstrates its full list: on the first full sweep CoinStats verified about two thirds of its catalog, Mobula and Zerion roughly a third and a quarter of theirs." + - "{{name:mobula}} verifies {{p50:mobula}} chains and {{name:zerion}} verifies {{p50:zerion}}. Zerion's probe is EVM-only by API design (its portfolio endpoint takes one EVM address), while Mobula and CoinStats also accept the Solana and Bitcoin test addresses, which shapes what each verified count can include." + - "Verified is a lower bound, not an exhaustive coverage audit. The test wallets hold balances on many chains but not all, so a provider can support chains the probe never observes. What the number guarantees is that every verified chain returned real balances for a real address through the public API today." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/portfolio-chain-coverage + +prometheus: + window: 24h + expected_freshness_seconds: 172800 + +faq: + - q: "Which portfolio API supports the most blockchains?" + a: "{{best_name}} currently leads at {{best_p50}} probe-verified chains across {{count}} measured providers. Probe-verified means the provider's portfolio API returned a balance above $1 on that chain for identical public test addresses within the last daily cycle, so the leaderboard reflects chains where the balance indexer demonstrably works, not chains claimed in a catalog." + - q: "Why publish both a listed and a verified chain count?" + a: "Because they answer different questions. listed is what the vendor self-declares through its own catalog endpoint and can be inflated by chains where indexing is partial or broken. verified is what the probe actually observed and is a conservative, address-dependent lower bound. On the first full sweep of the shared address set, CoinStats listed 151 chains and verified 94, Mobula 79 and 27, Zerion 65 and 17. Reading both numbers together tells you how much weight a vendor's own list deserves." + - q: "What does the probed column add?" + a: "It splits the gap between listed and verified into its two honest parts. probed counts chains tested with an address that demonstrably holds a real balance per the chain's own explorer, where the API gave a definitive answer; a failed probe only counts when the target chain is in the vendor's own catalog, so nobody is debited for chains they never claimed. So verified/probed is the indexer's real success rate on testable chains, and listed minus probed is the residue where no funded public address could be sourced, about which the bench stays silent. A chain that is probed but not verified is a documented indexer gap, not a guess." + - q: "Why are Zapper, DeBank, Dune and Codex not in the cohort?" + a: "The cohort is limited to providers with free, reproducible API access so anyone can rerun the harness and get the same numbers. Zapper is excluded for now pending an API key. DeBank offers no free API tier. Codex gates its balances query behind the paid Growth plan. Dune's Sim API, the natural candidate for its wallet balances product, is sunsetting on August 1, 2026 per its own site banner, so it cannot be measured on an ongoing basis. Expansions are welcome; the harness already ships a Zapper source that activates as soon as a key is configured." + - q: "How is Moralis measured without a chain catalog endpoint?" + a: "Moralis exposes no machine-readable chain catalog and every wallet call takes an explicit chain list, so its listed count carries listed_source=probe: the chains its net-worth endpoint accepted this cycle out of a candidate list mirroring its own supported-chains documentation, plus Solana through its separate gateway. That makes Moralis's listed a floor on its acknowledged surface rather than a self-declared claim, and the label is published so the distinction stays visible." + - q: "Does a low verified count mean the provider is bad?" + a: "No. verified is a lower bound that depends on where the shared test addresses hold balances. A provider can support chains the probe never observes, and Zerion's probe is EVM-only because its portfolio endpoint takes a single EVM address. What a low count does tell you is how much of the vendor's self-declared list was demonstrable with real addresses through the public API today." + - q: "Why do the numbers move by a chain or two between days?" + a: "Three benign reasons. The test addresses are live wallets: a balance can cross the $1 threshold in either direction. Some vendors compute portfolios on demand and a cold-cache wallet can time out one cycle and answer the next; timeouts are recorded as errors, never as failures, so they cost at most one cycle of visibility. And vendor catalogs themselves drift as chains get added or retired. Structural changes look like steps that persist across cycles; single-cycle wiggles of one or two chains are noise by design." + - q: "How often are the counts refreshed?" + a: "Once per day. Probes spend paid API credits, so the cadence is deliberately conservative, roughly 150 spaced calls across the whole cohort per cycle. A failed cycle leaves the provider's previous gauge values in place and buckets the failure in portfolio_probe_errors_total, so a temporary outage never silently zeroes a provider's row." + +# Real metrics exposed by the portfolio-chain-coverage harness: +# portfolio_chains_verified{provider} -> gauge, distinct chains with a +# real balance (> $1, or native amount > 0 when USD absent) for the +# canonical test addresses. Headline (slot p50). +# portfolio_chains_probed{provider} -> gauge, chains tested with a +# known-funded address and answered definitively (slot p99). +# portfolio_chains_listed{provider, listed_source} -> gauge, the +# vendor's self-declared chain count from its catalog endpoint +# (listed_source="declared"). Secondary column (slot p90). +# portfolio_probe_errors_total{provider, kind} -> counter, probe +# failures bucketed by kind. +# portfolio_last_probe_timestamp{provider} -> staleness alarm for the +# daily cadence. + +ledger_columns: + - { label: "Probe-verified chains", slot: p50, unit: count } + - { label: "Probed chains", slot: p99, unit: count } + - { label: "Listed chains", slot: p90, unit: count } + +providers: + - slug: coinstats + name: CoinStats + tag: Portfolio + market data + formula: "Distinct chains where CoinStats returned a balance above $1 for the shared test addresses (EVM in one call, Solana and Bitcoin via connectionId probes); listed is the row count of its /wallet/blockchains catalog. Probed daily." + queries: + p50: last_over_time(portfolio_chains_verified{provider="coinstats"}[48h]) + p90: last_over_time(portfolio_chains_listed{provider="coinstats"}[48h]) + p99: last_over_time(portfolio_chains_probed{provider="coinstats"}[48h]) + mean: last_over_time(portfolio_chains_verified{provider="coinstats"}[48h]) + success: clamp_max(last_over_time(portfolio_chains_verified{provider="coinstats"}[48h]) > bool 0, 1) + sample_size: last_over_time(portfolio_chains_verified{provider="coinstats"}[48h]) + series: portfolio_chains_verified{provider="coinstats"} + + - slug: zerion + name: Zerion + tag: Wallet + portfolio API + formula: "Distinct chains with more than $1 in Zerion's positions_distribution_by_chain for the shared EVM test address (EVM only by API design); listed is the chain count of its /v1/chains/ catalog. Probed daily." + queries: + p50: last_over_time(portfolio_chains_verified{provider="zerion"}[48h]) + p90: last_over_time(portfolio_chains_listed{provider="zerion"}[48h]) + p99: last_over_time(portfolio_chains_probed{provider="zerion"}[48h]) + mean: last_over_time(portfolio_chains_verified{provider="zerion"}[48h]) + success: clamp_max(last_over_time(portfolio_chains_verified{provider="zerion"}[48h]) > bool 0, 1) + sample_size: last_over_time(portfolio_chains_verified{provider="zerion"}[48h]) + series: portfolio_chains_verified{provider="zerion"} + + - slug: mobula + name: Mobula + tag: Aggregator + intent layer + formula: "Distinct cross_chain_balances keys above $1 from /wallet/portfolio, each wallet explicitly targeted via blockchains= over Mobula's own catalog; listed is its /api/1/blockchains count. Probed daily." + queries: + p50: last_over_time(portfolio_chains_verified{provider="mobula"}[48h]) + p90: last_over_time(portfolio_chains_listed{provider="mobula"}[48h]) + p99: last_over_time(portfolio_chains_probed{provider="mobula"}[48h]) + mean: last_over_time(portfolio_chains_verified{provider="mobula"}[48h]) + success: clamp_max(last_over_time(portfolio_chains_verified{provider="mobula"}[48h]) > bool 0, 1) + sample_size: last_over_time(portfolio_chains_verified{provider="mobula"}[48h]) + series: portfolio_chains_verified{provider="mobula"} + + - slug: moralis + name: Moralis + tag: Web3 data API + formula: "Distinct chains where Moralis net-worth returned more than $1 for the shared EVM address, plus a Solana gateway probe; no catalog endpoint, so listed counts chains its API accepted (listed_source=probe). Probed daily." + queries: + p50: last_over_time(portfolio_chains_verified{provider="moralis"}[48h]) + p90: last_over_time(portfolio_chains_listed{provider="moralis"}[48h]) + p99: last_over_time(portfolio_chains_probed{provider="moralis"}[48h]) + mean: last_over_time(portfolio_chains_verified{provider="moralis"}[48h]) + success: clamp_max(last_over_time(portfolio_chains_verified{provider="moralis"}[48h]) > bool 0, 1) + sample_size: last_over_time(portfolio_chains_verified{provider="moralis"}[48h]) + series: portfolio_chains_verified{provider="moralis"} diff --git a/benchmarks/rpc-capabilities.yml b/benchmarks/rpc-capabilities.yml index a18d4496..c365d89d 100644 --- a/benchmarks/rpc-capabilities.yml +++ b/benchmarks/rpc-capabilities.yml @@ -4,8 +4,8 @@ slug: rpc-capabilities number: "010" title: Fastest free public RPC for Ethereum, BNB, Polygon and 7 more EVM chains seo_title: "Fastest free public RPC 2026" -seo_description: "Fastest free public RPC live on eth_blockNumber p50. dRPC, PublicNode, 1RPC, Tenderly, Nodies, Lava ranked." -subtitle: HTTP round-trip latency for eth_blockNumber against free, no-key public RPC endpoints across 10 EVM chains, audited every 15 seconds. +seo_description: "Fastest free public RPC live on eth_getBlockByNumber p50. dRPC, PublicNode, 1RPC, 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 15 seconds. category: RPCs status: live metric: RPC latency @@ -19,7 +19,7 @@ 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_blockNumber` every 15 seconds against 15 + budget. We probe `eth_getBlockByNumber` every 15 seconds against 15 audited providers across 10 EVM chains. PublicNode, dRPC, 1RPC, MeowRPC, Tenderly Gateway, Nodies (POKT), Lava Network, Merkle, Flashbots Protect, Cloudflare and 5 chain-official foundation @@ -44,7 +44,7 @@ seo_intro: | abstract: | We measure the round-trip latency of a single, identical RPC call - (`eth_blockNumber`) against every major no-key Ethereum-compatible + (`eth_getBlockByNumber`) against every major no-key Ethereum-compatible public RPC endpoint, across 10 EVM chains. The harness emits three metric families from one binary because they share the same (provider × chain) client matrix and same 15 s @@ -61,7 +61,7 @@ abstract: | methodology: - "Cadence: every 15 seconds per (provider, chain) pair, from each of 3 Railway replicas (us-east Virginia, eu-west Amsterdam, sgp Singapore). The harness reads `$RAILWAY_REPLICA_REGION` at boot, normalizes it to the canonical 3-region set, and stamps a `region` label on every emitted metric. Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are available on the time-series chart." - - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber without touching a node." - "Latency: client-side `time.Now()` delta around the round-trip, in milliseconds. Exposed as both a gauge (`rpc_latency_milliseconds`) and a histogram (`rpc_latency_milliseconds_histogram`) with buckets 50 ms → 10 s, so p50/p90/p99 are computed via Prometheus `histogram_quantile` / `quantile_over_time`." - "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." - "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." @@ -88,7 +88,7 @@ faq: - q: "Which free RPC supports Ethereum archive node calls?" a: "Most free public RPCs are state-pruned, so `eth_getBalance` at block (head, 5,000,000) returns an error rather than the historical balance. This benchmark probes five depths (300, 7,200, 216,000, 1,296,000, 5,000,000 blocks) every five minutes and exposes a gauge per provider per depth. The page surfaces a per-provider archive-depth badge: a green tick at the 5M tier means full historical state is available for free without a key. Anything pruned at 7200 confirms the endpoint is on a Geth default config and only serves the last ~24 hours." - q: "How is RPC latency measured on OpenChainBench?" - a: "The harness issues a single `eth_blockNumber` JSON-RPC POST every 15 seconds against each (provider, chain) pair from each of 3 regions (us-east, eu-west, sgp), using the same plain HTTP client for every endpoint. Wall-clock delta around the round-trip is recorded with millisecond precision and pushed to both a gauge and a histogram. p50/p90/p99 are computed via Prometheus `quantile_over_time` over the last 24 hours. Stale-head detection compares each response against the cross-provider tip and flags anything more than 20 blocks behind as `stale`, so a dead endpoint stuck on yesterday's block cannot rank as the fastest." + a: "The harness issues a single `eth_getBlockByNumber` JSON-RPC POST every 15 seconds against each (provider, chain) pair from each of 3 regions (us-east, eu-west, sgp), using the same plain HTTP client for every endpoint. Wall-clock delta around the round-trip is recorded with millisecond precision and pushed to both a gauge and a histogram. p50/p90/p99 are computed via Prometheus `quantile_over_time` over the last 24 hours. Stale-head detection compares each response against the cross-provider tip and flags anything more than 20 blocks behind as `stale`, so a dead endpoint stuck on yesterday's block cannot rank as the fastest." - q: "Are public RPCs production-grade for a dapp?" a: "Read the success-rate column before deciding. A median latency under 100 ms is irrelevant if the endpoint silently returns a JSON-RPC error 5% of the time or stalls during an L1 reorg. Free public RPCs share rate-limit budgets with the entire internet and have no SLA. They are good for read-heavy demos, local development and fallback paths; production dapps usually graduate to a keyed tier (Alchemy, QuickNode, Infura) for `eth_sendRawTransaction`, websocket subscriptions and archive queries beyond Geth's pruned cap. This benchmark measures the no-key tier specifically because that is the unmeasured part of the market." @@ -148,7 +148,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode"}) @@ -171,7 +171,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's decentralized mesh." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's decentralized mesh." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc"}) @@ -194,7 +194,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's Automata gateway." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's Automata gateway." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc"}) @@ -217,7 +217,7 @@ providers: - slug: meowrpc name: MeowRPC tag: Free public RPC, no registration - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to MeowRPC's no-key endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to MeowRPC's no-key endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="meowrpc"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="meowrpc"}) @@ -240,7 +240,7 @@ providers: - slug: flashbots name: Flashbots tag: Private-mempool RPC, anti-sandwich - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to the Flashbots Protect read proxy." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to the Flashbots Protect read proxy." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="flashbots"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="flashbots"}) @@ -263,7 +263,7 @@ providers: - slug: cloudflare name: Cloudflare tag: Permissioned-mode for many JSON-RPC methods - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to cloudflare-eth (check success column for jsonrpc_err share)." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to cloudflare-eth (check success column for jsonrpc_err share)." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cloudflare"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="cloudflare"}) @@ -286,7 +286,7 @@ providers: - slug: base-official name: Base tag: Coinbase-operated, Base mainnet RPC - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Coinbase's `mainnet.base.org` endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Coinbase's `mainnet.base.org` endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="base-official"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="base-official"}) @@ -309,7 +309,7 @@ providers: - slug: binance name: Binance tag: BNB Chain dataseed RPC, Binance-operated - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Binance's `bsc-dataseed1.binance.org` endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Binance's `bsc-dataseed1.binance.org` endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="binance"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="binance"}) @@ -332,7 +332,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, 9 chains, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to `gateway.tenderly.co/public/`." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to `gateway.tenderly.co/public/`." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly"}) @@ -355,7 +355,7 @@ providers: - slug: nodies name: Nodies tag: POKT Network's decentralized public RPC successor, 7+ chains - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies' `*-pokt.nodies.app` endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies' `*-pokt.nodies.app` endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nodies"}) @@ -378,7 +378,7 @@ providers: - slug: lava name: Lava tag: Decentralized permissionless RPC mesh (ETH + Arbitrum no-key) - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Lava Network's no-key mesh endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Lava Network's no-key mesh endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="lava"}) @@ -401,7 +401,7 @@ providers: - slug: merkle name: Merkle tag: Base + BSC public no-key gateway (Ethereum hit by Cloudflare 20-min lockout, excluded) - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Merkle's Base/BSC no-key gateway." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Merkle's Base/BSC no-key gateway." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="merkle"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="merkle"}) @@ -424,7 +424,7 @@ providers: - slug: arbitrum-official name: Arbitrum tag: Arbitrum Foundation public RPC, Arbitrum One only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to the Arbitrum Foundation's `arb1.arbitrum.io/rpc`." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to the Arbitrum Foundation's `arb1.arbitrum.io/rpc`." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="arbitrum-official"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="arbitrum-official"}) @@ -447,7 +447,7 @@ providers: - slug: optimism-official name: Optimism tag: Optimism Foundation public RPC, Optimism mainnet only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to the Optimism Foundation's `mainnet.optimism.io`." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to the Optimism Foundation's `mainnet.optimism.io`." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="optimism-official"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="optimism-official"}) @@ -470,7 +470,7 @@ providers: - slug: avalanche-official name: Avalanche tag: Ava Labs C-Chain public RPC, Avalanche C-Chain only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Ava Labs' `api.avax.network/ext/bc/C/rpc`." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Ava Labs' `api.avax.network/ext/bc/C/rpc`." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="avalanche-official"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="avalanche-official"}) diff --git a/benchmarks/rpc-keyed-latency.yml b/benchmarks/rpc-keyed-latency.yml new file mode 100644 index 00000000..59550b9c --- /dev/null +++ b/benchmarks/rpc-keyed-latency.yml @@ -0,0 +1,238 @@ +# OpenChainBench. Bench № 069 + +slug: rpc-keyed-latency +number: "069" +title: Fastest free-tier keyed RPC. Alchemy, Infura, Chainstack, Ankr, Helius +seo_title: "Fastest free-tier RPC API 2026" +seo_description: "Alchemy vs Infura vs Chainstack vs Ankr vs Helius: free-tier RPC latency measured live every 60s from 3 regions. Signup-gated endpoints, real node calls." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against the signup-gated free tiers of Alchemy, Infura, Chainstack, Ankr and Helius, probed every 60 seconds from 3 regions. +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Every dapp that outgrows the no-key public RPCs asks the same + question next. which free tier do I sign up for. Alchemy, Infura, + Chainstack, Ankr and Helius all hand out a free API key with a + monthly quota; none of them publish comparable latency numbers. + This benchmark probes each provider's free-tier endpoint with the + same anti-cache call used on our no-key bench + (`eth_getBlockByNumber("latest")` with a rotating request id; + `getSlot` on Solana), every 60 seconds, from us-east, eu-west and + Singapore, staying far inside every provider's free quota. Chains + covered. Ethereum (5 providers), Base + Arbitrum + BNB + Polygon + (Alchemy, Infura, Ankr, Chainstack), Optimism (Alchemy, Infura, + Chainstack, QuickNode), Solana (Alchemy, Helius, Chainstack). Current leaders per the live data on this page. + The companion question, which RPC works with no signup at all, is + answered by the [rpc-capabilities](/benchmarks/rpc-capabilities) + bench with the identical methodology, so the keyed premium (or its + absence) is directly readable by comparing the two pages. + +abstract: | + We measure the round-trip latency of a single, identical RPC call + against the signup-gated free tier of every major keyed RPC + provider. The probe is the same anti-cache call as the no-key + bench, so the two tiers are directly comparable: full latest-header + fetch with a rotating JSON-RPC id on EVM chains, `getSlot` at + processed commitment on Solana. Responses are classified ok / + http_err / jsonrpc_err / stale / timeout against a cross-provider + tip, and a per-provider quota guard pauses probing at 90% of each + region's monthly budget so a free key can never be exhausted by the + bench itself. Free tier means the entry plan without a credit + card: what a developer gets in the first five minutes after signup, + not the provider's paid infrastructure. + +methodology: + - "Cadence: every 60 seconds per (provider, chain) pair, from each of 3 probe regions (us-east, eu-west, sgp), 4× slower than the no-key bench because keyed free tiers meter monthly quotas. Headline p50/p90/p99 aggregate across regions via Prometheus `avg(quantile_over_time(...))`." + - "Payload: identical to the no-key bench. EVM: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}`. Solana: `getSlot` at processed commitment. Non-cacheable by design: the rotating id defeats body-keyed edge caches." + - "Authentication: each provider's standard free-tier key, obtained via normal signup, no credit card, no sales contact. Keys live in env vars on the probe services and never in the repo. Endpoint shapes: key-in-path (Alchemy `/v2/`, Infura `/v3/`, Chainstack, Ankr) or query param (Helius `?api-key=`)." + - "Quota guard: each region gets 1/3 of a provider's audited monthly free quota; probing pauses at 90% of that budget until calendar-month rollover (`rpc_keyed_quota_used_ratio`, `rpc_call_total{result=\"quota_paused\"}`). Budgets target ≤2/3 of the real quota, so the bench can never exhaust a key." + - "Call-result classification: `ok` (HTTP 200 + usable result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks / 300 slots behind the cross-provider tip), `timeout`. Latency is recorded only for `ok` responses." + - "Cohort: Alchemy (6 EVM + Solana), Infura / MetaMask Developer (6 EVM), Ankr (5 EVM; Optimism pending chain enablement on the key), Chainstack (6 EVM + Solana via Global Nodes, see plan disclosure below), Helius (Solana only), QuickNode (BNB + Optimism, see plan disclosure below). Free-tier scope differences are disclosed, not hidden: a provider missing from a chain tab means its free tier does not cover that chain (or caps the node count), which is itself a finding." + - "Excluded by design: Tenderly (Node RPC excluded from the free plan), GetBlock (50k CU/day cannot sustain even one chain at probe cadence), Moralis (free plan capped at 2 node endpoints), Blast API (shut down Oct 2025). Each exclusion is quota math or plan scope, never editorial." + - "Plan disclosure: Alchemy, QuickNode and Chainstack are measured through standard shared endpoints of paid accounts. Alchemy and QuickNode route free and paid keys to the same shared fleets; Chainstack Global Nodes are the identical anycast product the free plan provides. The paid difference is quota and node count (free Chainstack caps at one node), not the serving infrastructure, so the latency read is representative of both plans." + - "Fair-play note: free tiers do not necessarily run on the provider's paid-tier infrastructure (e.g. dRPC routes free traffic to a separate provider pool; several providers serve free keys from shared clusters). This page measures exactly what a free signup gets, read it as the free-tier experience, not the provider's ceiling." + +findings: + - "Ethereum is the only chain where all five EVM-capable providers meet: {{best_name:chain:ethereum}} currently leads at {{best_p50:chain:ethereum}} (p50, 24h)." + - "{{name:alchemy}} aggregates {{p50:alchemy}} across its 7 covered chains, the widest free-tier footprint in the cohort (6 EVM + Solana on one key)." + - "{{name:infura}} sits at {{p50:infura}} on the cross-chain aggregate. Its 2026 free tier meters 3M credits per day (80 credits per call), the tightest effective budget of the EVM cohort." + - "{{name:chainstack}} aggregates {{p50:chainstack}} across 7 chains via Global Nodes, the same anycast product its free plan ships (capped at one node there), measured from a paid org and disclosed as such." + - "On Solana, {{best_name:chain:solana}} leads at {{best_p50:chain:solana}} (p50, 24h) between Helius (Solana-native) and Alchemy." + +faq: + - q: "Which free RPC API tier is fastest in 2026?" + a: "Per chain, per the live leaderboard above: on Ethereum {{best_name:chain:ethereum}} at {{best_p50:chain:ethereum}} (p50 over 24h, averaged across us-east, eu-west and Singapore probes). The ranking re-sorts continuously against fresh Prometheus samples. Switch the chain tab for the network your product runs on, free-tier chain coverage differs per provider and that difference is part of the answer." + - q: "How is this different from the no-key public RPC benchmark?" + a: "Same probe, same classification, same regions, different tier. The [rpc-capabilities](/benchmarks/rpc-capabilities) bench measures endpoints that work with zero signup; this page measures what a free API key gets you after a five-minute signup (Alchemy, Infura, Chainstack, Ankr, Helius). Comparing the two pages answers whether the signup buys you anything on latency, and on some chains the no-key tier is genuinely competitive." + - q: "Do the probes stay inside each provider's free quota?" + a: "Yes, by design. One probe per 60 seconds per chain per region ≈ 131k requests/month/chain, against audited free budgets of 1M to 3M requests/month. A quota guard additionally pauses probing at 90% of each region's monthly budget, so the bench can never exhaust a key. Sample sizes per cell are published (`sample_size`), so the statistical cost of the slower cadence is visible." + - q: "Why are QuickNode, Tenderly, Moralis and GetBlock missing?" + a: "Quota math or plan scope, never editorial. QuickNode's free plan issues a single endpoint on one chain (too narrow to compare against multi-chain keys). Tenderly excludes Node RPC from its free plan. GetBlock's 50k CU/day cannot sustain one chain at probe cadence. Moralis caps the free plan at 2 node endpoints. Blast API shut down in October 2025. Providers can enter the cohort the day their free plan clears the bar, and the bar is published in the methodology." + - q: "Is the free tier representative of a provider's paid performance?" + a: "No, and this page does not claim it is. Several providers route free-tier traffic to shared or separate infrastructure pools. What this bench measures is the free-tier experience: the latency and reliability a developer actually gets from the key handed out at signup. Paid-tier SLAs, dedicated clusters and websocket performance are out of scope." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-keyed-latency + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +rank_matrix_query: avg by (provider, chain, region) (ocb:rpc_latency_milliseconds:p50_24h) + +dimensions: + chain: + - { value: all, label: All chains } + - { value: ethereum, label: Ethereum } + - { value: base, label: Base } + - { value: arbitrum, label: Arbitrum } + - { value: optimism, label: Optimism } + - { value: bnb, label: BNB Chain } + - { value: polygon, label: Polygon } + - { value: solana, label: Solana } + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: alchemy + name: Alchemy + tag: 30M CU/mo free, 6 EVM + Solana on one key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for the anti-cache probe sent every 60s from 3 regions to Alchemy's free-tier keyed endpoints." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="alchemy"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="alchemy"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="alchemy"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="alchemy"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="alchemy", tier="keyed"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy", region="us-east"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="alchemy", tier="keyed", region="us-east"}[1h])) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy", region="eu-west"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="alchemy", tier="keyed", region="eu-west"}[1h])) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy", region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="alchemy", tier="keyed", region="sgp"}[1h])) + + - slug: infura + name: Infura + tag: MetaMask Developer, 3M credits/day, 6 EVM chains + formula: "50th percentile over 24h of client-side round-trip latency (ms) for the anti-cache probe sent every 60s from 3 regions to Infura's free-tier keyed endpoints." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="infura"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="infura"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="infura"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="infura"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="infura"}) / sum(ocb:rpc_call:rate_24h{provider="infura"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="infura"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="infura", tier="keyed"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="infura", region="us-east"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="infura", tier="keyed", region="us-east"}[1h])) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="infura", region="eu-west"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="infura", tier="keyed", region="eu-west"}[1h])) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="infura", region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="infura", tier="keyed", region="sgp"}[1h])) + + - slug: ankr + name: Ankr + tag: 200M credits/mo free, 5 EVM chains measured + formula: "50th percentile over 24h of client-side round-trip latency (ms) for the anti-cache probe sent every 60s from 3 regions to Ankr's free-tier keyed endpoints." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="ankr"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="ankr"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="ankr"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="ankr"}) / sum(ocb:rpc_call:rate_24h{provider="ankr"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="ankr"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="ankr", tier="keyed"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", region="us-east"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="ankr", tier="keyed", region="us-east"}[1h])) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", region="eu-west"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="ankr", tier="keyed", region="eu-west"}[1h])) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="ankr", tier="keyed", region="sgp"}[1h])) + + - slug: chainstack + name: Chainstack + tag: Global Nodes on 6 EVM + Solana, plan disclosed + formula: "50th percentile over 24h of client-side round-trip latency (ms) for the anti-cache probe sent every 60s from 3 regions to Chainstack's free-tier Ethereum Global Node." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="chainstack"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="chainstack"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="chainstack"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="chainstack"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="chainstack", tier="keyed"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack", region="us-east"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="chainstack", tier="keyed", region="us-east"}[1h])) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack", region="eu-west"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="chainstack", tier="keyed", region="eu-west"}[1h])) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack", region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="chainstack", tier="keyed", region="sgp"}[1h])) + + - slug: helius + name: Helius + tag: Solana-native, 1M credits/mo free + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a `getSlot` probe sent every 60s from 3 regions to Helius's free-tier Solana endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="helius"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="helius"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="helius"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="helius"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="helius"}) / sum(ocb:rpc_call:rate_24h{provider="helius"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="helius"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="helius", tier="keyed"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="helius", region="us-east"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="helius", tier="keyed", region="us-east"}[1h])) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="helius", region="eu-west"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="helius", tier="keyed", region="eu-west"}[1h])) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="helius", region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="helius", tier="keyed", region="sgp"}[1h])) + + - slug: quicknode + name: QuickNode + tag: Shared fleet, measured on BNB + Optimism, plan disclosed + formula: "50th percentile over 24h of client-side round-trip latency (ms) for the anti-cache probe sent every 60s from 3 regions to QuickNode shared endpoints." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="quicknode"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="quicknode"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="quicknode"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="quicknode"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="quicknode", tier="keyed"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode", region="us-east"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="quicknode", tier="keyed", region="us-east"}[1h])) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode", region="eu-west"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="quicknode", tier="keyed", region="eu-west"}[1h])) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode", region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="quicknode", tier="keyed", region="sgp"}[1h])) diff --git a/benchmarks/scroll-rpc.yml b/benchmarks/scroll-rpc.yml index 0f1d2f2f..9c5c6cf0 100644 --- a/benchmarks/scroll-rpc.yml +++ b/benchmarks/scroll-rpc.yml @@ -4,8 +4,8 @@ slug: scroll-rpc number: "052" title: Fastest free Scroll RPC, live no-key endpoint latency seo_title: "Fastest free Scroll RPC 2026" -seo_description: "{{best_name}} leads free Scroll RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Scroll RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Scroll RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Scroll RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -17,11 +17,11 @@ seo_intro: | Scroll runs the same 4-gateway field as Linea (PublicNode, dRPC, 1RPC, Tenderly), making the two chains a natural controlled experiment: same providers, same probe, different chain infrastructure. The differences you see between this page and the Linea leaderboard are the chains, not the gateways. Probes every 15 seconds, three regions, stale-head detection against the cross-provider tip. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Scroll endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Scroll-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Scroll endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Scroll-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,19 +29,19 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"scroll\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Scroll RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free Scroll RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." - "Scroll and Linea share an identical provider field, so cross-reading the two pages isolates chain-side latency from gateway-side latency, a comparison no single-chain benchmark can offer." - "As on every thin-cohort chain, the success-rate column outranks the latency column for picking a production default." faq: - q: "What is the fastest free Scroll RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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 Scroll RPCs work without an API key?" a: "The 4 providers on this page: PublicNode, dRPC, 1RPC, Tenderly. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Scroll RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Scroll RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Which free RPC should I default to on Scroll?" a: "Start from the current leader above, then check its per-region row for the origin closest to your deployment. With a 4-provider field the honest answer changes more often than on Ethereum, so a primary-plus-fallback pair (the top two on this page) is the resilient configuration rather than any single hardcoded URL." @@ -67,7 +67,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Scroll endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Scroll endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="scroll"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="scroll"}) @@ -90,7 +90,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Scroll endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Scroll endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="scroll"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="scroll"}) @@ -113,7 +113,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Scroll endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Scroll endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="scroll"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="scroll"}) @@ -136,7 +136,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, 9 chains, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Scroll endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Scroll endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="scroll"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="scroll"}) diff --git a/benchmarks/solana-dex-quote-latency.yml b/benchmarks/solana-dex-quote-latency.yml new file mode 100644 index 00000000..34541ae3 --- /dev/null +++ b/benchmarks/solana-dex-quote-latency.yml @@ -0,0 +1,192 @@ +# OpenChainBench. Bench № 029 + +slug: solana-dex-quote-latency +number: "029" +title: Fastest Solana DEX quote API +seo_title: "Fastest Solana DEX quote API 2026" +seo_description: "Live ms latency benchmark for Solana DEX aggregator quote APIs: Jupiter, Mobula, OpenOcean, Raydium ranked." +subtitle: Wallclock milliseconds from quote request to usable quote response. +category: Aggregators +status: live +metric: Quote latency +unit: ms + +seo_intro: | + Solana DEX aggregators sit on the hot path of every swap a wallet, + trading UI or routing front-end builds. A quote API that takes 600 ms + pushes a perceptible delay onto the user; a quote API at 80 ms feels + instant. This benchmark measures the wall-clock latency of the + request-response round trip on each major Solana DEX aggregator's + quote endpoint, against the tokens that are *actually trending right + now* on Solana. Every 60 seconds the harness picks a fresh bonded + token from Mobula Pulse V2's live WebSocket feed (post-bonding-curve + graduates from Pump.fun, Meteora, Raydium LaunchLab and friends) and + asks each provider for a 100 USDC → tokenOut quote at 1% slippage. + Rotating against newly-trending tokens defeats every per-pair edge + cache and forces each provider to actually search a route, so the + recorded number reflects routing-search cost rather than a CDN hit. + Liquidity-gap failures (a provider that can't route the picked token + at all) land on their own counter and are excluded from the latency + histogram, so a provider that fails fast on long-tail coverage isn't + penalised on the percentiles. + +abstract: | + We measure the wallclock latency of the Solana DEX aggregator quote APIs + on freshly-trending tokens. Every 60 seconds the harness picks one token + from a sliding 30-minute window of Mobula Pulse V2 bonded events on + Solana, then sends an identical 100 USDC → tokenOut quote (1% slippage, + no fee, no referrer) to each provider from three Railway regions and + records the time from request dispatch to the first byte of a response + that contains a usable quoted output amount. + +methodology: + - "Providers measured: Jupiter, Mobula, OpenOcean, Raydium. (DFlow added when partnership API key is available.)" + - "Canonical request: 100 USDC → tokenOut at 1% slippage. The tokenOut rotates every tick (see below)." + - "Token rotation (Pulse-fed). A persistent WebSocket to Mobula Pulse V2 (bonded view, Solana) maintains a 30-min sliding pool of trending bonded tokens, typically 50-300 active mints. Each tick picks one at random. A REST snapshot fallback (`market/query` volume ranks 6-55) refreshes every 10 min for Pulse outages. In steady state every tick is sourced from the WS feed." + - "Cadence: one quote per provider per region every 60 seconds. 1,440 samples per provider per region per day." + - "Regions: us-east, eu-west, sgp." + - "Latency definition: wallclock from HTTP request dispatch to the first byte of a response body that includes the quoted output amount (Jupiter `outAmount`, Mobula `data.amountOutTokens`, OpenOcean `data.outAmount`, Raydium `data.outputAmount`). The HTTP client reuses TCP + TLS connections across ticks (warm-path), matching how a long-running backend integration actually consumes the API. The bench measures steady-state round-trip cost, not the first-request cold-start penalty." + - "Failure classes. Each non-success outcome lands on exactly one counter and is excluded from the latency histogram: `solana_quote_throttled_total` (HTTP 429), `solana_quote_auth_error_total` (401/403), `solana_quote_no_route_total` (provider can't route the picked token), `solana_quote_other_error_total` (network/timeout/parse). Each provider's no-route signal is verified: Jupiter NO_ROUTES_FOUND, Mobula 'No route found', Raydium INSUFFICIENT_LIQUIDITY, OpenOcean payload-level." + - "Raydium scope. Raydium's compute API is single-venue (Raydium AMM v4 / CPMM / CLMM only) and does not multi-hop via SOL like Jupiter or Mobula. On Pulse-fed bonded tokens (mostly Pump.fun graduates living on PumpSwap, plus Meteora and Orca pools) it returns no-route a large and variable share of the time (30% to 100% depending on the rotation set). That's an honest coverage signal and surfaces on the success-rate column, not as latency outliers." + - "Direct egress from Railway, no residential proxy. The bench measures backend-to-backend latency as a real integration would see it." + - "Jupiter serves quotes from regional pods behind CloudFront (observed `x-region: eu-central-1` from EU, `x-pod-name: jupiter-core-*`), so each probe region reaches a nearby replica and its cross-region spread is flat by infrastructure, not by caching. Repeated identical requests return `x-cache: Miss` every time; CloudFront does not cache this endpoint. The other providers answer from a single origin, which is why their per-region tabs spread with geography." + - "Histogram buckets: 10, 25, 50, 100, 200, 500, 1000, 2000, 5000 ms." + +findings: + - "{{best_name}} leads at {{best_p50}} (p50, 24 h) across {{count}} measured Solana DEX quote APIs. The headline is the cross-region median across a rotating long-tail token target; the per-region tabs below show where each provider's infrastructure actually sits." + - "{{name:jupiter}} returns quotes in {{p50:jupiter}} (p50, 24 h). The number looks impossible until you see the response headers: Jupiter runs regional pods behind CloudFront (`x-region` matches the probe region), so a Railway us-east probe reaches a us-east replica in single-digit RTT and the in-memory route engine adds a few ms. It is a real routing search on every tick (`x-cache: Miss` on repeats), served close." + - "{{name:mobula}} returns quotes in {{p50:mobula}} (p50, 24 h). Mobula's quote API runs a live multi-DEX routing search per request, no per-pair body caching, so the gap to providers that historically cached the canonical 1 SOL → USDC pair closes once the bench rotates targets." + - "{{name:openocean}} returns quotes in {{p50:openocean}} (p50, 24 h). OpenOcean's v4 Solana endpoint runs a separate multi-DEX path engine; the routing search dominates the latency curve on long-tail tokens." + - "{{name:raydium}} is a single-venue compute endpoint (Raydium pools only). It cannot multi-hop via SOL, so on the Pulse-fed rotation it no-routes nearly everything (thousands of no-routes per day against a handful of successes). Below 50 successful quotes in 24h its latency cells go blank rather than reporting a percentile computed on single-digit samples; the success-rate column is its honest score." + - "p99 is the integration-grade number. {{p99:jupiter}} / {{p99:mobula}} / {{p99:openocean}} / {{p99:raydium}} is what a swap UI feels when a region spikes or a provider's route engine churns." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-quote-latency + +prometheus: + window: 24h + freshness_metric: solana_quote_latency_ms_count + +# Region selector, tabs at the top of the page. Server-side injects +# `region="X"` into every PromQL query for the active tab. The special +# value `all` skips the filter and aggregates across every region. +# Values match the raw harness labels emitted by the MONITOR_REGION env +# var on each Railway service. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +# Exact per-region rankings for scoped badges. One query, one sample per +# (provider, region) cell. +rank_matrix_query: avg by (provider, region) (ocb:solana_quote_latency_ms:p50_24h) + +faq: + - q: "Which Solana DEX has the fastest quote API right now?" + a: "{{best_name}} currently returns quotes the fastest at {{best_p50}} (p50, 24 h) across {{count}} measured providers. The leaderboard re-sorts every 60 seconds against fresh samples and rotates the target token each tick so no provider can serve from edge cache. The answer reflects 24 hours of measured latency across us-east, eu-west and sgp, not a marketing-page claim." + - q: "What is quote latency on a DEX aggregator?" + a: "Quote latency is the wallclock time between a swap UI asking a DEX aggregator 'what would I get if I traded 100 USDC for this token right now?' and the aggregator answering with a usable routed price. A quote API at 80 ms feels instant to the user; a quote API at 600 ms introduces a visible delay between input change and updated output. The number sets the floor on how live a swap UI can feel before any other latency (RPC, signing, broadcast) is added." + - q: "Why does the bench rotate the target token instead of always quoting SOL → USDC?" + a: "SOL → USDC is the most-cached pair on Solana: Jupiter's lite endpoint serves it from a CloudFront edge in 30-50 ms, but that measures cache hit, not routing. Rotating the target every tick, and rotating against the tokens that are *actually trending right now* via Mobula Pulse V2's bonded WebSocket feed, defeats every per-pair edge cache and forces each provider to actually run a routing search. The metric becomes a fair comparison of routing engines, not CDN configurations." + - q: "How is quote latency measured on OpenChainBench?" + a: "A harness in each of three Railway regions (us-east, eu-west, sgp) ticks every 60 seconds: it picks one Solana token from a sliding 30-minute window of bonded tokens emitted by Mobula Pulse V2 over WebSocket, then asks each provider for a 100 USDC → tokenOut quote at 1% slippage in parallel. The HTTP client reuses TCP and TLS connections across ticks, so the recorded number is the steady-state round-trip a long-lived backend integration sees, not the one-off cold-start handshake. The wallclock from request dispatch to the first byte containing a usable quoted output amount is the recorded latency. p50, p90 and p99 are computed per region via Prometheus `histogram_quantile` over 24 hours (precomputed recording rules), then averaged equal-weight across the 3 regions." + - q: "What if a provider can't quote a particular long-tail token?" + a: "It's counted as a no-route, not as a slow quote. Each provider has a recognisable 'I have no path for this pair' signal (Jupiter `NO_ROUTES_FOUND` / `TOKEN_NOT_TRADABLE`, Mobula `No route found` / `Token not found`, Raydium `INSUFFICIENT_LIQUIDITY` / `ROUTE_NOT_FOUND`, OpenOcean payload-level signal). When the harness sees it, the tick lands on `solana_quote_no_route_total` and is excluded from the latency histogram entirely, so providers that fail fast on coverage gaps aren't penalised on the percentiles. The success-rate column reflects the no-route rate honestly." + - q: "Why does Raydium have a much lower success rate than the aggregators?" + a: "Raydium's compute endpoint is single-venue: it only routes against Raydium's own AMM v4 / CPMM / CLMM pools and does not multi-hop via SOL the way Jupiter or Mobula do. Because the rotation now pulls from Mobula Pulse V2's bonded feed (most of which are Pump.fun graduates living on PumpSwap, plus Meteora and Orca pools), Raydium returns INSUFFICIENT_LIQUIDITY or ROUTE_NOT_FOUND on roughly 80% of picks. That's accurate: Raydium's API is a Raydium-pool route engine, not an aggregator. The latency column for Raydium is the conditional p50 over the small subset of bonded tokens that happen to have a Raydium pool." + - q: "Are authentication errors and rate limits counted as 'slow'?" + a: "No. HTTP 401/403/429 responses are excluded from the latency histogram entirely. They are counted in separate counters (`solana_quote_auth_error_total`, `solana_quote_throttled_total`) which show up on the success rate column. A provider that's fast but rate-limits us at the configured cadence loses points on success rate, not on latency." + +providers: + - slug: jupiter + name: Jupiter + tag: DEX aggregator (REST) + formula: "Avg of the 3 regional 24h median wallclock ms on Jupiter's `lite-api.jup.ag/swap/v1/quote` for 100 USDC → at 1% slippage. Recorded only on 200 + non-empty `outAmount`. No-route ticks excluded." + queries: + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="jupiter"}) + p90: avg(ocb:solana_quote_latency_ms:p90_24h{provider="jupiter"}) + p99: avg(ocb:solana_quote_latency_ms:p99_24h{provider="jupiter"}) + mean: sum(ocb:solana_quote_latency_ms:sum_rate_24h{provider="jupiter"}) / sum(ocb:solana_quote_latency_ms:count_rate_24h{provider="jupiter"}) + success: avg(ocb:solana_quote_success:avg_24h{provider="jupiter"}) + sample_size: sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="jupiter"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter"}[1h]))) + regions: + - region: us-east + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="jupiter", region="us-east"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter", region="us-east"}[1h]))) + - region: eu-west + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="jupiter", region="eu-west"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter", region="eu-west"}[1h]))) + - region: sgp + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="jupiter", region="sgp"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter", region="sgp"}[1h]))) + + - slug: mobula + name: Mobula + tag: DEX aggregator (REST) + formula: "Avg of the 3 regional 24h median wallclock ms on Mobula's `api.mobula.io/api/2/swap/quoting` for 100 USDC → at 1% slippage. Recorded only on 200 + non-empty `data.amountOutTokens`. No-route ticks excluded." + queries: + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="mobula"}) + p90: avg(ocb:solana_quote_latency_ms:p90_24h{provider="mobula"}) + p99: avg(ocb:solana_quote_latency_ms:p99_24h{provider="mobula"}) + mean: sum(ocb:solana_quote_latency_ms:sum_rate_24h{provider="mobula"}) / sum(ocb:solana_quote_latency_ms:count_rate_24h{provider="mobula"}) + success: avg(ocb:solana_quote_success:avg_24h{provider="mobula"}) + sample_size: sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="mobula"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula"}[1h]))) + regions: + - region: us-east + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="mobula", region="us-east"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula", region="us-east"}[1h]))) + - region: eu-west + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="mobula", region="eu-west"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula", region="eu-west"}[1h]))) + - region: sgp + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="mobula", region="sgp"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula", region="sgp"}[1h]))) + + - slug: openocean + name: OpenOcean + tag: DEX aggregator (REST) + formula: "3-region avg of 24h median wallclock ms on OpenOcean's `open-api.openocean.finance/v4/solana/quote` for 100 USDC → at 1% slippage. Recorded only when `data.outAmount > 0` and `dexId >= 0`. No-route ticks excluded." + queries: + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="openocean"}) + p90: avg(ocb:solana_quote_latency_ms:p90_24h{provider="openocean"}) + p99: avg(ocb:solana_quote_latency_ms:p99_24h{provider="openocean"}) + mean: sum(ocb:solana_quote_latency_ms:sum_rate_24h{provider="openocean"}) / sum(ocb:solana_quote_latency_ms:count_rate_24h{provider="openocean"}) + success: avg(ocb:solana_quote_success:avg_24h{provider="openocean"}) + sample_size: sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="openocean"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean"}[1h]))) + regions: + - region: us-east + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="openocean", region="us-east"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean", region="us-east"}[1h]))) + - region: eu-west + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="openocean", region="eu-west"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean", region="eu-west"}[1h]))) + - region: sgp + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="openocean", region="sgp"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean", region="sgp"}[1h]))) + + - slug: raydium + name: Raydium + tag: AMM (single-venue Trade API) + formula: "Avg of the 3 regional 24h median wallclock ms on Raydium's compute/swap-base-in for 100 USDC → , recorded only on success. Single-venue; no-route rate varies with the rotation, up to 100% (then it drops off the board)." + queries: + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="raydium"}) and on() (sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="raydium"}) > 50) + p90: avg(ocb:solana_quote_latency_ms:p90_24h{provider="raydium"}) and on() (sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="raydium"}) > 50) + p99: avg(ocb:solana_quote_latency_ms:p99_24h{provider="raydium"}) and on() (sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="raydium"}) > 50) + mean: (sum(ocb:solana_quote_latency_ms:sum_rate_24h{provider="raydium"}) / sum(ocb:solana_quote_latency_ms:count_rate_24h{provider="raydium"})) and on() (sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="raydium"}) > 50) + success: avg(ocb:solana_quote_success:avg_24h{provider="raydium"}) + sample_size: sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="raydium"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium"}[1h]))) + regions: + - region: us-east + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="raydium", region="us-east"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium", region="us-east"}[1h]))) + - region: eu-west + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="raydium", region="eu-west"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium", region="eu-west"}[1h]))) + - region: sgp + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="raydium", region="sgp"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium", region="sgp"}[1h]))) diff --git a/benchmarks/solana-tx-landing-latency.yml b/benchmarks/solana-tx-landing-latency.yml new file mode 100644 index 00000000..b09c765e --- /dev/null +++ b/benchmarks/solana-tx-landing-latency.yml @@ -0,0 +1,221 @@ +# OpenChainBench. Bench № 027 + +slug: solana-tx-landing-latency +number: "027" +title: Fastest Solana RPC for tx landing, live slot delta benchmark +seo_title: "Fastest Solana RPC 2026: slot delta" +seo_description: "Fastest Solana RPC for tx landing live: Helius, Jito, Astralane, Nozomi p50 slot delta ranked." +subtitle: How fast each landing service gets a signed mainnet tx confirmed. Slot delta = number of Solana slots between submit and confirmed (1 slot is roughly 400 ms). Active probing every hour from us-east. +category: Trading +status: live +metric: p50 slot delta to confirmed (7-day window) +unit: slots +higher_is_better: false + +disclaimer: | + Six caveats. (1) us-east only, sgp / eu-west arrive in V2. (2) One pre-registered tip per service. (3) Synthetic payload (1-lamport + memo); real swaps may land differently. (4) Helius / Astralane / Nozomi fan out to Jito internally; Jito control probe runs each cycle. (5) Confirmation = `confirmed`. (6) Slot delta is canonical; ms is derived (≈ slot_delta × 400 ms + RTT). Pair with /benchmarks/solana-tx-landing. + +seo_intro: | + This benchmark answers the only question that matters to a + Solana trader picking a landing service. how many slots does + your signed mainnet transaction take to reach the confirmed + state on chain. Every hour from a us-east probe, the harness + submits an identical signed tx through each of 5 services in + parallel, captures the submit slot before send and the land + slot from the signatureSubscribe WebSocket notification at + commitment=confirmed, and increments per-service Prometheus + histograms. Headline numbers shown are p50 and p99 slot delta + over a rolling 7-day window. Wall-clock milliseconds are + published alongside for intuition (one Solana slot is ~400 ms, + so a p50 of 1 slot is ~400 ms wall-clock plus submission RTT) + but slot delta is the canonical, sponsor-proof on-chain + measurement. + Why slot delta is the right metric. Solana confirmation is a + slot-level event. when a slot reaches supermajority vote, every + transaction in it becomes confirmed simultaneously. Wall-clock + ms conflates HTTP submission time, our RPC's polling lag, and + network RTT to the public WebSocket - all of which are + measurement artifacts unrelated to the landing service's actual + routing quality. Slot delta is what the chain itself records. + Coverage. 5 services probed in V0-Lean. Jito Block Engine (the + control / baseline because Helius, Astralane, Nozomi all + internally route some flow through it). Helius Sender in + `swqos_only=true` mode (isolates the Helius own-path from the + Jito leg). Astralane Iris (tip-refund mechanism). Nozomi by + Temporal Labs (premium tier, hard 1M lamport tip floor). + 0slot.trade (premium tier). NextBlock, bloXroute and + SolanaVibeStation arrive in the next tier (V1) once the first + sponsors land. Companion bench. /benchmarks/solana-tx-landing + measures market share via on-chain tip-wallet attribution - + who carries the flow today, regardless of speed. + +abstract: | + We probe 5 Solana transaction landing services from a single + Railway us-east region, once per hour, by submitting an + identical signed mainnet transaction to each. The payload is + the minimal valid Solana tx, compute-budget instructions + (50k CU limit, 50k micro-lamport/CU price), a 1-lamport + self-transfer, the per-service tip transfer to the service's + documented tip wallet, and an OCB-prefixed memo for forensic + traceability. All five services are submitted in parallel + goroutines within a single cycle so they sample the same chain + congestion window. The headline measurement is slot delta, + land_slot minus submit_slot, captured from the + signatureSubscribe WebSocket notification's context.slot field + at commitment=confirmed. Wall-clock ms is reported alongside + but is a derived approximation, slot_delta × ~400 ms plus + submission RTT and goroutine startup variance. A 60 s no- + confirmation deadline classifies the probe as + dropped{reason=timeout}; structured RPC errors classify as + invalid; transport failures as network_error; HTTP 419 / 429 / + "rate limit" errors classify as rate_limited (a separate label + so quota issues don't bias the bench against the throttled + service). Cost. ~$159/mo at SOL=$86, 86 % of which goes to the + four ≥1M-lamport-floor services (Nozomi, 0slot, bloXroute, + NextBlock, only two of these in V0-Lean). Sponsor SOL credits + covering a service's own probes are explicitly allowed per the + sponsor-proof framework. Limitations. (a) Single us-east + region, sgp / eu-west arrive in V2 once sponsors fund + geographic-edge story. (b) 1-hour cadence, 168 probes per + service per 7-day window, enough for stable p50 / p99 over the + publication window, not enough for intra-hour resolution + (V0.5 / V1 upgrade if needed). (c) Fan-out, Helius probed in + `swqos_only` mode only in V0-Lean to keep wire shape simple; + dual-mode arrives in v1.0.1 methodology PR. + +methodology: + - "Source endpoints (us-east Railway, base64 JSON-RPC sendTransaction unless noted). Jito `ny.mainnet.block-engine.jito.wtf/api/v1/transactions`. Helius Sender `ewr-sender.helius-rpc.com/fast?swqos_only=true` (skipPreflight + maxRetries=0). Nozomi `http://edge.nozomi.temporal.xyz/api/sendBatch?c=` (binary `[u16_BE_len][tx_bytes]`, HTTP per Temporal Labs). Astralane `ny.gateway.astralane.io/iris?api-key=` (3-elem params, mevProtect). 0slot `ny.0slot.trade?api-key=`." + - "Probe payload. 5 instructions in this exact order: SetComputeUnitLimit(50,000) + SetComputeUnitPrice(50,000 micro-lamports/CU) + SystemProgram.Transfer(payer→payer, 1 lamport) + SystemProgram.Transfer(payer→service tip wallet, floor lamports) + Memo(`ocb---`). cycle_id is an 8-byte random hex shared across the five parallel probes of one cycle, so the on-chain memos correlate." + - "Tip floors (pre-registered, methodology PR + 14-day window to change). Jito 10,000 lamports. Helius Sender 10,000. Astralane 500,000 net of refunds. Nozomi 1,000,000. 0slot 1,000,000." + - "Submission flow. One getLatestBlockhash(processed) shared across all five probes. One getSlot(processed) as submit_slot. For each service we subscribe to the signature via signatureSubscribe on the public WS BEFORE submission (otherwise a fast confirm could fire before we listen). Probes then fire in parallel goroutines, sign, POST. We block on the signatureNotification at commitment=confirmed; context.slot is land_slot; slot_delta = land_slot - submit_slot." + - "Why slot delta is canonical. Solana confirmation is slot-level. when a slot reaches supermajority, every tx in it becomes confirmed simultaneously. The WS pushes notifications for all subscribed sigs in that slot at the same instant. So sub-400 ms wallclock diffs between services in the same slot are artifacts (goroutine startup, RTT), not routing quality. slot_delta is what the chain records, what to cite in audits." + - "Wall-clock ms is a derived approximation. ms ≈ slot_delta × ~400 ms + HTTP submission RTT + variance. We publish it for intuition because traders think in seconds, not slots, but it should not be the sole metric in a sponsor pitch or audit. If a service argues 'your ms numbers are biased by your RTT', the answer is the slot delta column, which is RTT-independent." + - "Drop classification. timeout = no confirmation within 60 s. invalid = RPC error, on-chain Err, or BlockhashNotFound. network_error = transport-level (timeout, DNS, EOF, connection refused). rate_limited = HTTP 419 / 429 / 'rate limit' / 'too many requests'. landing_rate is published as success / (success + timeout), rate_limited and network_error are excluded so quota / transport issues don't bias the bench against a throttled service." + - "Jito control probe. Helius (default), Astralane, Nozomi route a portion of flow through Jito internally, conflating own-path vs Jito-caught-it. Jito is in the V0-Lean set so its control fires in the same cycle. Same slot_delta as Jito = no measurable own-path value. Suspect ahead by 1+ slot = real routing advantage." + - "Reproducibility. The full harness source is at github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing. Anyone with a funded Solana keypair (~1 SOL) can clone, set SOLANA_PROBE_KEYPAIR_BASE58, run the binary, and reproduce these metrics. The bench does not rely on any private or internal service for measurement, the only RPC dependency is the public `api.mainnet-beta.solana.com` HTTP + WebSocket endpoints." + - "Methodology v1.3 pre-registered at github.com/ChainBench/OpenChainBench/blob/main/docs/methodology/solana-tx-landing-active.md. Any change (tip floor, probe payload, cadence, region, metric definitions) ships as a public PR with a 14-day comment window. Major version bumps run a 30-day shadow period publishing old and new metrics in parallel." + +findings: + - "{{best_name}} leads the V0-Lean probe set at p50 = {{best_p50}} slot delta over the rolling 7-day window. Lower = fewer Solana slots between submission and confirmation. The gap between fastest and slowest is the operational signal, every service claims '99 %+ landing rate' in marketing copy, but the chain doesn't lie about which slot included your tx. A 1-slot difference is ~400 ms, enough for a MEV bot to front-run a competitor." + - "{{name:jito}} is the baseline / control. Helius (default mode), Astralane, and Nozomi all internally fan out to Jito, so the Jito p50 is the floor any premium service must beat. Same slot_delta as Jito on a given cycle = the service is essentially using Jito as its inclusion path. {{name:jito}} sits at p50 = {{p50:jito}} slot delta." + - "{{name:helius-sender}} in `swqos_only` mode isolates Helius's own routing path from the Jito leg. p50 = {{p50:helius-sender}} slot delta. A v1.0.1 methodology update will publish Helius default mode (with Jito fan-out) side-by-side for direct comparison." + - "{{name:nozomi}} premium pricing (1M lamport hard floor, ~10 × Jito's competitive level) only makes economic sense if the slot_delta advantage is meaningful. p50 = {{p50:nozomi}} slot delta. The gap vs Jito quantifies whether the tip premium buys real slot priority." + - "{{worst_name}} trails at p50 = {{worst_p50}} slot delta. The worst slot delta in the V0-Lean set is not necessarily a bad service, it may be a service whose strength is in dimensions this bench doesn't measure (anti-MEV protection, durable nonce, fee-refund mechanics). Latency is one variable, not the whole product." + +faq: + - q: "Why is slot delta the headline metric instead of wall-clock latency?" + a: "Solana confirmation is a slot-level event. when a slot reaches supermajority vote (~2/3 of stake), every transaction in that slot becomes confirmed simultaneously. The WebSocket pushes notifications for all subscribed signatures in that slot at the same instant. So if 3 services delivered txs that all landed in the same slot, our wallclock measurement records the same time for all 3, the only differentiation is whether the next service's tx landed in slot N or N+1. slot_delta captures that directly. Wall-clock ms is derived (slot_delta × ~400 ms + RTT + variance) and conflates routing quality with measurement artifacts like HTTP submission speed and our public RPC's network latency. We publish wall-clock ms because traders think in seconds, but slot_delta is what you should cite in an audit or methodology dispute. It's RTT-independent and reads directly from the chain." + - q: "What does '1 slot' actually mean in time?" + a: "Solana slots are ~400 ms in practice (~625 ms target with leader skips and forks averaging it down). A p50 slot_delta of 1 means your tx typically lands in the slot immediately following your submission, ~400 ms after sendTransaction return. p50 of 2 means typically one slot later, ~800 ms. The gap between p50 = 1 and p50 = 2 is the operational signal, a service that consistently lands 1 slot earlier than its competitors is ~400 ms ahead, which is the difference between catching an arbitrage and missing it." + - q: "Why an active bench when /benchmarks/solana-tx-landing already exists?" + a: "/benchmarks/solana-tx-landing is observational, it watches the chain and counts who carries the flow. It cannot answer 'how fast does my tx land if I send it now', because it doesn't send anything. This bench (active probing) answers that, at the cost of running 24 / 7 with real SOL ($159 / month at the V0-Lean cadence). The two benches answer different product questions. Read both." + - q: "Why only 5 services, not the 8 you measure observationally?" + a: "NextBlock, bloXroute Trader, and SolanaVibeStation all require paid plans or sales-call onboarding before they issue an API key. We're shipping V0-Lean today with the 5 services that have a clear self-serve or contact-based path. The other 3 will be added as the bench scales. The observational bench at /benchmarks/solana-tx-landing already covers all 8 because it doesn't need API keys." + - q: "Why us-east only?" + a: "V0-Lean. us-east is the de-facto Solana baseline (Jito, NextBlock, bloXroute, Helius all anchor their best-connected POPs there) and is where most Solana bots deploy by default. Adding eu-west and sgp triples the bench cost and answers a different question ('does the ranking change by geography?'), which is a planned V2 scope expansion." + - q: "What's the probe payload?" + a: "Five instructions in this exact order, locked by methodology §3. (1) SetComputeUnitLimit(50,000). (2) SetComputeUnitPrice(50,000 micro-lamports/CU), together a 2,500-lamport priority fee. (3) SystemProgram.Transfer of 1 lamport from the prober keypair to itself, the minimal valid state-touching tx. (4) SystemProgram.Transfer to the service's documented tip wallet at the pre-registered floor. (5) Memo program write with the cycle ID, service name, and probe mode. Total weight: ~600 bytes, well under the 1,232-byte tx limit." + - q: "How is fan-out handled?" + a: "Helius (default mode), Astralane, and Nozomi route a portion of flow through Jito internally. The Jito control probe, Jito is part of the V0-Lean probe set, fires in the same cycle as the suspect services with the same blockhash and a comparable tip. The slot_delta column tells you immediately whether a suspect service is adding value beyond a Jito wrapper. Same slot_delta as Jito = same inclusion slot = Jito caught it. Suspect ahead by 1+ slot = real own-path routing. Helius is additionally probed in `?swqos_only=true` mode to fully isolate its own routing path." + - q: "Can a service detect and prioritise our probes?" + a: "Yes, in principle. The memo prefix `ocb-` is deterministic and the keypair is constant per region. Anti-fingerprinting (memo randomisation, sub-account rotation, tip jitter within the floor band) ships in v1.0.2 methodology PR. We disclose this risk openly; the trade-off is that announcing the bench publicly to providers gives them a chance to fix real performance issues before we publish, which is a good outcome. We do NOT accept private deals to alter the probe surface for any specific service." + - q: "Why is sample_size on the dashboard ~168 per service?" + a: "V0-Lean cadence = 1 probe per service per hour from 1 region. 168 = 24 hours × 7 days. The 7-day publication window is the trade-off between statistical resolution (sample size grows with window) and freshness (shorter window reflects current chain conditions). At ~168 samples per cell, p50 is stable to within ±5 % and p99 to within ±15 %. Lower confidence intervals are unlocked at V0.5 cadence (1 / 10 min, ~$760 / mo) and above." + - q: "How is the confirmation observed?" + a: "Via `signatureSubscribe` on the public mainnet WebSocket (`wss://api.mainnet-beta.solana.com`). The subscription is registered BEFORE submission so a fast-confirming tx cannot complete before we are listening (otherwise we'd miss the notification and timeout spuriously). The RPC pushes the notification at the instant the commitment level is reached, so observation resolution is RTT-bounded (~30-50 ms us-east → mainnet-beta) and slot_delta is read directly from the notification's context.slot field. HTTP polling at 200 ms is an automatic fallback if the WebSocket fails to connect for a given cycle." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing + +prometheus: + window: 7d + expected_freshness_seconds: 7200 + +# Real metrics emitted by the active prober in solana-tx-landing harness: +# solana_landing_probe_success_total{service, mode, region} counter +# solana_landing_probe_dropped_total{service, mode, region, reason} counter +# solana_landing_probe_latency_ms{service, mode, region} gauge (set every cycle) +# solana_landing_probe_latency_slots{service, mode, region} gauge (set every cycle) +# solana_landing_probe_latency_slots_histogram{service, mode, region} histogram (debug) +# solana_landing_probe_latency_ms_histogram{service, mode, region} histogram (debug) +# solana_landing_probe_keypair_balance_sol{region} gauge +# solana_landing_probe_cycle_total{region} counter +# solana_landing_probe_enabled{region} gauge +# +# Headline metric (canonical) = slot_p50 / slot_p99 read from the gauge. +# Wall-clock ms is published alongside via the standard p50/p90/p99 fields +# for reader intuition but is derived (slot_delta × ~400 ms + RTT + variance). +# Mode label is `swqos_only` for helius-sender, `default` for the rest. +# +# Why quantile_over_time(gauge) instead of histogram_quantile(histogram)? +# At V0-Lean cadence (1 probe / hour) we have ~168 samples per cell over 7d. +# Histogram buckets {100, 250, 500, 1000, 2000, 5000, 10000, 30000, 60000} ms +# have ~3 buckets in the 1-5s zone where probes actually land, so +# histogram_quantile collapses to bucket midpoints (1500, 3500 ms) and the +# series looks flat. quantile_over_time on the gauge takes the real sample +# at the 50th percentile, which is the accurate published number. + +providers: + - slug: jito + name: Jito + tag: Baseline + control probe; atomic bundles + tip auction since 2022 + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed mainnet probes submitted to Jito's `ny.mainnet.block-engine.jito.wtf` from us-east." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="jito",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="jito",region="us-east"} + + - slug: helius-sender + name: Helius + tag: Isolated Helius own-path (no Jito fan-out); anycast + 7 POPs + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted to Helius Sender in `swqos_only=true` mode from us-east, isolating its own-path." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="helius-sender",mode="swqos_only",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"} + + - slug: astralane + name: Astralane + tag: Tip-refund mechanism, sendBundle / sendIdeal modes, FRA + NY POPs + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted with a 500k-lamport net tip to Astralane Iris's NY gateway from us-east." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="astralane",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="astralane",region="us-east"} + + - slug: nozomi + name: Nozomi + tag: Temporal Labs, direct-to-leader, premium 1M-lamport hard floor + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted with a 1M-lamport tip to Nozomi's `edge.nozomi.temporal.xyz` from us-east." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="nozomi",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="nozomi",region="us-east"} + + - slug: mobula + name: Mobula + tag: Multi-RPC fan-out aggregator (relays via Jito / Nozomi / zeroslot) + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly probes submitted via Mobula's `api.mobula.io/api/2/swap/send` multi-RPC fan-out from us-east, using a Jito tip wallet." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="mobula",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="mobula",region="us-east"} diff --git a/benchmarks/solana-tx-landing.yml b/benchmarks/solana-tx-landing.yml new file mode 100644 index 00000000..9872f99c --- /dev/null +++ b/benchmarks/solana-tx-landing.yml @@ -0,0 +1,218 @@ +# OpenChainBench. Bench № 016 + +slug: solana-tx-landing +number: "016" +title: Solana transaction landing services market share +seo_title: "Solana tx landing services benchmark 2026" +seo_description: "Live observational benchmark of Solana tx landing services. Market share by tx count, attributed via tip wallets." +subtitle: Share of tipped Solana transactions attributed to each landing service, counted via known on-chain tip wallets. Observational, no transactions sent. +category: Trading +status: live +metric: Tx count (24h) +unit: count +higher_is_better: true + +disclaimer: | + Three caveats. (1) Market share, NOT landing rate, measuring success rates would require sending controlled tx, which costs SOL. (2) Jito is over-counted, other services (Helius Sender, Astralane, bloXroute) fan-out tips to Jito alongside their own wallet, inflating Jito's counter. (3) High share ≠ best, Nozomi has tiny share but charges 10x higher tips for guaranteed inclusion. Pick on latency / cost / reliability fit, not leaderboard position. + +seo_intro: | + This benchmark answers the question every Solana dev choosing a + transaction landing service asks. who actually carries the flow, + what does it cost, and is it growing or shrinking. Marketing + pages quote self-reported "99% landing rate" without methodology; + this page measures the reality on-chain by observing the known + tip wallets each service publishes in its docs. Every confirmed + Solana transaction that pays a tip to one of ~72 known landing- + service wallets is attributed to that service. Zero on-chain + footprint, we don't send tx ourselves, we watch the chain. + Coverage. 8 services with cleanly attributable tip wallets. + Jito Block Engine (the OG, 8 wallets), Helius Sender (10 wallets, + disjoint from Jito's pool), Nozomi by Temporal Labs (17 wallets + with `noz` vanity prefix), bloXroute Trader API (17 `bLx` wallets), + 0slot.trade (10 wallets), NextBlock (8 wallets), Astralane Iris, + SolanaVibeStation Lightspeed. Blind spots. Syncro Sender (per- + customer tip wallets, undisclosed), Slipstream (pure router whose + tx land via underlying senders' wallets and get attributed to + those), and any direct-RPC tx that doesn't pay a tip (a large + share of total Solana traffic, but not what this bench measures). + +abstract: | + We attribute Solana transactions to their landing service by + watching the on-chain tip wallets each service documents publicly. + A single WebSocket connection to mainnet-beta subscribes via + `logsSubscribe(mentions=[tip_wallet], commitment=confirmed)` for + each of ~72 tip wallets across 8 services. Every confirmed + notification increments a per-service counter; signatures are + deduplicated against a 50k-entry LRU so reconnect replays don't + double-count. Failed transactions (Err != null) are excluded - + they didn't actually land. The headline metric is total + landed-and-tipped tx attributed to each service in the last 24 + hours, surfaced as a leaderboard sorted by volume. This is the + observational counterpart to a controlled landing-rate benchmark. + it costs nothing to run (no tx sent, no SOL spent), provides + market-share signal that the controlled approach cannot (real + user behaviour, not synthetic probes), and stays neutral + (services can't fingerprint our probes, there are no probes). + Limitations. (a) Helius Sender fans some tx to Jito under the + hood; if Helius's own tip wallet is paid, attribution is clean, + but Helius's "dual-path" fallback to Jito-only payment would be + miscounted as Jito. (b) Services rotating tip wallets without + doc updates introduce silent under-counting until the new + addresses are added to the harness. The harness logs every + unattributable tip-pattern tx so the gap surfaces in operator + metrics. + +methodology: + - "Source: `wss://api.mainnet-beta.solana.com` public WebSocket, no API key. One `logsSubscribe({mentions:[wallet]}, {commitment:confirmed})` call per known tip wallet. Server-side filtered, bandwidth scales with matched traffic, not full firehose." + - "Attribution: 72 tip wallets across 8 services. Jito (8), Helius Sender (10), Nozomi (17), bloXroute (17), 0slot (10), NextBlock (8), Astralane (1), SolanaVibeStation (1). Every signature carries to exactly one service because the wallet sets are disjoint." + - "Dedup: 50,000-entry LRU keyed by signature. Reconnects can replay a few seconds of notifications, without dedup we'd double-count attribution. Memory cost ~4 MB." + - "Failed tx filter: notifications with `err != null` are dropped. The bench measures LANDED tx, not submission attempts. Failed-tx flow is its own measurement and lives outside this bench." + - "Reconnect: WebSocket disconnects (mainnet-beta is best-effort and kicks idle / overloaded clients) trigger exponential backoff 2s → 60s cap. Every (service, subscription) is re-established on reconnect; `solana_landing_reconnects_total` surfaces stability over the day." + - "Blind spots. Syncro Sender (P2P.org) uses per-customer tip wallets not publicly documented, invisible. Slipstream is a router that routes through underlying senders, so its tx are attributed to the underlying service (Jito/Nozomi/etc.). Direct-RPC tx that pay only a priority fee (no tip to a landing service) are not measured, they constitute the majority of Solana volume but they're not the subject of this bench." + - "Excluded by design: time-to-land, success rate per service, geographic latency. Those would require sending controlled tx (active probing) which costs SOL and risks services fingerprinting our probes. This bench is purely observational." + +findings: + - "{{best_name}} currently carries {{best_p50}} of attributed landed tx over the last 24 h, across {{count}} measured services. The current leader's position reflects default integrations: most Solana wallets and trading bots ship with one landing service preconfigured, so flow concentrates on whoever won the integration race rather than on per-tx merit." + - "{{name:helius-sender}} ships {{p50:helius-sender}}. Helius Sender's strength is the dual-path fan-out (Jito + SWQoS staked connections) which gives it second-place reach without owning the volume Jito does." + - "{{name:nozomi}} carries {{p50:nozomi}}. Nozomi has tiny share by volume but historically charges 10x higher tips per tx, used by serious traders who treat the higher tip as guaranteed-inclusion insurance rather than discretionary spend." + - "{{name:bloxroute}} sits at {{p50:bloxroute}}. bloXroute Trader API serves institutional flow with multi-path BDN propagation. Volume share is smaller than its revenue share because individual tips are higher." + - "{{worst_name}} trails at {{worst_p50}}. The long tail (NextBlock, Astralane, SolanaVibeStation, 0slot) collectively carries a few percent of attributed flow. These services compete on specific edges (regional co-location, validator stake size, tip floor) rather than universal coverage." + +faq: + - q: "Why are the p50 / p90 / p99 columns identical?" + a: "This bench has no latency dimension, so the percentile slots are repurposed: every aggregate column carries the same 24h attributed-tx count, and the Success column reports the harness's chain-subscription health rather than any per-service delivery rate. The ranking signal is the count itself (market share); the columns exist because the ledger layout is shared with latency benches." + - q: "What is a Solana landing service?" + a: "Solana has no traditional mempool. Transactions go directly to the current and next slot leader. During congestion, leaders drop transactions they can't process, they're simply lost, not queued. Landing services help your tx avoid being dropped via various tricks. direct connections to leaders (Nozomi, 0slot), stake-weighted QoS pools (Helius Sender, Triton Cascade), tip-based bundle auctions (Jito), multi-path propagation (bloXroute, Astralane). This bench measures which services actually carry the on-chain flow today." + - q: "Why observational? Why not measure landing rate directly?" + a: "Sending controlled tx through each service to measure landing rate costs real SOL (every tx = base fee + tip + compute units, all paid to validators, none recoverable). At a 30 s cadence across 8 services and 3 regions, that's ~$11,700/month. The observational approach costs $0 because we don't send anything, we watch the chain. Trade-off. observational tells you 'who is used' (market share, tip economics, growth); controlled tells you 'who lands best' (per-service success rate). Different questions, both useful. This is the observational answer." + - q: "How is each tx attributed to a service?" + a: "Each landing service publishes a list of known tip wallet addresses in its docs (Jito has 8, Nozomi has 17, etc.). When a user routes a tx through a service, the tx includes a transfer to that service's tip wallet. We watch all 72 tip wallets via `logsSubscribe(mentions=[wallet])` and attribute each notification to the owning service. Wallet sets are disjoint, no overlap between services, so attribution is unambiguous. The pattern is verified against real mainnet blocks: 95-97% of tip-paying tx attribute cleanly, zero multi-service overlaps observed." + - q: "Why does Jito have such dominant share?" + a: "Three reasons. (1) First-mover. Jito launched the tip+bundle pattern in 2022 and got every major wallet (Phantom, Backpack) + every major trading bot to integrate first. (2) Lowest tip floor, Jito accepts tips as low as 1,000 lamports versus 0.001 SOL minimums on most competitors. (3) Atomic bundles, only Jito offers the 5-tx atomic group that DEX aggregators (Jupiter, Raydium) use for sandwich-proof swaps. The result is structural: Jito carries the default flow even when competitors are technically faster on specific paths." + - q: "Why does Nozomi look small here?" + a: "Nozomi's share by tx count is small but its share by tip revenue is much larger. Nozomi targets serious traders who pay 0.001+ SOL per tx as guaranteed-inclusion insurance, while Jito averages ~0.0001 SOL per tx as casual usage. Blockworks Research's 'Solana Block Building Wars' (Feb 2026) documented Nozomi at ~$1M tips on 2M swaps vs Jito's $300k on 24M swaps, a 130x premium per swap. For 'who do serious traders use', track Nozomi. For 'who handles default flow', track Jito." + - q: "What about Syncro Sender and Slipstream?" + a: "Syncro Sender (P2P.org) uses per-customer tip wallets that aren't publicly documented, every customer gets a private address. We can't enumerate them, so Syncro flow is invisible to this bench. Estimated share: small (Syncro launched in 2025 and hasn't disclosed adoption metrics). Slipstream is a router that calls the underlying senders' SDKs, its tx land via Jito/Nozomi/0slot tip wallets and are attributed to those services. So Slipstream-originated flow shows up correctly counted, just under the underlying service's name. If you specifically want to know 'what % uses Slipstream as the router', this bench can't answer that, Slipstream itself doesn't add an identifying memo." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing + +prometheus: + window: 24h + +# Real metrics emitted by the solana-tx-landing harness: +# solana_landing_tx_total{service} counter (attributed landed tx) +# solana_landing_subscription_health{service} gauge (0|1) +# solana_landing_reconnects_total counter +# solana_landing_last_slot{service} gauge +# +# Each "provider" below is one landing service. The metric is +# observed throughput (tx/24h), so `higher_is_better: true` - +# the leader is the one carrying the most flow. p50/p90/p99 +# columns are filled with the 24h increase (single scalar) for +# consistency with the OCB renderer; they're not statistical +# distributions because each bench probe is a single counter. + +providers: + - slug: jito + name: Jito + tag: Block Engine, atomic bundles + tip auction, OG since 2022 + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of Jito's 8 documented tip wallets, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="jito"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="jito"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="jito"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="jito"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="jito"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="jito"}[24h])) + series: sum(rate(solana_landing_tx_total{service="jito"}[5m])) + + - slug: helius-sender + name: Helius + tag: Dual-path fan-out (Jito + SWQoS staked connections), 7 regions + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of Helius Sender's 10 documented tip wallets, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="helius-sender"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) + series: sum(rate(solana_landing_tx_total{service="helius-sender"}[5m])) + + - slug: nozomi + name: Nozomi + tag: Temporal Labs, direct-to-leader, 9 co-located regions, premium tips + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of Nozomi's 17 `noz`-prefixed tip wallets, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="nozomi"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) + series: sum(rate(solana_landing_tx_total{service="nozomi"}[5m])) + + - slug: bloxroute + name: bloXroute + tag: Multi-path BDN propagation, institutional flow + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of bloXroute's 17 `bLx`-prefixed tip wallets, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="bloxroute"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) + series: sum(rate(solana_landing_tx_total{service="bloxroute"}[5m])) + + - slug: 0slot + name: 0slot + tag: Premium QUIC leader-direct sender + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of 0slot.trade's 10 documented tip wallets, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="0slot"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) + series: sum(rate(solana_landing_tx_total{service="0slot"}[5m])) + + - slug: nextblock + name: NextBlock + tag: SWQoS sender, TX Stream API + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of NextBlock's 8 documented tip wallets, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="nextblock"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) + series: sum(rate(solana_landing_tx_total{service="nextblock"}[5m])) + + - slug: astralane + name: Astralane + tag: Validator co-located, leader-schedule-aware routing + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to Astralane Iris's documented tip wallet, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="astralane"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) + series: sum(rate(solana_landing_tx_total{service="astralane"}[5m])) + + - slug: solanavibestation + name: SolanaVibeStation + tag: Lightspeed validator-pool tip-based sender + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to SolanaVibeStation's documented Lightspeed tip wallet, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="solanavibestation"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) + series: sum(rate(solana_landing_tx_total{service="solanavibestation"}[5m])) diff --git a/benchmarks/soneium-rpc.yml b/benchmarks/soneium-rpc.yml index a9c8b58e..b965f546 100644 --- a/benchmarks/soneium-rpc.yml +++ b/benchmarks/soneium-rpc.yml @@ -4,8 +4,8 @@ slug: soneium-rpc number: "066" title: Fastest free Soneium RPC, live no-key endpoint latency seo_title: "Fastest free Soneium RPC 2026" -seo_description: "{{best_name}} leads free Soneium RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Soneium RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Soneium RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Soneium RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -14,14 +14,14 @@ unit: ms higher_is_better: false seo_intro: | - Soneium, Sony's OP Stack rollup, is the long-tail chain where the official endpoint actually wins: `rpc.soneium.org` leads at roughly 17 ms on the 3-region average, distributed official infrastructure that answers near every probe origin while keeping fresh heads in our stale detection. 4 no-key providers, the identical `eth_blockNumber` call every 15 seconds from us-east, eu-west and Singapore. + Soneium, Sony's OP Stack rollup, is the long-tail chain where the official endpoint actually wins: `rpc.soneium.org` leads at roughly 17 ms on the 3-region average, distributed official infrastructure that answers near every probe origin while keeping fresh heads in our stale detection. 4 no-key providers, the identical `eth_getBlockByNumber` call every 15 seconds from us-east, eu-west and Singapore. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Soneium endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Soneium-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Soneium endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Soneium-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,20 +29,20 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"soneium\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, Tenderly, Soneium). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Soneium RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free Soneium RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." - "The official `rpc.soneium.org` is one of the expansion's two exceptions to the dRPC sweep: ~17 ms on the 3-region average with fresh heads throughout, a chain operator that fronts its RPC properly across regions instead of pointing DNS at one box." - "{{name:drpc}} ({{p50:drpc}}) still posts its trademark three-region consistency here; it just meets the rare official endpoint built on the same playbook." - "{{name:tenderly}} closes the pattern the expansion documents everywhere: roughly 330 ms flat in all three regions on Soneium, single-origin routing behind a gateway that is competitive on the majors." faq: - q: "What is the fastest free Soneium RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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 Soneium RPCs work without an API key?" a: "The 4 providers on this page: PublicNode, dRPC, Tenderly, Soneium. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Soneium RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Soneium RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Is the official Soneium RPC actually the best choice?" a: "Currently yes, and that is unusual: across the 12-chain long-tail expansion only two chains resist the gateway tier, and `rpc.soneium.org` is the clearest case, leading the 3-region average at around 17 ms while our stale-head checks stay clean. The usual free-tier caveats (no SLA, shared limits) still apply, so keep {{name:drpc}} or {{name:publicnode}} wired as fallback." @@ -68,7 +68,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Soneium endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Soneium endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="soneium"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="soneium"}) @@ -91,7 +91,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Soneium endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Soneium endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="soneium"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="soneium"}) @@ -114,7 +114,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Soneium endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Soneium endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="soneium"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="soneium"}) @@ -137,7 +137,7 @@ providers: - slug: soneium-official name: Soneium tag: Sony Block Solutions public RPC, Soneium mainnet only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Soneium's no-key Soneium endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Soneium's no-key Soneium endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="soneium-official", chain="soneium"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="soneium-official", chain="soneium"}) diff --git a/benchmarks/sonic-rpc.yml b/benchmarks/sonic-rpc.yml index 2c0608d4..633aba5c 100644 --- a/benchmarks/sonic-rpc.yml +++ b/benchmarks/sonic-rpc.yml @@ -4,8 +4,8 @@ slug: sonic-rpc number: "055" title: Fastest free Sonic RPC, live no-key endpoint latency seo_title: "Fastest free Sonic RPC 2026" -seo_description: "{{best_name}} leads free Sonic RPC at {{best_p50}} (eth_blockNumber p50, 24h). 6 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Sonic RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Sonic RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 6 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Sonic RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -14,14 +14,14 @@ unit: ms higher_is_better: false seo_intro: | - Sonic ties Gnosis for the largest cohort in the long-tail expansion: 6 no-key providers, including Sonic Labs' own `rpc.soniclabs.com` and the only keyless Lava endpoint outside Ethereum and Arbitrum, `sonic.lava.build` answers no-key while every other Lava subdomain 403s without an API key. Every provider gets the identical `eth_blockNumber` probe every 15 seconds from us-east, eu-west and Singapore, with stale-head detection against the cross-provider tip. + Sonic ties Gnosis for the largest cohort in the long-tail expansion: 6 no-key providers, including Sonic Labs' own `rpc.soniclabs.com` and the only keyless Lava endpoint outside Ethereum and Arbitrum, `sonic.lava.build` answers no-key while every other Lava subdomain 403s without an API key. Every provider gets the identical `eth_getBlockByNumber` probe every 15 seconds from us-east, eu-west and Singapore, with stale-head detection against the cross-provider tip. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Sonic endpoint that sustains continuous probing, 6 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Sonic-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Sonic endpoint that sustains continuous probing, 6 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Sonic-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,20 +29,20 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"sonic\". Provider coverage: 6 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Lava, Sonic Labs). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Sonic RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 6 measured providers." + - "{{best_name}} currently leads free Sonic RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 6 measured providers." - "{{name:drpc}} ({{p50:drpc}}) illustrates the long-tail pattern: it wins 10 of the 12 chains in this expansion on the 3-region average, not by posting the fastest single-region peak but by answering every origin from a nearby anycast edge." - "{{name:tenderly}} tells the opposite story: roughly 330 ms in every region, the flat signature of single-origin routing. The same gateway is competitive on Ethereum and Base, so this is a per-chain routing decision, not a capacity problem." - "{{name:lava}} makes Sonic a curiosity: `sonic.lava.build` is the only Lava subdomain that answers no-key outside eth1/arb1, so this page is the cluster's only long-tail read on the Lava mesh." faq: - q: "What is the fastest free Sonic RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 6 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 6 no-key providers probed every 15 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 Sonic RPCs work without an API key?" a: "The 6 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Lava, Sonic Labs. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Sonic RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Sonic RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Why does Lava appear on Sonic but on no other long-tail chain?" a: "Lava publishes `*.lava.build` subdomains for many chains, but nearly all of them return 403 without an API key. `sonic.lava.build` is the exception: it passed our no-key verification (eth_chainId match plus sustained probing) and has held the 15-second cadence since. Every (provider, chain) pair in this cluster is admitted on measured behavior, not on a provider's published chain list." @@ -68,7 +68,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Sonic endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Sonic endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="sonic"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="sonic"}) @@ -91,7 +91,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Sonic endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Sonic endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="sonic"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="sonic"}) @@ -114,7 +114,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Sonic endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Sonic endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="sonic"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="sonic"}) @@ -137,7 +137,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Sonic endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Sonic endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="sonic"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="sonic"}) @@ -160,7 +160,7 @@ providers: - slug: lava name: Lava tag: Decentralized permissionless RPC mesh (sonic.lava.build, open no-key) - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Lava's no-key Sonic endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Lava's no-key Sonic endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="sonic"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="lava", chain="sonic"}) @@ -183,7 +183,7 @@ providers: - slug: sonic-official name: Sonic Labs tag: Sonic Labs public RPC, Sonic mainnet only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Sonic Labs's no-key Sonic endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Sonic Labs's no-key Sonic endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="sonic-official", chain="sonic"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="sonic-official", chain="sonic"}) diff --git a/benchmarks/taiko-rpc.yml b/benchmarks/taiko-rpc.yml index b6fc045d..86206150 100644 --- a/benchmarks/taiko-rpc.yml +++ b/benchmarks/taiko-rpc.yml @@ -4,8 +4,8 @@ slug: taiko-rpc number: "061" title: Fastest free Taiko RPC, live no-key endpoint latency seo_title: "Fastest free Taiko RPC 2026" -seo_description: "{{best_name}} leads free Taiko RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Taiko RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Taiko RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Taiko RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -17,11 +17,11 @@ seo_intro: | Taiko, the based rollup where Ethereum validators sequence L2 blocks, fields 4 no-key providers. Our verification sweep caught one routing quirk worth encoding: Tenderly serves the chain only at the `taiko-mainnet` gateway slug, the plain `/taiko` path 404s. Probes run every 15 seconds from us-east, eu-west and Singapore with stale-head detection against the cross-provider tip. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Taiko endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Taiko-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Taiko endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Taiko-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,20 +29,20 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"taiko\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, Tenderly, Taiko). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Taiko RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free Taiko RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." - "{{name:drpc}} ({{p50:drpc}}) brings the same anycast steadiness that carries it to 10 wins across the 12 expansion chains; based sequencing on the chain side changes nothing about who answers RPC reads fastest." - "{{name:tenderly}} both qualifies and disappoints: reachable only at the `taiko-mainnet` slug, and once reached it shows the flat ~330 ms three-region signature of a single origin, while the same gateway is competitive on the majors." - "A 4-provider field leaves little redundancy: one gateway incident visibly reshuffles the 24h board, which is why the success-rate column and region tabs matter more here than on the deep Ethereum cohort." faq: - q: "What is the fastest free Taiko RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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 Taiko RPCs work without an API key?" a: "The 4 providers on this page: PublicNode, dRPC, Tenderly, Taiko. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Taiko RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Taiko RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Which free Taiko RPC should production traffic use?" a: "Start from {{best_name}} ({{best_p50}} on the 3-region average) and pair it with the runner-up as fallback; in a 4-provider field a single degradation reshuffles the board. If you configure Tenderly manually, note its gateway path is `taiko-mainnet`, the intuitive `/taiko` path 404s, a detail our probes encode but provider directories rarely do." @@ -68,7 +68,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Taiko endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Taiko endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="taiko"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="taiko"}) @@ -91,7 +91,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Taiko endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Taiko endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="taiko"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="taiko"}) @@ -114,7 +114,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Taiko endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Taiko endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="taiko"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="taiko"}) @@ -137,7 +137,7 @@ providers: - slug: taiko-official name: Taiko tag: Taiko Labs public RPC, Taiko mainnet only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Taiko's no-key Taiko endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Taiko's no-key Taiko endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="taiko-official", chain="taiko"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="taiko-official", chain="taiko"}) diff --git a/benchmarks/unichain-rpc.yml b/benchmarks/unichain-rpc.yml index 5f0e574c..15b764f7 100644 --- a/benchmarks/unichain-rpc.yml +++ b/benchmarks/unichain-rpc.yml @@ -4,8 +4,8 @@ slug: unichain-rpc number: "059" title: Fastest free Unichain RPC, live no-key endpoint latency seo_title: "Fastest free Unichain RPC 2026" -seo_description: "{{best_name}} leads free Unichain RPC at {{best_p50}} (eth_blockNumber p50, 24h). 5 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Unichain RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free Unichain RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 5 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Unichain RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -17,11 +17,11 @@ seo_intro: | Unichain, Uniswap Labs' OP Stack rollup, fields 5 no-key providers including the house `mainnet.unichain.org`. For a chain this young the gateway coverage is unusually complete, PublicNode, dRPC, 1RPC and Tenderly all sustain no-key probing at our 15-second cadence, so the official endpoint faces a full gateway tier from day one. Three regions, identical probes, stale-head detection. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Unichain endpoint that sustains continuous probing, 5 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Unichain-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Unichain endpoint that sustains continuous probing, 5 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 Unichain-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,20 +29,20 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"unichain\". Provider coverage: 5 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Unichain). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free Unichain RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 5 measured providers." + - "{{best_name}} currently leads free Unichain RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 5 measured providers." - "{{name:drpc}} ({{p50:drpc}}) treats Unichain like every other chain in the expansion, and that is the point: 10 of 12 long-tail wins on the 3-region average come from routing every probe to a nearby edge, chain age irrelevant." - "The official sequencer-adjacent endpoint has the locational advantage on paper; the region tabs show whether it holds against gateways that terminate at the probe's nearest edge instead of one home region." - "{{name:tenderly}} repeats its long-tail signature here, roughly 330 ms from all three origins at once, while remaining competitive on the majors, the clearest sign its public gateway routes small chains through a single origin." faq: - q: "What is the fastest free Unichain RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 5 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 5 no-key providers probed every 15 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 Unichain RPCs work without an API key?" a: "The 5 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Unichain. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest Unichain RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 Unichain RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Should I use mainnet.unichain.org or a gateway for Unichain?" a: "Check the region tab nearest your deployment. Chain-official endpoints are typically a single origin, so they can only be close to one of our three probes, while anycast gateways answer everywhere. On the 3-region average the current leader is {{best_name}} at {{best_p50}}; a primary-plus-fallback pair from the top of this page is the resilient default for a chain this young." @@ -68,7 +68,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Unichain endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Unichain endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="unichain"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="unichain"}) @@ -91,7 +91,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Unichain endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Unichain endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="unichain"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="unichain"}) @@ -114,7 +114,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Unichain endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Unichain endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="unichain"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="unichain"}) @@ -137,7 +137,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Unichain endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Unichain endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="unichain"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="unichain"}) @@ -160,7 +160,7 @@ providers: - slug: unichain-official name: Unichain tag: Uniswap Labs public RPC, Unichain mainnet only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Unichain's no-key Unichain endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Unichain's no-key Unichain endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unichain-official", chain="unichain"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="unichain-official", chain="unichain"}) diff --git a/benchmarks/zksync-rpc.yml b/benchmarks/zksync-rpc.yml index 7f2618c7..958d6c76 100644 --- a/benchmarks/zksync-rpc.yml +++ b/benchmarks/zksync-rpc.yml @@ -4,8 +4,8 @@ slug: zksync-rpc number: "063" title: Fastest free zkSync Era RPC, live no-key endpoint latency seo_title: "Fastest free zkSync RPC 2026" -seo_description: "{{best_name}} leads free zkSync Era RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." -subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public zkSync Era RPC endpoint, audited every 15 seconds from 3 regions. +seo_description: "{{best_name}} leads free zkSync Era RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public zkSync Era RPC endpoint, audited every 15 seconds from 3 regions. category: RPCs status: live @@ -14,14 +14,14 @@ unit: ms higher_is_better: false seo_intro: | - zkSync Era is the only chain in our entire probe matrix with no PublicNode endpoint: both plausible subdomains 404, a genuine rarity for a provider that covers 70+ chains. That leaves dRPC, 1RPC, Tenderly and Matter Labs' own `mainnet.era.zksync.io` answering the identical `eth_blockNumber` probe every 15 seconds from three regions, with stale-head detection against the cross-provider tip. + zkSync Era is the only chain in our entire probe matrix with no PublicNode endpoint: both plausible subdomains 404, a genuine rarity for a provider that covers 70+ chains. That leaves dRPC, 1RPC, Tenderly and Matter Labs' own `mainnet.era.zksync.io` answering the identical `eth_getBlockByNumber` probe every 15 seconds from three regions, with stale-head detection against the cross-provider tip. abstract: | - Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public zkSync Era endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 zkSync Era-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public zkSync Era endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, 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 zkSync Era-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 15 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\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "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 latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber 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), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." @@ -29,20 +29,20 @@ methodology: - "Chain scope: every query on this page is pinned to chain=\"zksync\". Provider coverage: 4 no-key endpoints (dRPC, 1RPC, Tenderly, zkSync). Exclusions follow the cluster-wide rules documented on the parent benchmark." findings: - - "{{best_name}} currently leads free zkSync Era RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free zkSync Era RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." - "No PublicNode is the structural headline: the near-universal default provider simply does not serve zkSync Era (both candidate subdomains 404), so dapps that template PublicNode URLs per chain need a different answer here." - "{{name:drpc}} ({{p50:drpc}}) picks up the default-provider role instead, with the anycast consistency that wins it 10 of the 12 expansion chains on the 3-region average." - "{{name:tenderly}} runs zkSync through the same single-origin path as the rest of the long tail, a flat ~330 ms from all three regions, despite marketing the chain as a first-class network." faq: - q: "What is the fastest free zkSync Era RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 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 zkSync Era RPCs work without an API key?" a: "The 4 providers on this page: dRPC, 1RPC, Tenderly, zkSync. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." - q: "Does the fastest zkSync Era RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia 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 zkSync Era RPC latency measured here?" - a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. 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. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Why is PublicNode not listed for zkSync Era?" a: "Because it does not serve the chain: both plausible PublicNode subdomains returned 404 in our 2026-07-03 verification sweep, making zkSync Era the only chain we probe without a PublicNode endpoint. The bench lists what actually answers, not what coverage pages claim, so the leaderboard has 4 providers and {{best_name}} ({{best_p50}}) currently leads them." @@ -68,7 +68,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key zkSync Era endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key zkSync Era endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="zksync"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="zksync"}) @@ -91,7 +91,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key zkSync Era endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key zkSync Era endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="zksync"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="zksync"}) @@ -114,7 +114,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key zkSync Era endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key zkSync Era endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="zksync"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="zksync"}) @@ -137,7 +137,7 @@ providers: - slug: zksync-official name: zkSync tag: Matter Labs public RPC, zkSync Era only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to zkSync's no-key zkSync Era endpoint." + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to zkSync's no-key zkSync Era endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="zksync-official", chain="zksync"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="zksync-official", chain="zksync"}) diff --git a/harnesses/aggregator-latency-benchmark/cmd/script/head_lag_monitor.go b/harnesses/aggregator-latency-benchmark/cmd/script/head_lag_monitor.go index 7f1dfef9..2dccc051 100644 --- a/harnesses/aggregator-latency-benchmark/cmd/script/head_lag_monitor.go +++ b/harnesses/aggregator-latency-benchmark/cmd/script/head_lag_monitor.go @@ -16,6 +16,14 @@ import ( // Measures indexation latency: time between on-chain event and WebSocket receipt // ============================================================================ +// Post-reconnect grace period: Mobula WS replays buffered/old trades right +// after a (re)connect, which show up as multi-second "lag" that is NOT real +// indexation latency. During this window we do NOT feed the alerting gauges +// (mobula_head_lag_detailed_seconds & co) so #alerting-aggregator-latency +// stays quiet on reconnect bursts. The head_lag_seconds bench gauge keeps +// being fed (no spike alert on it; keeps AggregatorHeadLagStale happy). +const reconnectGracePeriod = 10 * time.Second + // Pool configurations for head lag monitoring type HeadLagPool struct { Name string // Human readable name @@ -84,9 +92,11 @@ func runMobulaHeadLagMonitor(config *Config, stopChan <-chan struct{}, wg *sync. return default: err := connectAndMonitorMobula(config, stopChan) + RecordWSConnected("mobula", config.MonitorRegion, false) if err != nil { - log.Printf("[HEAD-LAG][MOBULA] Connection error: %v. Reconnecting in %v...", err, reconnectDelay) - + RecordWSReconnect("mobula", config.MonitorRegion) + log.Printf("[HEAD-LAG][MOBULA] 🔌 DISCONNECTED: %v. Reconnecting in %v...", err, reconnectDelay) + select { case <-stopChan: return @@ -136,7 +146,9 @@ func connectAndMonitorMobula(config *Config, stopChan <-chan struct{}) error { return fmt.Errorf("subscribe failed: %w", err) } - fmt.Printf("[HEAD-LAG][MOBULA] Subscribed to %d pools\n", len(items)) + connectedAt := time.Now() + RecordWSConnected("mobula", config.MonitorRegion, true) + log.Printf("[HEAD-LAG][MOBULA] ✅ CONNECTED — subscribed to %d pools (alert-gauge grace period: %v)", len(items), reconnectGracePeriod) // Start ping goroutine pingDone := make(chan struct{}) @@ -210,19 +222,28 @@ func connectAndMonitorMobula(config *Config, stopChan <-chan struct{}) error { // Track latest tx per pool so alert annotations can link to it RecordMobulaLastTx(chainName, config.MonitorRegion, trade.Pair, trade.Hash) - // Record detailed metric with breakdown - RecordMobulaHeadLagDetailed( - chainName, - config.MonitorRegion, - trade.Pair, - trade.Hash, - totalLagMs, - mobulaLagMs, - networkLagMs, - onChainTime.Format("2006-01-02T15:04:05Z"), - mobulaProcessTime.Format("2006-01-02T15:04:05Z"), - receiveTime.Format("2006-01-02T15:04:05Z"), - ) + // Post-reconnect grace: don't feed the alerting gauges with + // replayed/buffered trades from the fresh connection. + if time.Since(connectedAt) < reconnectGracePeriod { + if totalLagMs > 2000 { + log.Printf("[HEAD-LAG][MOBULA][%s] ⏭️ GRACE: late trade %.2fs within %v of reconnect — alert gauge NOT updated | Tx: %s", + chainName, lagSeconds, reconnectGracePeriod, trade.Hash) + } + } else { + // Record detailed metric with breakdown + RecordMobulaHeadLagDetailed( + chainName, + config.MonitorRegion, + trade.Pair, + trade.Hash, + totalLagMs, + mobulaLagMs, + networkLagMs, + onChainTime.Format("2006-01-02T15:04:05Z"), + mobulaProcessTime.Format("2006-01-02T15:04:05Z"), + receiveTime.Format("2006-01-02T15:04:05Z"), + ) + } // Enhanced logging for spikes if totalLagMs > 3000 { @@ -325,7 +346,9 @@ func runCodexHeadLagMonitor(config *Config, stopChan <-chan struct{}, wg *sync.W log.Printf("[HEAD-LAG][CODEX] 🔄 Connection attempt #%d (proxy rotation enabled)", attemptNum) err := connectAndMonitorCodex(config, stopChan) + RecordWSConnected("codex", config.MonitorRegion, false) if err != nil { + RecordWSReconnect("codex", config.MonitorRegion) consecutiveFailures++ log.Printf("[HEAD-LAG][CODEX] ❌ Connection attempt #%d failed (%d consecutive): %v", attemptNum, consecutiveFailures, err) @@ -492,6 +515,7 @@ func connectAndMonitorCodex(config *Config, stopChan <-chan struct{}) error { log.Printf("[HEAD-LAG][CODEX] ✅ Subscribed to %d pools", len(headLagPools)) log.Printf("[HEAD-LAG][CODEX] 🎉 Connection fully established! Waiting for events...") + RecordWSConnected("codex", config.MonitorRegion, true) // graphql-transport-ws keepalive: send {"type":"ping"} every 20s. // Without this, the rotating proxy (or Codex itself) silently kills idle diff --git a/harnesses/aggregator-latency-benchmark/cmd/script/metrics.go b/harnesses/aggregator-latency-benchmark/cmd/script/metrics.go index 14a772ca..9a244032 100644 --- a/harnesses/aggregator-latency-benchmark/cmd/script/metrics.go +++ b/harnesses/aggregator-latency-benchmark/cmd/script/metrics.go @@ -49,6 +49,10 @@ var ( mobulaLastTxHash *prometheus.GaugeVec mobulaLastTxMu sync.Mutex mobulaLastTxSeen = make(map[string]string) // key: "chain:pool_address" -> last tx_hash + + // WebSocket connection lifecycle (deco/reco visibility in Grafana/Prom) + wsReconnects *prometheus.CounterVec + wsConnected *prometheus.GaugeVec ) func init() { @@ -262,6 +266,38 @@ func init() { []string{"aggregator", "chain", "region", "pool_address", "tx_hash"}, ) prometheus.MustRegister(mobulaLastTxHash) + + wsReconnects = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "ws_reconnects_total", + Help: "Total number of WebSocket reconnections per aggregator (increments on every disconnect, whatever the cause)", + }, + []string{"aggregator", "region"}, + ) + prometheus.MustRegister(wsReconnects) + + wsConnected = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "ws_connected", + Help: "WebSocket connection state per aggregator (1 = connected, 0 = disconnected)", + }, + []string{"aggregator", "region"}, + ) + prometheus.MustRegister(wsConnected) +} + +// RecordWSReconnect increments the reconnect counter for an aggregator's WebSocket. +func RecordWSReconnect(aggregator string, region string) { + wsReconnects.WithLabelValues(aggregator, region).Inc() +} + +// RecordWSConnected sets the connection state gauge for an aggregator's WebSocket. +func RecordWSConnected(aggregator string, region string, connected bool) { + v := 0.0 + if connected { + v = 1.0 + } + wsConnected.WithLabelValues(aggregator, region).Set(v) } // RecordMobulaLastTx overwrites the single live series for (chain, pool_address) diff --git a/harnesses/explorer-chain-coverage/.env.example b/harnesses/explorer-chain-coverage/.env.example new file mode 100644 index 00000000..1723f9ec --- /dev/null +++ b/harnesses/explorer-chain-coverage/.env.example @@ -0,0 +1,21 @@ +# Free self-serve keys. Empty = family skipped gracefully (partial cohort). +# Blockscout, Routescan and Blockchair are fully keyless. +ETHERSCAN_API_KEY= + +# Hours between probe cycles. Default 24 — every surface is free, but a +# cycle is ~600 calls (dominated by the Blockscout registry sweep). +# PROBE_INTERVAL_HOURS=24 + +# Minutes of block-age tolerated by the freshness gate. Default 60. +# FRESH_WINDOW_MINUTES=60 + +# Set to 1 to skip the startup probe cycle (deploy-storm days). +# SKIP_INITIAL_CYCLE=1 + +# Optional host overrides. +# ETHERSCAN_BASE_URL=https://api.etherscan.io +# ROUTESCAN_BASE_URL=https://api.routescan.io +# BLOCKCHAIR_BASE_URL=https://api.blockchair.com + +# Enables GET /logs?tail=N (header X-Logs-Token). +# LOGS_TOKEN= diff --git a/harnesses/explorer-chain-coverage/Dockerfile b/harnesses/explorer-chain-coverage/Dockerfile new file mode 100644 index 00000000..b07f5dfd --- /dev/null +++ b/harnesses/explorer-chain-coverage/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/explorer-chain-coverage ./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/explorer-chain-coverage /app/explorer-chain-coverage + +EXPOSE 2112 + +CMD ["/app/explorer-chain-coverage"] diff --git a/harnesses/explorer-chain-coverage/README.md b/harnesses/explorer-chain-coverage/README.md new file mode 100644 index 00000000..a80b74fc --- /dev/null +++ b/harnesses/explorer-chain-coverage/README.md @@ -0,0 +1,60 @@ +# explorer-chain-coverage + +OpenChainBench harness measuring **block explorer chain coverage** — for each explorer family, how many chains does it *register*, and on how many does its API demonstrably serve a *working indexer* today? + +## What it measures + +Three honest numbers per family, once per day: + +| Number | Meaning | How | +|---|---|---| +| **registered** | Mainnets the family self-declares via a machine-readable surface | One registry/chainlist call | +| **verified** | Registered mainnets whose latest indexed block is younger than the freshness window (default 60m) | One freshness probe per chain | +| **top-50** | Of the 50 most economically active mainnets (pinned in `top50.go`), how many pass the same gate | Same probes, restricted view | + +Registries rot, raw counts reward ghost rollups, marketing claims for the same vendor range from 100+ to 3000+ chains. The three numbers separate the claim, the catalog, and the working product. + +## Families tracked + +| Family | Key | registered source | freshness probe | +|---|---|---|---| +| Blockscout | keyless | Chainscout registry (`chains.blockscout.com/api/chains`, mainnets, `hostedBy` preserved) | `{instance}/api/v2/blocks?type=block` → `items[0].timestamp`, 8-worker sweep across distinct hosts, no retry (a dead instance IS the measurement) | +| Etherscan | free self-serve | keyless `/v2/chainlist` (testnets filtered by name) | `module=block&action=getblocknobytime×tamp=now-window&closest=after` — queries their INDEX, unlike `module=proxy` | +| Routescan | keyless | `/v2/network/mainnet/evm/all/blockchains` | `/v2/network/mainnet/evm/{id}/blocks?limit=1` → `items[0].timestamp` | +| Blockchair | keyless | aggregate `/stats` chain keys | `/{chain}/stats` → `data.best_block_time` (UTC, no suffix) | + +Etherscan's freshness probes activate with `ETHERSCAN_API_KEY` (free); everything else is keyless. Subscan and OKLink were audited and excluded: signup proved impractical, and the cohort rule is free REPRODUCIBLE access. + +## Fairness rules + +- **Same freshness gate for everyone**: latest indexed block younger than `FRESH_WINDOW_MINUTES` (default 60 — tolerates slow producers, catches stalled pipelines). A reachable server with a stalled indexer never counts. +- **Testnets excluded everywhere.** +- **Operator attribution stays auditable**: most Blockscout instances are chain-team-run; the registry's `hostedBy` label is preserved so vendor-run vs chain-run can be split. The count measures the software family's working footprint — the claim its marketing makes — and the rule benefits every family equally. +- **Instance failures during the Blockscout sweep are never error-bucketed**: registry rot is the signal, not a fault. Only registry/chainlist fetch failures bucket errors. +- **Quota-truncated cycles publish nothing** (publish-then-leave): 401/402/403/406/429 anywhere in a keyed family's sweep invalidates that family's cycle. + +## Probe budget + +All surfaces free. Per cycle: Blockscout ~460 (distinct hosts, worker pool), Etherscan ~36 (600ms spacing, 3 rps free), Routescan ~37, Blockchair ~15. Total ≈ 550 calls/day. `explorer_probe_calls_total{provider}` guards volume drift. + +## Metrics + +| Name | Type | Labels | +|---|---|---| +| `explorer_chains_registered` | gauge | provider, registered_source (`registry` \| `pinned`) | +| `explorer_chains_verified` | gauge | provider | +| `explorer_chains_top50` | gauge | provider | +| `explorer_probe_latency_ms` | gauge | provider | +| `explorer_probe_errors_total` | counter | provider, kind | +| `explorer_probe_calls_total` | counter | provider | +| `explorer_last_probe_timestamp` | gauge | provider | + +## Env vars + +See `.env.example`. `SKIP_INITIAL_CYCLE=1` suppresses the startup probe on deploy-storm days. + +## Development + +```bash +go vet ./... && go build -o /tmp/ecc ./cmd/script && go test ./... +``` diff --git a/harnesses/explorer-chain-coverage/cmd/script/config.go b/harnesses/explorer-chain-coverage/cmd/script/config.go new file mode 100644 index 00000000..0285b700 --- /dev/null +++ b/harnesses/explorer-chain-coverage/cmd/script/config.go @@ -0,0 +1,45 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// Config holds runtime knobs. Set via env vars on the deploy target. +type Config struct { + // ProbeInterval is how often a full probe cycle runs. Default is + // 24h: every cohort surface is free, but a full cycle is ~600 + // upstream calls (dominated by the Blockscout registry sweep) and + // chain-coverage numbers move on a weeks cadence. + ProbeInterval time.Duration +} + +func loadConfig() *Config { + c := &Config{ProbeInterval: 24 * time.Hour} + + if v := os.Getenv("PROBE_INTERVAL_HOURS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.ProbeInterval = time.Duration(n) * time.Hour + } + } + if v := os.Getenv("FRESH_WINDOW_MINUTES"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + freshWindow = time.Duration(n) * time.Minute + } + } + + fmt.Printf("Config: providers=%d, probe_every=%v, fresh_window=%v\n", + len(Registry), c.ProbeInterval, freshWindow) + return c +} + +// envDefault returns the trimmed env var value, or def when unset. +func envDefault(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} diff --git a/harnesses/explorer-chain-coverage/cmd/script/http.go b/harnesses/explorer-chain-coverage/cmd/script/http.go new file mode 100644 index 00000000..c964c698 --- /dev/null +++ b/harnesses/explorer-chain-coverage/cmd/script/http.go @@ -0,0 +1,157 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Per-call HTTP timeout. 20s is generous for portfolio APIs that fan +// out to dozens of chain indexers server-side; anything slower is a +// vendor problem worth surfacing as a timeout error. +const httpTimeout = 20 * time.Second + +// retryDelay is the wait before the single allowed retry. 30s gives a +// transient 5xx / edge timeout time to clear without burning credits +// on a hot loop. +const retryDelay = 30 * time.Second + +// sweepSpacing is the pause between two consecutive calls inside a +// provider's per-chain sweep against ONE host (Etherscan, Routescan, +// Subscan, Blockchair). Rate limits bite on bursts, not daily volume. +// The Blockscout registry sweep talks to hundreds of DISTINCT hosts +// and uses a small worker pool instead (no shared rate limit). +const sweepSpacing = 600 * time.Millisecond + +var httpClient = &http.Client{Timeout: httpTimeout} + +func clientFor(provider string) *http.Client { + return httpClient +} + +// httpError carries the status code so callers can branch on 4xx +// (e.g. Mobula's optional SOL/BTC probes tolerate a 400). +type httpError struct { + status int + body string +} + +func (e *httpError) Error() string { + return fmt.Sprintf("http %d: %s", e.status, e.body) +} + +// httpStatus returns the HTTP status behind err, or 0 for transport +// level failures (DNS, TLS, timeout). +func httpStatus(err error) int { + if he, ok := err.(*httpError); ok { + return he.status + } + return 0 +} + +// doCall executes one HTTP request with the standard probe semantics: +// per-call 20s timeout, single retry after 30s ONLY on 5xx or +// timeout/transport failure — never on 4xx (a 4xx is deterministic: +// retrying burns credits without changing the answer). Returns the +// response body, the total elapsed wall time across attempts, and an +// error for any non-2xx outcome. Every attempt (including the retry) +// increments portfolio_probe_calls_total{provider} so monthly credit +// consumption per vendor is observable in Prometheus. +func doCall(provider, method, url string, headers map[string]string, body []byte) ([]byte, time.Duration, error) { + countCall(provider) + client := clientFor(provider) + b, elapsed, err := doOnce(client, method, url, headers, body) + if err != nil && retryable(err) { + fmt.Printf(" [retry] %s %s failed (%v), retrying in %v\n", method, url, err, retryDelay) + time.Sleep(retryDelay) + countCall(provider) + b2, elapsed2, err2 := doOnce(client, method, url, headers, body) + return b2, elapsed + elapsed2, err2 + } + return b, elapsed, err +} + +func doOnce(client *http.Client, method, url string, headers map[string]string, body []byte) ([]byte, time.Duration, error) { + var rdr io.Reader + if body != nil { + rdr = bytes.NewReader(body) + } + req, err := http.NewRequest(method, url, rdr) + if err != nil { + return nil, 0, err + } + for k, v := range headers { + req.Header.Set(k, v) + } + if body != nil && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + // Go's default "Go-http-client/2.0" UA gets WAF-throttled by some + // providers (Zerion 429s the portfolio endpoint instantly on it + // while the identical curl request passes). Identify honestly. + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0 (+https://openchainbench.com)") + if req.Header.Get("Accept") == "" { + req.Header.Set("Accept", "application/json") + } + + start := time.Now() + resp, err := client.Do(req) + elapsed := time.Since(start) + if err != nil { + return nil, elapsed, err + } + defer resp.Body.Close() + + // 20MB cap: portfolio responses for a whale wallet can be large, + // but anything bigger than this is a runaway payload. + raw, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20)) + if err != nil { + return nil, elapsed, fmt.Errorf("read body: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return raw, elapsed, &httpError{status: resp.StatusCode, body: truncate(string(raw), 200)} + } + return raw, elapsed, nil +} + +// isQuotaStatus flags statuses that mean "the account, not the +// chain": credit exhaustion, auth, throttling. A probe cycle that +// hits one of these is truncated, not measured — publishing its +// partial counts would clobber good gauges with an artifact (seen +// live twice on 2026-07-06/07 with CoinStats 406 credit limits). +func isQuotaStatus(s int) bool { + return s == 401 || s == 402 || s == 403 || s == 406 || s == 429 +} + +// doCallOnce is doCall without the retry. Used by registry sweeps +// where a failure IS the measurement (dead registry entries): a 30s +// retry per dead host would multiply the cycle length for nothing. +func doCallOnce(provider, method, url string, headers map[string]string, body []byte) ([]byte, time.Duration, error) { + countCall(provider) + return doOnce(clientFor(provider), method, url, headers, body) +} + +// retryable: only transport/timeout failures and 5xx. 4xx never. +func retryable(err error) bool { + if status := httpStatus(err); status != 0 { + return status >= 500 + } + // Transport-level: DNS, TLS, connection refused, context deadline. + msg := err.Error() + return strings.Contains(msg, "timeout") || + strings.Contains(msg, "deadline") || + strings.Contains(msg, "connection") || + strings.Contains(msg, "EOF") || + strings.Contains(msg, "no such host") +} + +func truncate(s string, n int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/harnesses/explorer-chain-coverage/cmd/script/loghub.go b/harnesses/explorer-chain-coverage/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/explorer-chain-coverage/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/explorer-chain-coverage/cmd/script/main.go b/harnesses/explorer-chain-coverage/cmd/script/main.go new file mode 100644 index 00000000..ea020c39 --- /dev/null +++ b/harnesses/explorer-chain-coverage/cmd/script/main.go @@ -0,0 +1,163 @@ +// explorer-chain-coverage measures how many blockchains each block +// explorer family actually serves with a WORKING indexer — split into +// honest per-provider numbers: +// +// explorer_chains_registered{provider, registered_source} +// explorer_chains_verified{provider} freshness-gated live count +// explorer_chains_top50{provider} coverage of the 50 most +// active mainnets +// +// registered is what the family self-declares via a machine-readable +// surface; verified only counts chains whose latest indexed block is +// younger than the freshness window (a reachable web server with a +// stalled indexer does not count); top50 is the anti-inflation view +// (raw counts reward hosting ghost rollups). The gaps between the +// three are the story: marketing claims like "3000+ chains" are +// unverifiable by anyone, registries rot, and the fresh-indexed +// number is what integrators can actually build on today. +// +// Every surface in the cohort is free; two families need free +// self-serve keys (Etherscan, Subscan, OKLink) and are skipped +// gracefully without them. Cycle default 24h. Quota-truncated cycles +// publish nothing (publish-then-leave). +// +// HTTP server on :2112 per OCB harness convention. +package main + +import ( + "fmt" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" +) + +// providerSpacing is the pause between two sequential provider probes. +const providerSpacing = 5 * time.Second + +func main() { + installLogCapture() + fmt.Println("=== explorer-chain-coverage harness ===") + fmt.Println("OpenChainBench - block explorer chain coverage: registered vs fresh-indexed.") + fmt.Println("Exposes /metrics on :2112.") + fmt.Println() + + cfg := loadConfig() + for _, p := range Registry { + if p.KeyEnv == "" { + fmt.Printf(" - %-11s keyless\n", p.Slug) + continue + } + fmt.Printf(" - %-11s key_env=%s key_set=%v\n", + p.Slug, p.KeyEnv, strings.TrimSpace(os.Getenv(p.KeyEnv)) != "") + } + fmt.Println() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + wg.Add(1) + go func() { + defer wg.Done() + fmt.Println("Starting Prometheus metrics server on :2112") + if err := StartMetricsServer(":2112"); err != nil { + fmt.Printf("Metrics server error: %v\n", err) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + runProbeLoop(cfg, stop) + }() + + <-sigChan + fmt.Println("\nShutting down...") + close(stop) + wg.Wait() +} + +func runProbeLoop(cfg *Config, stop <-chan struct{}) { + tick := time.NewTicker(cfg.ProbeInterval) + defer tick.Stop() + + if envDefault("SKIP_INITIAL_CYCLE", "") == "1" { + fmt.Println("[cycle] SKIP_INITIAL_CYCLE=1: waiting for the first tick") + } else { + runCycle() + } + for { + select { + case <-stop: + return + case <-tick.C: + runCycle() + } + } +} + +var skipLogged = map[string]bool{} + +func runCycle() { + fmt.Printf("[cycle] starting probe cycle at %s\n", time.Now().UTC().Format(time.RFC3339)) + first := true + + for _, p := range Registry { + key := "" + if p.KeyEnv != "" { + key = strings.TrimSpace(os.Getenv(p.KeyEnv)) + if key == "" { + if !skipLogged[p.Slug] { + fmt.Printf("[cycle] skipping %s: %s is empty (free self-serve key enables it)\n", p.Slug, p.KeyEnv) + skipLogged[p.Slug] = true + } + continue + } + } + + if !first { + time.Sleep(providerSpacing) + } + first = false + + fmt.Printf("[%s] probing...\n", p.Slug) + cov := p.Probe(key) + publish(p.Slug, cov) + } + fmt.Printf("[cycle] probe cycle complete\n") +} + +// publish writes one provider's coverage. Fields at -1 are unknown +// this cycle and left untouched (publish-then-leave). +func publish(slug string, cov coverage) { + published := false + if cov.registered >= 0 { + explorerChainsRegistered.WithLabelValues(slug, cov.registeredSource).Set(float64(cov.registered)) + published = true + } + if cov.verified >= 0 { + explorerChainsVerified.WithLabelValues(slug).Set(float64(cov.verified)) + published = true + } + if cov.verifiedStrict >= 0 { + explorerChainsVerifiedStrict.WithLabelValues(slug).Set(float64(cov.verifiedStrict)) + published = true + } + if cov.top50 >= 0 { + explorerChainsTop50.WithLabelValues(slug).Set(float64(cov.top50)) + published = true + } + if cov.latencyMs > 0 { + explorerProbeLatencyMs.WithLabelValues(slug).Set(cov.latencyMs) + } + if published { + explorerLastProbeTimestamp.WithLabelValues(slug).Set(float64(time.Now().Unix())) + } + fmt.Printf("[%s] registered=%d (source=%s) verified=%d strict5m=%d top50=%d latency_ms=%.0f\n", + slug, cov.registered, cov.registeredSource, cov.verified, cov.verifiedStrict, cov.top50, cov.latencyMs) +} diff --git a/harnesses/explorer-chain-coverage/cmd/script/metrics.go b/harnesses/explorer-chain-coverage/cmd/script/metrics.go new file mode 100644 index 00000000..2a30dc68 --- /dev/null +++ b/harnesses/explorer-chain-coverage/cmd/script/metrics.go @@ -0,0 +1,132 @@ +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" +) + +// Gauges are keyed by `provider=` and publish-then-leave: +// a failed or quota-truncated cycle publishes nothing and Prometheus +// retention carries the previous values forward. +var ( + explorerChainsRegistered = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "explorer_chains_registered", + Help: "Mainnet chains the explorer family self-declares through a machine-readable surface. registered_source=registry means a standalone registry/chainlist endpoint (Blockscout Chainscout, Etherscan chainlist, Routescan blockchains, Blockchair stats, OKLink summary); registered_source=pinned means no machine surface exists and the harness pins the list (Subscan network subdomains).", + }, + []string{"provider", "registered_source"}, + ) + + explorerChainsVerified = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "explorer_chains_verified", + Help: "Registered mainnet chains whose explorer API passed the freshness probe this cycle: latest indexed block younger than the freshness window (default 60m). A 200 from a stalled indexer does not count, so this measures working indexers, not reachable web servers.", + }, + []string{"provider"}, + ) + + explorerChainsVerifiedStrict = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "explorer_chains_verified_strict", + Help: "Registered mainnet chains whose latest indexed block was younger than 5 MINUTES at probe time (tight rung of the freshness ladder, same probes as explorer_chains_verified). Separates real-time indexers from batch pipelines.", + }, + []string{"provider"}, + ) + + explorerChainsTop50 = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "explorer_chains_top50", + Help: "Of the pinned 50 most economically active mainnets (DefiLlama TVL + fees blend, see harness top50.go), how many passed the freshness probe on this family. Anti-inflation column: raw counts reward ghost chains, this answers the integrator question.", + }, + []string{"provider"}, + ) + + explorerProbeLatencyMs = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "explorer_probe_latency_ms", + Help: "Aggregate HTTP round-trip in milliseconds across all calls of the provider's last probe cycle.", + }, + []string{"provider"}, + ) + + explorerProbeErrors = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "explorer_probe_errors_total", + Help: "Probe failures per provider, bucketed by kind: timeout, auth, rate_limit, server_error, not_found, parse, other.", + }, + []string{"provider", "kind"}, + ) + + explorerLastProbeTimestamp = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "explorer_last_probe_timestamp", + Help: "Unix timestamp of the last cycle that published at least one value for the provider. Staleness alarm.", + }, + []string{"provider"}, + ) + + explorerProbeCalls = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "explorer_probe_calls_total", + Help: "Upstream HTTP attempts per provider, retries included. All cohort surfaces are free; this guards against accidental volume drift.", + }, + []string{"provider"}, + ) +) + +// countCall tallies one upstream HTTP attempt for a provider. +func countCall(provider string) { + explorerProbeCalls.WithLabelValues(provider).Inc() +} + +// classifyError buckets an error string into a bounded enum (same +// classifier shape as the other OCB harnesses). +func classifyError(msg string) string { + switch { + case contains(msg, "timeout"), contains(msg, "deadline"): + return "timeout" + case contains(msg, "401"), contains(msg, "403"), contains(msg, "unauthorized"): + return "auth" + case contains(msg, "429"): + return "rate_limit" + case contains(msg, "500"), contains(msg, "502"), contains(msg, "503"), contains(msg, "504"): + return "server_error" + case contains(msg, "404"): + return "not_found" + case contains(msg, "parse"), contains(msg, "unmarshal"), contains(msg, "unexpected"): + return "parse" + default: + return "other" + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// recordError logs and buckets one probe failure for a provider. +func recordError(provider string, err error) { + explorerProbeErrors.WithLabelValues(provider, classifyError(err.Error())).Inc() +} + +// StartMetricsServer binds /metrics + /health + /logs on addr. +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("explorer-chain-coverage harness · OpenChainBench")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/explorer-chain-coverage/cmd/script/parse_test.go b/harnesses/explorer-chain-coverage/cmd/script/parse_test.go new file mode 100644 index 00000000..deb10230 --- /dev/null +++ b/harnesses/explorer-chain-coverage/cmd/script/parse_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "testing" + "time" +) + +func iso(t time.Time) string { return t.UTC().Format(time.RFC3339) } + +// ─── freshness gate ───────────────────────────────────────────────── + +func TestFreshEnough(t *testing.T) { + if !freshEnough(time.Now().Add(-5 * time.Minute)) { + t.Fatal("5 minutes old must be fresh") + } + if freshEnough(time.Now().Add(-3 * time.Hour)) { + t.Fatal("3 hours old must be stale") + } + if freshEnough(time.Time{}) { + t.Fatal("zero time must be stale") + } +} + +// ─── Blockscout ───────────────────────────────────────────────────── + +func TestParseChainscout(t *testing.T) { + fixture := `{ + "1": {"name":"Ethereum","isTestnet":false,"explorers":[{"url":"https://eth.blockscout.com/","hostedBy":"blockscout"}]}, + "11155111": {"name":"Sepolia","isTestnet":true,"explorers":[{"url":"https://sepolia.blockscout.com","hostedBy":"blockscout"}]}, + "100": {"name":"Gnosis","isTestnet":false,"explorers":[{"url":"https://gnosis.dead.example","hostedBy":"self"},{"url":"https://gnosis.blockscout.com","hostedBy":"blockscout"}]} + }` + m, err := parseChainscout([]byte(fixture)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(m) != 2 { + t.Fatalf("mainnets = %d, want 2 (testnet filtered)", len(m)) + } + if got := pickBlockscoutURL(m["100"]); got != "https://gnosis.blockscout.com" { + t.Fatalf("pick = %q, want the blockscout-hosted instance", got) + } + if got := pickBlockscoutURL(m["1"]); got != "https://eth.blockscout.com" { + t.Fatalf("pick = %q, want trailing slash trimmed", got) + } +} + +func TestParseBlockscoutLatestBlock(t *testing.T) { + fresh := `{"items":[{"timestamp":"` + iso(time.Now().Add(-2*time.Minute)) + `"}]}` + ts, err := parseBlockscoutLatestBlock([]byte(fresh)) + if err != nil || !freshEnough(ts) { + t.Fatalf("fresh block must parse and pass gate: %v", err) + } + if _, err := parseBlockscoutLatestBlock([]byte(`{"items":[]}`)); err == nil { + t.Fatal("empty items must error") + } +} + +// ─── Etherscan ────────────────────────────────────────────────────── + +func TestParseEtherscanChainlist(t *testing.T) { + fixture := `{"result":[ + {"chainname":"Ethereum Mainnet","chainid":"1"}, + {"chainname":"Sepolia Testnet","chainid":"11155111"}, + {"chainname":"Base Mainnet","chainid":"8453"}, + {"chainname":"Holesky","chainid":"17000"} + ]}` + chains, err := parseEtherscanChainlist([]byte(fixture)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(chains) != 2 { + // Sepolia caught by the testnet token, Holesky by the holesky + // token: only Ethereum and Base remain. + t.Fatalf("mainnets = %d, want 2: %v", len(chains), chains) + } +} + +func TestParseEtherscanBlockNoByTime(t *testing.T) { + fresh, err := parseEtherscanBlockNoByTime([]byte(`{"status":"1","message":"OK","result":"23456789"}`)) + if err != nil || !fresh { + t.Fatalf("status 1 must be fresh, got %v %v", fresh, err) + } + fresh, err = parseEtherscanBlockNoByTime([]byte(`{"status":"0","message":"No records found","result":""}`)) + if err != nil || fresh { + t.Fatalf("no records = stale, not error: %v %v", fresh, err) + } + if _, err := parseEtherscanBlockNoByTime([]byte(`{"status":"0","message":"NOTOK","result":"Max rate limit reached"}`)); err == nil { + t.Fatal("rate limit must surface as error") + } + if _, err := parseEtherscanBlockNoByTime([]byte(`{"status":"0","message":"NOTOK","result":"Missing/Invalid API Key"}`)); err == nil { + t.Fatal("auth must surface as error") + } +} + +// ─── Routescan ────────────────────────────────────────────────────── + +func TestParseRoutescanChains(t *testing.T) { + chains, err := parseRoutescanChains([]byte(`{"items":[{"chainId":"1","name":"Ethereum"},{"chainId":"43114","name":"Avalanche"}]}`)) + if err != nil || len(chains) != 2 { + t.Fatalf("wrapped shape: %v %v", chains, err) + } + chains, err = parseRoutescanChains([]byte(`[{"chainId":"10","name":"Optimism"}]`)) + if err != nil || len(chains) != 1 { + t.Fatalf("bare array shape: %v %v", chains, err) + } +} + +func TestParseRoutescanLatestBlock(t *testing.T) { + fresh := `{"items":[{"timestamp":"` + iso(time.Now().Add(-1*time.Minute)) + `","number":123}]}` + ts, err := parseRoutescanLatestBlock([]byte(fresh)) + if err != nil || !freshEnough(ts) { + t.Fatalf("fresh block must pass: %v", err) + } +} + +// ─── Blockchair ───────────────────────────────────────────────────── + +func TestParseBlockchairAggregate(t *testing.T) { + chains, err := parseBlockchairAggregate([]byte(`{"data":{"bitcoin":{},"ethereum":{},"litecoin":{}}}`)) + if err != nil || len(chains) != 3 { + t.Fatalf("aggregate: %v %v", chains, err) + } +} + +func TestParseBlockchairBestBlockTime(t *testing.T) { + fresh := `{"data":{"best_block_time":"` + time.Now().UTC().Add(-10*time.Minute).Format("2006-01-02 15:04:05") + `"}}` + ts, err := parseBlockchairBestBlockTime([]byte(fresh)) + if err != nil || !freshEnough(ts) { + t.Fatalf("fresh best_block_time must pass: %v", err) + } + stale := `{"data":{"best_block_time":"2020-01-01 00:00:00"}}` + ts, err = parseBlockchairBestBlockTime([]byte(stale)) + if err != nil || freshEnough(ts) { + t.Fatal("2020 block must be stale") + } +} + +func timeNowUnixMinus(d time.Duration) string { + return timeToUnixStr(time.Now().Add(-d)) +} + +func timeToUnixStr(t time.Time) string { + return fmtInt(t.Unix()) +} + +func fmtInt(i int64) string { + // tiny helper avoiding strconv import churn in tests + if i == 0 { + return "0" + } + var b [20]byte + pos := len(b) + for i > 0 { + pos-- + b[pos] = byte('0' + i%10) + i /= 10 + } + return string(b[pos:]) +} + +// ─── top50 ────────────────────────────────────────────────────────── + +func TestTop50Count(t *testing.T) { + if len(top50) != 50 { + t.Fatalf("pinned list must hold exactly 50 entries, has %d", len(top50)) + } + evm := map[int64]bool{1: true, 8453: true} + names := map[string]bool{"bitcoin": true, "solana": true, "kusama": true} + if got := top50Count(evm, names); got != 4 { + t.Fatalf("top50Count = %d, want 4 (eth, base, btc, sol; kusama not in list)", got) + } +} diff --git a/harnesses/explorer-chain-coverage/cmd/script/registry.go b/harnesses/explorer-chain-coverage/cmd/script/registry.go new file mode 100644 index 00000000..fffb924b --- /dev/null +++ b/harnesses/explorer-chain-coverage/cmd/script/registry.go @@ -0,0 +1,94 @@ +package main + +import "time" + +// Provider is one block-explorer family measured by this harness. +// Slug MUST match the OCB site's provider registry. KeyEnv may be +// empty (fully keyless family); when set and the env var is empty the +// provider is SKIPPED gracefully (partial cohort, logged once). +type Provider struct { + Slug string + Name string + KeyEnv string // "" = keyless family + Probe func(key string) coverage +} + +// coverage is the outcome of one provider probe cycle. +type coverage struct { + // registered is the chain count the family self-declares through + // a machine-readable surface (Chainscout registry, Etherscan + // chainlist, Routescan blockchains endpoint, ...). Mainnets only. + // -1 = unknown this cycle. + registered int + + // registeredSource labels where registered comes from: + // "registry" — a standalone machine-readable registry/chainlist + // "pinned" — no machine surface exists; the harness pins the + // list (Subscan network subdomains) + registeredSource string + + // verified is the number of registered mainnet chains whose + // explorer API answered the freshness probe: latest indexed block + // timestamp within freshWindow of now. A 200 from a stalled + // indexer does NOT count. -1 = unknown. + verified int + + // top50 is how many of the pinned top-50 most active mainnets + // (see top50.go) passed the same freshness probe on this family. + // This is the anti-inflation column: raw chain counts reward + // hosting ghost chains, top50 answers the buyer question. -1 = + // unknown. + top50 int + + // verifiedStrict is the verified count under the 5-minute + // freshness window, computed from the SAME probe data. The + // 60m/5m ladder separates batch pipelines from real-time + // indexers at zero extra call cost. -1 = unknown. + verifiedStrict int + + // latencyMs aggregates HTTP time across the probe cycle. + latencyMs float64 +} + +// freshWindowStrict is the tight rung of the freshness ladder. +const freshWindowStrict = 5 * time.Minute + +func freshStrict(latest time.Time) bool { + if latest.IsZero() { + return false + } + return time.Since(latest) <= freshWindowStrict +} + +// freshWindow is how recent the latest indexed block must be for a +// chain to count as live-verified. 60 minutes tolerates slow and lazy +// block producers (Bitcoin ~10m, low-traffic L2s with on-demand +// blocks) while still catching stalled indexers, which drift hours to +// weeks behind. Override via FRESH_WINDOW_MINUTES. +var freshWindow = 60 * time.Minute + +// freshEnough is the shared gate: a chain counts only when its latest +// indexed block is younger than freshWindow. +func freshEnough(latest time.Time) bool { + if latest.IsZero() { + return false + } + return time.Since(latest) <= freshWindow +} + +// Registry is the canonical cohort. Order = probe order (sequential). +var Registry = []Provider{ + {Slug: "blockscout", Name: "Blockscout", KeyEnv: "", Probe: probeBlockscout}, + // Etherscan runs KEYLESS for the registered count (its chainlist + // needs no auth) and adds verified/top50 once ETHERSCAN_API_KEY is + // set — the probe handles the empty key itself. + {Slug: "etherscan", Name: "Etherscan", KeyEnv: "", Probe: probeEtherscan}, + {Slug: "routescan", Name: "Routescan", KeyEnv: "", Probe: probeRoutescan}, + {Slug: "blockchair", Name: "Blockchair", KeyEnv: "", Probe: probeBlockchair}, +} + +// Families audited and excluded (2026-07-07): Subscan and OKLink both +// hard-require account signups that proved impractical to complete; +// the cohort rule is free REPRODUCIBLE access, and a bench nobody can +// rerun is not reproducible. 3xpl needs a Discord-granted token. +// Revisit if any of them ships self-serve keyless access. diff --git a/harnesses/explorer-chain-coverage/cmd/script/source_blockchair.go b/harnesses/explorer-chain-coverage/cmd/script/source_blockchair.go new file mode 100644 index 00000000..b7de6bb7 --- /dev/null +++ b/harnesses/explorer-chain-coverage/cmd/script/source_blockchair.go @@ -0,0 +1,122 @@ +package main + +import ( + "encoding/json" + "fmt" + "time" +) + +// Blockchair. Fully keyless (soft limit ~1440 req/day; a cycle spends +// ~15 calls). +// +// registered: GET api.blockchair.com/stats — the aggregate stats +// endpoint, one key per supported chain. +// verified: per chain GET /{chain}/stats and gate +// data.best_block_time ("YYYY-MM-DD HH:MM:SS", UTC, no +// suffix) on the freshness window. +const blockchairBaseDefault = "https://api.blockchair.com" + +func probeBlockchair(_ string) coverage { + base := envDefault("BLOCKCHAIR_BASE_URL", blockchairBaseDefault) + cov := coverage{registered: -1, registeredSource: "registry", verified: -1, verifiedStrict: -1, top50: -1} + var total time.Duration + + raw, el, err := doCall("blockchair", "GET", base+"/stats", map[string]string{"Accept": "application/json"}, nil) + total += el + if err != nil { + recordError("blockchair", err) + fmt.Printf("[blockchair] aggregate stats failed: %v\n", err) + cov.latencyMs = float64(total.Milliseconds()) + return cov + } + chains, perr := parseBlockchairAggregate(raw) + if perr != nil { + recordError("blockchair", perr) + fmt.Printf("[blockchair] aggregate parse failed: %v\n", perr) + cov.latencyMs = float64(total.Milliseconds()) + return cov + } + cov.registered = len(chains) + + nameLive := map[string]bool{} + verified := 0 + strict := 0 + anyOK := false + quotaHit := false + for _, chain := range chains { + time.Sleep(sweepSpacing) + raw, el, err := doCall("blockchair", "GET", base+"/"+chain+"/stats", map[string]string{"Accept": "application/json"}, nil) + total += el + if err != nil { + if isQuotaStatus(httpStatus(err)) { + quotaHit = true + recordError("blockchair", err) + continue + } + anyOK = true + continue + } + ts, perr := parseBlockchairBestBlockTime(raw) + anyOK = true + if perr == nil && freshEnough(ts) { + verified++ + if freshStrict(ts) { + strict++ + } + nameLive[normalizeChainName(chain)] = true + // blockchair keys are dashed names ("bitcoin-cash"); also + // index the undashed form for top-50 alias matching. + } + } + + if quotaHit { + fmt.Printf("[blockchair] quota-class failures during cycle, publishing nothing\n") + cov.registered, cov.verified, cov.top50 = -1, -1, -1 + } else if anyOK { + cov.verified = verified + cov.verifiedStrict = strict + cov.top50 = top50Count(map[int64]bool{}, nameLive) + } + cov.latencyMs = float64(total.Milliseconds()) + return cov +} + +// parseBlockchairAggregate returns the chain keys of the aggregate +// stats payload ({"data": {"bitcoin": {...}, ...}}). +func parseBlockchairAggregate(raw []byte) ([]string, error) { + var resp struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("parse aggregate: %w", err) + } + if len(resp.Data) == 0 { + return nil, fmt.Errorf("parse aggregate: empty data") + } + out := make([]string, 0, len(resp.Data)) + for k := range resp.Data { + out = append(out, k) + } + return out, nil +} + +// parseBlockchairBestBlockTime reads data.best_block_time, format +// "2026-07-07 13:24:11" in UTC without a timezone suffix. +func parseBlockchairBestBlockTime(raw []byte) (time.Time, error) { + var resp struct { + Data struct { + BestBlockTime string `json:"best_block_time"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return time.Time{}, fmt.Errorf("parse stats: %w", err) + } + if resp.Data.BestBlockTime == "" { + return time.Time{}, fmt.Errorf("parse stats: no best_block_time") + } + t, err := time.ParseInLocation("2006-01-02 15:04:05", resp.Data.BestBlockTime, time.UTC) + if err != nil { + return time.Time{}, fmt.Errorf("parse best_block_time: %w", err) + } + return t, nil +} diff --git a/harnesses/explorer-chain-coverage/cmd/script/source_blockscout.go b/harnesses/explorer-chain-coverage/cmd/script/source_blockscout.go new file mode 100644 index 00000000..de4b476d --- /dev/null +++ b/harnesses/explorer-chain-coverage/cmd/script/source_blockscout.go @@ -0,0 +1,224 @@ +package main + +import ( + "encoding/json" + "fmt" + "strconv" + "sync" + "sync/atomic" + "time" +) + +// Blockscout. Fully keyless. +// +// registered: GET chains.blockscout.com/api/chains — the Chainscout +// registry, a machine-readable dict keyed by chain id +// with explorer URLs and a hostedBy label. Mainnets only. +// verified: for every registered mainnet, GET +// {instance}/api/v2/blocks?type=block and gate on +// items[0].timestamp being inside the freshness window. +// The registry is known to rot (dead DNS, chains that +// migrated away), which is exactly what the +// registered-vs-verified gap surfaces. Instance failures +// are expected and never error-bucketed; only the +// registry fetch itself can fail the cycle. +const chainscoutURL = "https://chains.blockscout.com/api/chains" + +// blockscoutWorkers is the sweep concurrency. Every probe hits a +// DISTINCT host, so there is no shared rate limit to respect; 8 +// workers keep a ~460-instance sweep under 2 minutes while staying +// polite per host. +const blockscoutWorkers = 8 + +type chainscoutEntry struct { + Name string `json:"name"` + IsTestnet bool `json:"isTestnet"` + Explorers []struct { + URL string `json:"url"` + HostedBy string `json:"hostedBy"` + } `json:"explorers"` +} + +func probeBlockscout(_ string) coverage { + cov := coverage{registered: -1, registeredSource: "registry", verified: -1, verifiedStrict: -1, top50: -1} + var total time.Duration + + raw, el, err := doCall("blockscout", "GET", chainscoutURL, map[string]string{"Accept": "application/json"}, nil) + total += el + if err != nil { + recordError("blockscout", err) + fmt.Printf("[blockscout] chainscout registry failed: %v\n", err) + cov.latencyMs = float64(total.Milliseconds()) + return cov + } + mainnets, perr := parseChainscout(raw) + if perr != nil { + recordError("blockscout", perr) + fmt.Printf("[blockscout] chainscout parse failed: %v\n", perr) + cov.latencyMs = float64(total.Milliseconds()) + return cov + } + cov.registered = len(mainnets) + + type job struct { + chainID int64 + name string + url string + } + jobs := make(chan job) + var mu sync.Mutex + evmLive := map[int64]bool{} + nameLive := map[string]bool{} + var liveCount, deadCount, strictCount int64 + var latAcc int64 + + var wg sync.WaitGroup + for w := 0; w < blockscoutWorkers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := range jobs { + raw, el, err := doCallOnce("blockscout", "GET", j.url+"/api/v2/blocks?type=block", map[string]string{"Accept": "application/json"}, nil) + atomic.AddInt64(&latAcc, el.Milliseconds()) + if err != nil { + // Registry rot or dead instance: expected, this + // IS the measurement. Not error-bucketed. + atomic.AddInt64(&deadCount, 1) + continue + } + ts, perr := parseBlockscoutLatestBlock(raw) + if perr != nil { + atomic.AddInt64(&deadCount, 1) + continue + } + if !freshEnough(ts) { + // Quiet-chain second chance: on-demand block + // producers can be healthy yet silent for hours. + // The instance's own average_block_time decides: + // stale only when the age also exceeds 10x the + // chain's own cadence. + if !blockscoutQuietOK(j.url, ts) { + atomic.AddInt64(&deadCount, 1) + continue + } + } + if freshStrict(ts) { + atomic.AddInt64(&strictCount, 1) + } + atomic.AddInt64(&liveCount, 1) + mu.Lock() + if j.chainID != 0 { + evmLive[j.chainID] = true + } + nameLive[normalizeChainName(j.name)] = true + mu.Unlock() + time.Sleep(200 * time.Millisecond) + } + }() + } + for id, e := range mainnets { + url := pickBlockscoutURL(e) + if url == "" { + continue + } + cid, _ := strconv.ParseInt(id, 10, 64) + jobs <- job{chainID: cid, name: e.Name, url: url} + } + close(jobs) + wg.Wait() + + cov.verified = int(liveCount) + cov.verifiedStrict = int(strictCount) + cov.top50 = top50Count(evmLive, nameLive) + total += time.Duration(latAcc) * time.Millisecond + fmt.Printf("[blockscout] sweep: %d live, %d dead/stale of %d registered mainnets\n", + liveCount, deadCount, len(mainnets)) + cov.latencyMs = float64(total.Milliseconds()) + return cov +} + +// blockscoutQuietOK gives a stale-looking chain a second chance: it +// reads the instance's own average_block_time from /api/v2/stats and +// accepts the chain when the last block's age is under 10x its own +// cadence (an on-demand rollup with hourly blocks is healthy at 60m +// of silence; an Ethereum instance 60m behind is broken). +func blockscoutQuietOK(url string, latest time.Time) bool { + raw, _, err := doCallOnce("blockscout", "GET", url+"/api/v2/stats", map[string]string{"Accept": "application/json"}, nil) + if err != nil { + return false + } + var resp struct { + AverageBlockTime float64 `json:"average_block_time"` + } + if json.Unmarshal(raw, &resp) != nil || resp.AverageBlockTime <= 0 { + return false + } + avg := time.Duration(resp.AverageBlockTime) * time.Millisecond + if resp.AverageBlockTime < 1000 { + // Some instances report seconds, not milliseconds. + avg = time.Duration(resp.AverageBlockTime * float64(time.Second)) + } + return time.Since(latest) <= 10*avg +} + +// pickBlockscoutURL prefers the Blockscout-hosted instance when one +// exists (near-100% uptime stratum), else the first listed explorer. +func pickBlockscoutURL(e chainscoutEntry) string { + for _, x := range e.Explorers { + if x.HostedBy == "blockscout" && x.URL != "" { + return trimSlash(x.URL) + } + } + for _, x := range e.Explorers { + if x.URL != "" { + return trimSlash(x.URL) + } + } + return "" +} + +func trimSlash(s string) string { + for len(s) > 0 && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + return s +} + +// parseChainscout returns the mainnet entries keyed by chain id. +func parseChainscout(raw []byte) (map[string]chainscoutEntry, error) { + var all map[string]chainscoutEntry + if err := json.Unmarshal(raw, &all); err != nil { + return nil, fmt.Errorf("parse chainscout: %w", err) + } + if len(all) == 0 { + return nil, fmt.Errorf("parse chainscout: empty registry") + } + out := map[string]chainscoutEntry{} + for id, e := range all { + if e.IsTestnet { + continue + } + out[id] = e + } + return out, nil +} + +// parseBlockscoutLatestBlock reads items[0].timestamp (ISO 8601). +func parseBlockscoutLatestBlock(raw []byte) (time.Time, error) { + var resp struct { + Items []struct { + Timestamp string `json:"timestamp"` + } `json:"items"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return time.Time{}, fmt.Errorf("parse blocks: %w", err) + } + if len(resp.Items) == 0 || resp.Items[0].Timestamp == "" { + return time.Time{}, fmt.Errorf("parse blocks: no items") + } + t, err := time.Parse(time.RFC3339, resp.Items[0].Timestamp) + if err != nil { + return time.Time{}, fmt.Errorf("parse blocks timestamp: %w", err) + } + return t, nil +} diff --git a/harnesses/explorer-chain-coverage/cmd/script/source_etherscan.go b/harnesses/explorer-chain-coverage/cmd/script/source_etherscan.go new file mode 100644 index 00000000..c08dd4d4 --- /dev/null +++ b/harnesses/explorer-chain-coverage/cmd/script/source_etherscan.go @@ -0,0 +1,202 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// Etherscan API V2 (one key, multichain). +// +// registered: GET api.etherscan.io/v2/chainlist — KEYLESS, returns +// every V2 chain with its own status field. Mainnets +// only (testnets filtered by name). +// verified: per mainnet chain, one keyed call to +// module=block&action=getblocknobytime with a timestamp +// one freshness-window ago and closest=after. The call +// queries their INDEX (unlike module=proxy, which can +// pass through to a node): a block found after that +// timestamp proves the index is fresh. Free key, +// 3 calls/s, 100k/day — a cycle spends ~35 calls. +const etherscanBaseDefault = "https://api.etherscan.io" + +// etherscanMainnets is the pinned allowlist of V2 mainnet chain ids +// (audited 2026-07-07 against the keyless chainlist). Name-based +// testnet filtering is a time bomb: historical Etherscan testnets +// ("Amoy", "Fuji", "Chapel", "Moonbase Alpha") carry none of the +// obvious tokens. Chainlist entries NOT in this set and not name- +// flagged as testnets are EXCLUDED from registered and logged, so a +// new listing never silently moves the number before a human +// classifies it. +var etherscanMainnets = map[int64]bool{ + 1: true, 56: true, 137: true, 8453: true, 42161: true, 59144: true, + 81457: true, 10: true, 43114: true, 199: true, 42220: true, 252: true, + 100: true, 5000: true, 4352: true, 1284: true, 1285: true, 204: true, + 167000: true, 50: true, 33139: true, 480: true, 146: true, 130: true, + 2741: true, 80094: true, 143: true, 999: true, 747474: true, + 737373: true, 1329: true, 988: true, 9745: true, 4326: true, +} + +func probeEtherscan(keyIgnored string) coverage { + key := envDefault("ETHERSCAN_API_KEY", "") + _ = keyIgnored + base := envDefault("ETHERSCAN_BASE_URL", etherscanBaseDefault) + cov := coverage{registered: -1, registeredSource: "registry", verified: -1, verifiedStrict: -1, top50: -1} + var total time.Duration + quotaHit := false + + raw, el, err := doCall("etherscan", "GET", base+"/v2/chainlist", map[string]string{"Accept": "application/json"}, nil) + total += el + if err != nil { + recordError("etherscan", err) + fmt.Printf("[etherscan] chainlist failed: %v\n", err) + cov.latencyMs = float64(total.Milliseconds()) + return cov + } + chains, perr := parseEtherscanChainlist(raw) + if perr != nil { + recordError("etherscan", perr) + fmt.Printf("[etherscan] chainlist parse failed: %v\n", perr) + cov.latencyMs = float64(total.Milliseconds()) + return cov + } + cov.registered = len(chains) + + if key == "" { + // Keyless mode: the chainlist needs no auth, the freshness + // probes do. Publish registered, leave verified unknown. + fmt.Printf("[etherscan] no ETHERSCAN_API_KEY: publishing registered only (%d mainnets)\n", len(chains)) + cov.latencyMs = float64(total.Milliseconds()) + return cov + } + + evmLive := map[int64]bool{} + nameLive := map[string]bool{} + verified := 0 + strict := 0 + anyOK := false + probeWindow := func(chainID int64, window time.Duration) (bool, bool) { + // returns (fresh, definitiveAnswer) + since := time.Now().Add(-window).Unix() + url := fmt.Sprintf("%s/v2/api?chainid=%d&module=block&action=getblocknobytime×tamp=%d&closest=after&apikey=%s", + base, chainID, since, key) + raw, el, err := doCall("etherscan", "GET", url, map[string]string{"Accept": "application/json"}, nil) + total += el + if err != nil { + quotaHit = quotaHit || isQuotaStatus(httpStatus(err)) + recordError("etherscan", err) + return false, false + } + fresh, perr := parseEtherscanBlockNoByTime(raw) + if perr != nil { + if strings.Contains(perr.Error(), "rate limit") { + quotaHit = true + } + recordError("etherscan", perr) + return false, false + } + return fresh, true + } + for _, c := range chains { + time.Sleep(sweepSpacing) + fresh, ok := probeWindow(c.id, freshWindow) + if !ok { + continue + } + anyOK = true + if !fresh { + continue + } + verified++ + evmLive[c.id] = true + nameLive[normalizeChainName(c.name)] = true + time.Sleep(sweepSpacing) + if s, ok2 := probeWindow(c.id, freshWindowStrict); ok2 && s { + strict++ + } + } + + if quotaHit { + fmt.Printf("[etherscan] quota-class failures during cycle, publishing nothing\n") + cov.registered, cov.verified, cov.verifiedStrict, cov.top50 = -1, -1, -1, -1 + } else if anyOK { + cov.verified = verified + cov.verifiedStrict = strict + cov.top50 = top50Count(evmLive, nameLive) + } + cov.latencyMs = float64(total.Milliseconds()) + return cov +} + +type etherscanChain struct { + id int64 + name string +} + +// parseEtherscanChainlist returns V2 mainnet chains (testnets are +// filtered by name: their chainlist has no testnet flag). +func parseEtherscanChainlist(raw []byte) ([]etherscanChain, error) { + var resp struct { + Result []struct { + ChainName string `json:"chainname"` + ChainID json.Number `json:"chainid"` + } `json:"result"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("parse chainlist: %w", err) + } + if len(resp.Result) == 0 { + return nil, fmt.Errorf("parse chainlist: empty result") + } + var out []etherscanChain + unclassified := 0 + for _, c := range resp.Result { + id, err := c.ChainID.Int64() + if err != nil || id == 0 { + continue + } + if etherscanMainnets[id] { + out = append(out, etherscanChain{id: id, name: c.ChainName}) + continue + } + lc := strings.ToLower(c.ChainName) + if strings.Contains(lc, "testnet") || strings.Contains(lc, "sepolia") || + strings.Contains(lc, "holesky") || strings.Contains(lc, "goerli") || + strings.Contains(lc, "hoodi") { + continue // known-testnet by name + } + // Neither allowlisted nor name-flagged: a NEW listing. Never + // silently counted; excluded until a human classifies it. + unclassified++ + fmt.Printf("[etherscan] UNCLASSIFIED chainlist entry %d (%s): excluded until pinned\n", id, c.ChainName) + } + _ = unclassified + return out, nil +} + +// parseEtherscanBlockNoByTime: status "1" with a numeric result means +// a block exists after the probe timestamp (fresh index). status "0" +// with "No record found"-ish messages means stale — returned as +// (false, nil). Rate-limit strings surface as errors. +func parseEtherscanBlockNoByTime(raw []byte) (bool, error) { + var resp struct { + Status string `json:"status"` + Message string `json:"message"` + Result string `json:"result"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return false, fmt.Errorf("parse getblocknobytime: %w", err) + } + if resp.Status == "1" && resp.Result != "" { + return true, nil + } + lr := strings.ToLower(resp.Result + " " + resp.Message) + if strings.Contains(lr, "rate limit") { + return false, fmt.Errorf("etherscan rate limit: %s", resp.Result) + } + if strings.Contains(lr, "invalid api key") || strings.Contains(lr, "missing") { + return false, fmt.Errorf("etherscan auth: %s", resp.Result) + } + return false, nil +} diff --git a/harnesses/explorer-chain-coverage/cmd/script/source_routescan.go b/harnesses/explorer-chain-coverage/cmd/script/source_routescan.go new file mode 100644 index 00000000..7f48543f --- /dev/null +++ b/harnesses/explorer-chain-coverage/cmd/script/source_routescan.go @@ -0,0 +1,141 @@ +package main + +import ( + "encoding/json" + "fmt" + "time" +) + +// Routescan. Fully keyless (2 rps anonymous, headers observed: +// 120/min, 10k/day — a cycle spends ~37 calls). +// +// registered: GET /v2/network/mainnet/evm/all/blockchains — the +// public free-tier chain list. +// verified: per chain GET /v2/network/mainnet/evm/{id}/blocks?limit=1 +// and gate items[0].timestamp on the freshness window. +const routescanBaseDefault = "https://api.routescan.io" + +func probeRoutescan(_ string) coverage { + base := envDefault("ROUTESCAN_BASE_URL", routescanBaseDefault) + cov := coverage{registered: -1, registeredSource: "registry", verified: -1, verifiedStrict: -1, top50: -1} + var total time.Duration + + raw, el, err := doCall("routescan", "GET", base+"/v2/network/mainnet/evm/all/blockchains", map[string]string{"Accept": "application/json"}, nil) + total += el + if err != nil { + recordError("routescan", err) + fmt.Printf("[routescan] blockchains list failed: %v\n", err) + cov.latencyMs = float64(total.Milliseconds()) + return cov + } + chains, perr := parseRoutescanChains(raw) + if perr != nil { + recordError("routescan", perr) + fmt.Printf("[routescan] blockchains parse failed: %v\n", perr) + cov.latencyMs = float64(total.Milliseconds()) + return cov + } + cov.registered = len(chains) + + evmLive := map[int64]bool{} + nameLive := map[string]bool{} + verified := 0 + strict := 0 + anyOK := false + quotaHit := false + for _, c := range chains { + time.Sleep(sweepSpacing) + url := fmt.Sprintf("%s/v2/network/mainnet/evm/%d/blocks?limit=1", base, c.id) + raw, el, err := doCall("routescan", "GET", url, map[string]string{"Accept": "application/json"}, nil) + total += el + if err != nil { + if isQuotaStatus(httpStatus(err)) { + quotaHit = true + recordError("routescan", err) + continue + } + // Per-chain refusal: definitive, the chain is not served. + anyOK = true + continue + } + ts, perr := parseRoutescanLatestBlock(raw) + anyOK = true + if perr == nil && freshEnough(ts) { + verified++ + if freshStrict(ts) { + strict++ + } + evmLive[c.id] = true + nameLive[normalizeChainName(c.name)] = true + } + } + + if quotaHit { + fmt.Printf("[routescan] quota-class failures during cycle, publishing nothing\n") + cov.registered, cov.verified, cov.top50 = -1, -1, -1 + } else if anyOK { + cov.verified = verified + cov.verifiedStrict = strict + cov.top50 = top50Count(evmLive, nameLive) + } + cov.latencyMs = float64(total.Milliseconds()) + return cov +} + +type routescanChain struct { + id int64 + name string +} + +// parseRoutescanChains tolerates both {items:[...]} and a bare array. +func parseRoutescanChains(raw []byte) ([]routescanChain, error) { + type row struct { + ChainID json.Number `json:"chainId"` + Name string `json:"name"` + } + var wrapped struct { + Items []row `json:"items"` + } + rows := wrapped.Items + if err := json.Unmarshal(raw, &wrapped); err != nil || len(wrapped.Items) == 0 { + var arr []row + if err2 := json.Unmarshal(raw, &arr); err2 != nil || len(arr) == 0 { + return nil, fmt.Errorf("parse blockchains: unexpected shape: %s", truncate(string(raw), 120)) + } + rows = arr + } else { + rows = wrapped.Items + } + var out []routescanChain + for _, r := range rows { + id, err := r.ChainID.Int64() + if err != nil || id == 0 { + continue + } + out = append(out, routescanChain{id: id, name: r.Name}) + } + if len(out) == 0 { + return nil, fmt.Errorf("parse blockchains: no usable rows") + } + return out, nil +} + +// parseRoutescanLatestBlock reads items[0].timestamp (ISO 8601). +func parseRoutescanLatestBlock(raw []byte) (time.Time, error) { + var resp struct { + Items []struct { + Timestamp string `json:"timestamp"` + } `json:"items"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return time.Time{}, fmt.Errorf("parse blocks: %w", err) + } + if len(resp.Items) == 0 || resp.Items[0].Timestamp == "" { + return time.Time{}, fmt.Errorf("parse blocks: no items") + } + t, err := time.Parse(time.RFC3339, resp.Items[0].Timestamp) + if err != nil { + return time.Time{}, fmt.Errorf("parse blocks timestamp: %w", err) + } + return t, nil +} diff --git a/harnesses/explorer-chain-coverage/cmd/script/top50.go b/harnesses/explorer-chain-coverage/cmd/script/top50.go new file mode 100644 index 00000000..883f4a4f --- /dev/null +++ b/harnesses/explorer-chain-coverage/cmd/script/top50.go @@ -0,0 +1,110 @@ +package main + +// top50 is the pinned list of the 50 most economically active +// mainnets (blend of DefiLlama TVL, daily fees and tx activity, +// L2Beat cross-checked, snapshot 2026-07-06). It powers the +// anti-inflation column: raw chain counts reward hosting ghost +// rollups, "top-50 covered" answers what integrators actually ask +// ("does it cover MY chains?"). Refresh quarterly by re-pulling +// api.llama.fi/v2/chains; keep 50 entries exactly. +// +// evmID is 0 for non-EVM chains; aliases are normalized (lowercase +// alphanumerics) names used to match non-EVM chains across explorer +// namespaces (Blockchair keys, OKLink shortnames, ...). +type topChain struct { + name string + evmID int64 + aliases []string +} + +var top50 = []topChain{ + {"Ethereum", 1, []string{"ethereum", "eth"}}, + {"Solana", 0, []string{"solana", "sol"}}, + {"BNB Smart Chain", 56, []string{"bnbsmartchain", "bsc", "bnb"}}, + {"Tron", 0, []string{"tron", "trx"}}, + {"Base", 8453, []string{"base"}}, + {"Bitcoin", 0, []string{"bitcoin", "btc"}}, + {"HyperEVM", 999, []string{"hyperevm", "hyperliquid"}}, + {"Arbitrum One", 42161, []string{"arbitrumone", "arbitrum"}}, + {"Polygon PoS", 137, []string{"polygonpos", "polygon", "matic"}}, + {"Plasma", 9745, []string{"plasma"}}, + {"Monad", 143, []string{"monad"}}, + {"Avalanche C-Chain", 43114, []string{"avalanchecchain", "avalanche", "avax"}}, + {"Sui", 0, []string{"sui"}}, + {"OP Mainnet", 10, []string{"opmainnet", "optimism"}}, + {"Cronos", 25, []string{"cronos", "cro"}}, + {"Stellar", 0, []string{"stellar", "xlm"}}, + {"Starknet", 0, []string{"starknet"}}, + {"NEAR", 0, []string{"near"}}, + {"Mantle", 5000, []string{"mantle"}}, + {"Ink", 57073, []string{"ink"}}, + {"Flare", 14, []string{"flare"}}, + {"Aptos", 0, []string{"aptos", "apt"}}, + {"Rootstock", 30, []string{"rootstock", "rsk"}}, + {"Gnosis Chain", 100, []string{"gnosischain", "gnosis", "xdai"}}, + {"dYdX Chain", 0, []string{"dydxchain", "dydx"}}, + {"X Layer", 196, []string{"xlayer", "okb"}}, + {"Cardano", 0, []string{"cardano", "ada"}}, + {"MegaETH", 4326, []string{"megaeth"}}, + {"Stacks", 0, []string{"stacks", "stx"}}, + {"Katana", 747474, []string{"katana"}}, + {"TON", 0, []string{"ton", "theopennetwork"}}, + {"Berachain", 80094, []string{"berachain", "bera"}}, + {"Sei", 1329, []string{"sei"}}, + {"PulseChain", 369, []string{"pulsechain", "pls"}}, + {"Hedera", 295, []string{"hedera", "hbar"}}, + {"XRP Ledger", 0, []string{"xrpledger", "xrpl", "xrp", "ripple"}}, + {"Algorand", 0, []string{"algorand", "algo"}}, + {"World Chain", 480, []string{"worldchain", "world"}}, + {"Linea", 59144, []string{"linea"}}, + {"Unichain", 130, []string{"unichain"}}, + {"Celo", 42220, []string{"celo"}}, + {"Sonic", 146, []string{"sonic"}}, + {"ZKsync Era", 324, []string{"zksyncera", "zksync"}}, + {"Internet Computer", 0, []string{"internetcomputer", "icp"}}, + {"Scroll", 534352, []string{"scroll"}}, + {"Ronin", 2020, []string{"ronin"}}, + {"Injective", 0, []string{"injective", "inj"}}, + {"Filecoin", 314, []string{"filecoin", "fil"}}, + {"Litecoin", 0, []string{"litecoin", "ltc"}}, + {"Dogecoin", 0, []string{"dogecoin", "doge"}}, +} + +// top50Count returns how many top-50 chains are present in the given +// live sets: evmLive keyed by EVM chain id, nameLive keyed by +// normalized chain name/alias. +func top50Count(evmLive map[int64]bool, nameLive map[string]bool) int { + n := 0 + for _, c := range top50 { + if c.evmID != 0 && evmLive[c.evmID] { + n++ + continue + } + hit := false + for _, a := range c.aliases { + if nameLive[a] { + hit = true + break + } + } + if hit { + n++ + } + } + return n +} + +// normalizeChainName lowercases and strips non-alphanumerics, same +// convention as the portfolio harness. +func normalizeChainName(s string) string { + out := make([]rune, 0, len(s)) + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + out = append(out, r) + case r >= 'A' && r <= 'Z': + out = append(out, r+32) + } + } + return string(out) +} diff --git a/harnesses/explorer-chain-coverage/go.mod b/harnesses/explorer-chain-coverage/go.mod new file mode 100644 index 00000000..fc5d88d3 --- /dev/null +++ b/harnesses/explorer-chain-coverage/go.mod @@ -0,0 +1,17 @@ +module explorer-chain-coverage + +go 1.24 + +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.9 // 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.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + golang.org/x/sys v0.22.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/harnesses/explorer-chain-coverage/go.sum b/harnesses/explorer-chain-coverage/go.sum new file mode 100644 index 00000000..d5318cf8 --- /dev/null +++ b/harnesses/explorer-chain-coverage/go.sum @@ -0,0 +1,24 @@ +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/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.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +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/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.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= diff --git a/harnesses/indexing-freshness/Dockerfile b/harnesses/indexing-freshness/Dockerfile new file mode 100644 index 00000000..63108cfc --- /dev/null +++ b/harnesses/indexing-freshness/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/indexing-freshness/cmd/script/config.go b/harnesses/indexing-freshness/cmd/script/config.go new file mode 100644 index 00000000..64679ccf --- /dev/null +++ b/harnesses/indexing-freshness/cmd/script/config.go @@ -0,0 +1,68 @@ +package main + +import ( + "os" + "strconv" + "strings" + "time" +) + +// Provider is one wallet-data API we race. Keys come exclusively from +// env (INDEXING_KEY_); a provider without a key is skipped. +// +// EveryN throttles participation for quota-tight free tiers: the +// provider only joins every Nth probe event. Budget is the monthly +// API-call budget for the guard (90% cutoff, calendar-month reset) — +// derived from each free tier's documented quota with headroom. +type Provider struct { + Slug string + EveryN int + Budget int64 +} + +var providers = []Provider{ + {Slug: "mobula", EveryN: 1, Budget: 250_000}, + {Slug: "zerion", EveryN: 1, Budget: 55_000}, + {Slug: "moralis", EveryN: 1, Budget: 500_000}, + {Slug: "goldrush", EveryN: 1, Budget: 90_000}, + // Allium free tier: 20k calls/month, aggressive per-second limits. + {Slug: "allium", EveryN: 3, Budget: 18_000}, +} + +func keyFor(slug string) string { + return strings.TrimSpace(os.Getenv("INDEXING_KEY_" + strings.ToUpper(slug))) +} + +func activeProviders() []Provider { + var out []Provider + for _, p := range providers { + if keyFor(p.Slug) != "" { + out = append(out, p) + } + } + return out +} + +const chainSlug = "base" + +func rpcHTTP() string { return strings.TrimSpace(os.Getenv("INDEXING_RPC_HTTP")) } +func rpcWSS() string { return strings.TrimSpace(os.Getenv("INDEXING_RPC_WSS")) } + +// eventInterval: one probe event (fresh organic tx picked from a new +// block) per interval. 10 min default → 144 events/day, which keeps +// every provider inside its monthly free quota given the poll schedule. +func eventInterval() time.Duration { + if v := strings.TrimSpace(os.Getenv("INDEXING_EVENT_SECONDS")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 60 { + return time.Duration(n) * time.Second + } + } + return 10 * time.Minute +} + +// pollSchedule: seconds after T0 at which each provider is polled. +// Front-loaded because the interesting race happens in the first +// seconds; capped at 120s after which the event counts as "missed". +// Precision note for the methodology: measured lag is an upper bound +// with resolution equal to the gap between consecutive polls. +var pollSchedule = []int{1, 2, 3, 4, 6, 8, 11, 15, 20, 26, 34, 45, 60, 80, 100, 120} diff --git a/harnesses/indexing-freshness/cmd/script/main.go b/harnesses/indexing-freshness/cmd/script/main.go new file mode 100644 index 00000000..5d2ab929 --- /dev/null +++ b/harnesses/indexing-freshness/cmd/script/main.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" +) + +// indexing-freshness — bench №070. +// +// One probe event per interval: grab a fresh organic native transfer +// from the newest block (T0 = our observation of the block), then poll +// every cohort wallet API on a front-loaded schedule until each one +// returns the tx (lag = poll time − T0) or 120s passes (missed). + +func main() { + fmt.Println("=== Indexing Freshness Harness ===") + fmt.Println("OpenChainBench — organic tx → wallet-API visibility lag.") + if rpcHTTP() == "" { + fmt.Println("[fatal] INDEXING_RPC_HTTP not set") + os.Exit(1) + } + active := activeProviders() + if len(active) == 0 { + fmt.Println("[fatal] no INDEXING_KEY_* env vars set") + os.Exit(1) + } + fmt.Printf("Chain: %s | event interval: %s | poll cap: %ds\n", chainSlug, eventInterval(), pollSchedule[len(pollSchedule)-1]) + for _, p := range active { + fmt.Printf(" - %-10s everyN=%d budget=%d calls/mo\n", p.Slug, p.EveryN, p.Budget) + } + + addr := ":2112" + if v := strings.TrimSpace(os.Getenv("METRICS_ADDR")); v != "" { + addr = v + } + 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) + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go runEvents(ctx, active) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + s := <-sig + fmt.Printf("\n[shutdown] %v\n", s) + cancel() +} + +func runEvents(ctx context.Context, active []Provider) { + t := time.NewTicker(eventInterval()) + defer t.Stop() + var lastSeen uint64 + eventN := 0 + for { + eventN++ + _, bn, tx := waitFreshBlock(lastSeen) + lastSeen = bn + t0 := time.Now() + fmt.Printf("[event %d] block=%d tx=%s wallet=%s\n", eventN, bn, tx.Hash[:14]+"…", tx.From[:10]+"…") + + var wg sync.WaitGroup + for _, p := range active { + if eventN%p.EveryN != 0 { + continue + } + if !quota.allow(p) { + probeTotal.WithLabelValues(p.Slug, chainSlug, "quota_paused").Inc() + fmt.Printf(" %-10s quota guard tripped — paused until month rollover\n", p.Slug) + continue + } + wg.Add(1) + go func(p Provider) { + defer wg.Done() + raceProvider(ctx, p, tx.From, tx.Hash, t0) + }(p) + } + wg.Wait() + + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +func raceProvider(ctx context.Context, p Provider, wallet, txHash string, t0 time.Time) { + var lastErr error + for _, after := range pollSchedule { + wait := time.Until(t0.Add(time.Duration(after) * time.Second)) + if wait > 0 { + select { + case <-ctx.Done(): + return + case <-time.After(wait): + } + } + found, err := checkProvider(p.Slug, wallet, txHash) + if err != nil { + lastErr = err + continue + } + if found { + lag := time.Since(t0).Seconds() + freshnessSeconds.WithLabelValues(p.Slug, chainSlug).Set(lag) + freshnessHist.WithLabelValues(p.Slug, chainSlug).Observe(lag) + probeTotal.WithLabelValues(p.Slug, chainSlug, "found").Inc() + fmt.Printf(" %-10s found in %.1fs\n", p.Slug, lag) + return + } + } + if lastErr != nil { + probeTotal.WithLabelValues(p.Slug, chainSlug, "api_error").Inc() + fmt.Printf(" %-10s api_error: %s\n", p.Slug, sanitize(lastErr)) + return + } + probeTotal.WithLabelValues(p.Slug, chainSlug, "missed").Inc() + fmt.Printf(" %-10s MISSED (not indexed within %ds)\n", p.Slug, pollSchedule[len(pollSchedule)-1]) +} diff --git a/harnesses/indexing-freshness/cmd/script/metrics.go b/harnesses/indexing-freshness/cmd/script/metrics.go new file mode 100644 index 00000000..e2286884 --- /dev/null +++ b/harnesses/indexing-freshness/cmd/script/metrics.go @@ -0,0 +1,62 @@ +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 ( + freshnessSeconds = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "indexing_freshness_seconds", + Help: "Latest observed lag between an organic on-chain tx confirmation and the moment the provider's wallet API first returns it.", + }, + []string{"provider", "chain"}, + ) + + freshnessHist = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "indexing_freshness_seconds_histogram", + Help: "Histogram of wallet-API indexing freshness lags — drives p50/p90 via quantile_over_time.", + Buckets: []float64{1, 2, 3, 4, 6, 8, 11, 15, 20, 26, 34, 45, 60, 80, 100, 120}, + }, + []string{"provider", "chain"}, + ) + + probeTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "indexing_probe_total", + Help: "Probe outcomes per provider: found | missed (not indexed within 120s) | api_error | quota_paused.", + }, + []string{"provider", "chain", "result"}, + ) + + apiCalls = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "indexing_api_calls_total", + Help: "API calls issued per provider (feeds the monthly quota guard).", + }, + []string{"provider"}, + ) + + quotaUsedRatio = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "indexing_quota_used_ratio", + Help: "Fraction of the provider's monthly call budget consumed (probing pauses at 0.90).", + }, + []string{"provider"}, + ) +) + +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + 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/indexing-freshness/cmd/script/pollers.go b/harnesses/indexing-freshness/cmd/script/pollers.go new file mode 100644 index 00000000..a289f431 --- /dev/null +++ b/harnesses/indexing-freshness/cmd/script/pollers.go @@ -0,0 +1,117 @@ +package main + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// Detection is deliberately parser-free: we lowercase the raw response +// body and look for the tx hash substring. Every cohort API returns the +// hash verbatim in its JSON, so this is immune to per-provider schema +// churn and cannot be accused of favouring any response shape. +func bodyContains(resp *http.Response, txHash string) (bool, error) { + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return false, err + } + if resp.StatusCode != 200 { + return false, fmt.Errorf("status %d", resp.StatusCode) + } + return bytes.Contains(bytes.ToLower(raw), []byte(strings.ToLower(txHash))), nil +} + +var httpClient = &http.Client{Timeout: 8 * time.Second} + +// checkProvider asks one provider's wallet API whether it has indexed +// txHash for wallet yet. Returns (found, error). Every call increments +// the quota counter regardless of outcome. +func checkProvider(slug, wallet, txHash string) (bool, error) { + apiCalls.WithLabelValues(slug).Inc() + key := keyFor(slug) + var req *http.Request + var err error + + switch slug { + case "zerion": + u := fmt.Sprintf("https://api.zerion.io/v1/wallets/%s/transactions/?page%%5Bsize%%5D=20&filter%%5Bchain_ids%%5D=%s", wallet, chainSlug) + req, err = http.NewRequest("GET", u, nil) + if err == nil { + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(key+":"))) + req.Header.Set("Accept", "application/json") + } + case "moralis": + u := fmt.Sprintf("https://deep-index.moralis.io/api/v2.2/wallets/%s/history?chain=%s&limit=20", wallet, chainSlug) + req, err = http.NewRequest("GET", u, nil) + if err == nil { + req.Header.Set("X-API-Key", key) + } + case "goldrush": + u := fmt.Sprintf("https://api.covalenthq.com/v1/%s-mainnet/address/%s/transactions_v3/?page-size=20", chainSlug, wallet) + req, err = http.NewRequest("GET", u, nil) + if err == nil { + req.Header.Set("Authorization", "Bearer "+key) + } + case "allium": + body := fmt.Sprintf(`[{"chain":"%s","address":"%s","limit":20}]`, chainSlug, wallet) + req, err = http.NewRequest("POST", "https://api.allium.so/api/v1/developer/wallet/transactions", strings.NewReader(body)) + if err == nil { + req.Header.Set("X-API-KEY", key) + req.Header.Set("Content-Type", "application/json") + } + case "mobula": + u := fmt.Sprintf("https://api.mobula.io/api/1/wallet/transactions?wallet=%s&limit=20", wallet) + req, err = http.NewRequest("GET", u, nil) + if err == nil { + req.Header.Set("Authorization", key) + } + default: + return false, fmt.Errorf("unknown provider %s", slug) + } + if err != nil { + return false, err + } + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + resp, err := httpClient.Do(req) + if err != nil { + return false, err + } + return bodyContains(resp, txHash) +} + +// --------------------------------------------------------------------------- +// Monthly quota guard (same design as rpc-keyed-latency). +// --------------------------------------------------------------------------- + +type quotaGuard struct { + mu sync.Mutex + month string + counts map[string]int64 +} + +var quota = "aGuard{counts: make(map[string]int64)} + +func (q *quotaGuard) allow(p Provider) bool { + q.mu.Lock() + defer q.mu.Unlock() + m := time.Now().UTC().Format("2006-01") + if m != q.month { + q.month = m + q.counts = make(map[string]int64) + } + used := q.counts[p.Slug] + ratio := float64(used) / float64(p.Budget) + quotaUsedRatio.WithLabelValues(p.Slug).Set(ratio) + if ratio >= 0.90 { + return false + } + // Reserve the worst case for one event (full poll schedule). + q.counts[p.Slug] = used + int64(len(pollSchedule)) + return true +} diff --git a/harnesses/indexing-freshness/cmd/script/sampler.go b/harnesses/indexing-freshness/cmd/script/sampler.go new file mode 100644 index 00000000..33a2d2ee --- /dev/null +++ b/harnesses/indexing-freshness/cmd/script/sampler.go @@ -0,0 +1,139 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "strconv" + "strings" + "time" +) + +// The organic sampler. Instead of funding a probe wallet, we pick a +// fresh native transfer from the newest block: real user, real tx, +// different wallet every event — which also makes the ground truth +// impossible for a provider to special-case (there is no benchmark +// wallet to whitelist). +// +// T0 = the instant WE observe the block containing the tx via our own +// RPC. Identical reference for every provider, same host clock. + +type rpcTx struct { + Hash string `json:"hash"` + From string `json:"from"` + To string `json:"to"` + Input string `json:"input"` + Value string `json:"value"` +} + +type rpcBlock struct { + Number string `json:"number"` + Transactions []rpcTx `json:"transactions"` +} + +func rpcCall(method string, params string) (json.RawMessage, error) { + body := fmt.Sprintf(`{"jsonrpc":"2.0","method":"%s","params":%s,"id":%d}`, method, params, time.Now().UnixNano()) + req, _ := http.NewRequest("POST", rpcHTTP(), strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return nil, err + } + var env struct { + Result json.RawMessage `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return nil, err + } + if env.Error != nil { + return nil, fmt.Errorf("rpc: %s", env.Error.Message) + } + return env.Result, nil +} + +func latestBlock() (*rpcBlock, error) { + res, err := rpcCall("eth_getBlockByNumber", `["latest",true]`) + if err != nil { + return nil, err + } + var b rpcBlock + if err := json.Unmarshal(res, &b); err != nil { + return nil, err + } + return &b, nil +} + +// pickNativeTransfer returns a random plain native transfer from the +// block: value > 0, empty calldata, sender is a normal EOA. On OP-stack +// chains transactions[0] is always the L1 system deposit — the +// 0xdeaddead filter drops it. +func pickNativeTransfer(b *rpcBlock) *rpcTx { + var cands []rpcTx + for _, tx := range b.Transactions { + if tx.Input != "0x" || tx.To == "" || strings.EqualFold(tx.From, tx.To) { + continue + } + if strings.HasPrefix(strings.ToLower(tx.From), "0xdeaddead") { + continue + } + v, err := strconv.ParseUint(strings.TrimPrefix(tx.Value, "0x"), 16, 64) + if err != nil || v == 0 { + continue + } + cands = append(cands, tx) + } + if len(cands) == 0 { + return nil + } + tx := cands[rand.Intn(len(cands))] + return &tx +} + +// waitFreshBlock polls the RPC until a block newer than lastSeen with a +// usable native transfer shows up. 500ms cadence bounds the T0 error at +// +500ms, identical for every provider. +func waitFreshBlock(lastSeen uint64) (*rpcBlock, uint64, *rpcTx) { + for { + b, err := latestBlock() + if err != nil { + time.Sleep(2 * time.Second) + continue + } + bn, _ := strconv.ParseUint(strings.TrimPrefix(b.Number, "0x"), 16, 64) + if bn > lastSeen { + if tx := pickNativeTransfer(b); tx != nil { + return b, bn, tx + } + lastSeen = bn + } + time.Sleep(500 * time.Millisecond) + } +} + +// sanitize strips anything URL-ish from provider errors before logging +// (defense in depth — no endpoint carries a key in this harness's URLs +// except mobula/allium headers, but keep the rule uniform). +func sanitize(err error) string { + if err == nil { + return "" + } + s := err.Error() + if i := strings.Index(s, "http"); i >= 0 { + return s[:i] + "" + } + return s +} + +var _ = bytes.MinRead diff --git a/harnesses/indexing-freshness/go.mod b/harnesses/indexing-freshness/go.mod new file mode 100644 index 00000000..55bd09f7 --- /dev/null +++ b/harnesses/indexing-freshness/go.mod @@ -0,0 +1,18 @@ +module indexing-freshness + +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/indexing-freshness/go.sum b/harnesses/indexing-freshness/go.sum new file mode 100644 index 00000000..d6b8ca98 --- /dev/null +++ b/harnesses/indexing-freshness/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/indexing-freshness/railway.toml b/harnesses/indexing-freshness/railway.toml new file mode 100644 index 00000000..2abbb1f0 --- /dev/null +++ b/harnesses/indexing-freshness/railway.toml @@ -0,0 +1,7 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/health" +restartPolicyType = "ON_FAILURE" diff --git a/harnesses/perp-execution-scanner/Dockerfile b/harnesses/perp-execution-scanner/Dockerfile new file mode 100644 index 00000000..4df09f9e --- /dev/null +++ b/harnesses/perp-execution-scanner/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 || true + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/perp-execution-scanner ./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/perp-execution-scanner /app/perp-execution-scanner + +EXPOSE 2112 + +CMD ["/app/perp-execution-scanner"] diff --git a/harnesses/perp-execution-scanner/cmd/script/config.go b/harnesses/perp-execution-scanner/cmd/script/config.go new file mode 100644 index 00000000..9a7f1ed6 --- /dev/null +++ b/harnesses/perp-execution-scanner/cmd/script/config.go @@ -0,0 +1,69 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "time" +) + +// Config holds runtime knobs for the perp-execution-scanner harness. +// +// The scanner tracks a small hard-coded list of (asset, venue) pairs and +// walks the orderbook at every SizeBucket, both sides. Cardinality is +// intentionally low (2 venues x 8 sizes x 2 sides = 32 slippage series +// per asset today) so a 30s tick is comfortable. +type Config struct { + TickInterval time.Duration + ListenAddr string + + // Assets is the list of underlyings to track. Venue slugs are fixed + // (lighter, hyperliquid) and are wired to the corresponding + // fetcher in main.go via a switch. + Assets []AssetConfig + + // SizeBuckets is the set of USD notionals walked on both sides of + // every book each tick. Emitted as the `size_usd` metric label. + SizeBuckets []float64 +} + +// AssetConfig pairs one asset symbol with the per-venue identifiers each +// upstream expects. Lighter uses a numeric market_id, HL HIP-3 uses the +// deployer-namespaced coin symbol like "xyz:BOT". +type AssetConfig struct { + Asset string + LighterMarketID int + HyperliquidCoin string +} + +func loadConfig() *Config { + c := &Config{ + TickInterval: 30 * time.Second, + ListenAddr: ":2112", + Assets: []AssetConfig{ + { + Asset: "BOT", + LighterMarketID: 185, + HyperliquidCoin: "xyz:BOT", + }, + }, + SizeBuckets: []float64{100, 1000, 5000, 10000, 25000, 50000, 100000, 500000}, + } + + if v := os.Getenv("TICK_INTERVAL_SECONDS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.TickInterval = time.Duration(n) * time.Second + } + } + if v := os.Getenv("LISTEN_ADDR"); v != "" { + c.ListenAddr = v + } + + fmt.Printf("Config: assets=%d, sizes=%v, tick=%v, listen=%s\n", + len(c.Assets), c.SizeBuckets, c.TickInterval, c.ListenAddr) + for _, a := range c.Assets { + fmt.Printf(" %s -> lighter market_id=%d, hyperliquid coin=%s\n", + a.Asset, a.LighterMarketID, a.HyperliquidCoin) + } + return c +} diff --git a/harnesses/perp-execution-scanner/cmd/script/loghub.go b/harnesses/perp-execution-scanner/cmd/script/loghub.go new file mode 100644 index 00000000..dc92ef55 --- /dev/null +++ b/harnesses/perp-execution-scanner/cmd/script/loghub.go @@ -0,0 +1,104 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Inline log ring buffer + /logs endpoint. Same pattern used across every +// OCB harness so we can `curl -H X-Logs-Token:$T .../logs?tail=N` on any +// running container without dropping into `docker logs`. + +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 + +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 + }() +} + +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/perp-execution-scanner/cmd/script/main.go b/harnesses/perp-execution-scanner/cmd/script/main.go new file mode 100644 index 00000000..a06f8ab9 --- /dev/null +++ b/harnesses/perp-execution-scanner/cmd/script/main.go @@ -0,0 +1,196 @@ +// perp-execution-scanner is a Prom-exporter harness that publishes live +// per-venue execution quality gauges for the OCB perp-execution-quality +// bench. +// +// Each tick (default 30s) it polls the visible orderbook on every listed +// venue, computes: +// - top-of-book bid / ask / mid / spread bps +// - simulated market-order slippage at every SizeBucket, per side +// - max fillable USD notional on both sides +// +// No trades are placed; everything is derived from public REST endpoints +// (Lighter /orderBookOrders, Hyperliquid POST /info l2Book). No auth. +// +// The exposed HTTP server is fixed at :2112 to match the OCB Railway / +// VPS scrape convention. +package main + +import ( + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +func main() { + installLogCapture() + fmt.Println("=== perp-execution-scanner harness ===") + fmt.Println("Live per-venue market-order slippage for the OCB perp-execution-quality bench.") + fmt.Println("Exposes /metrics, /health, /logs on the configured LISTEN_ADDR (default :2112).") + + cfg := loadConfig() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + wg.Add(1) + go func() { + defer wg.Done() + fmt.Printf("Starting Prometheus metrics server on %s\n", cfg.ListenAddr) + if err := StartMetricsServer(cfg.ListenAddr); err != nil { + fmt.Printf("Metrics server error: %v\n", err) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + runSweepLoop(cfg, stop) + }() + + <-sigChan + fmt.Println("\nShutting down...") + close(stop) + wg.Wait() +} + +func runSweepLoop(cfg *Config, stop <-chan struct{}) { + tick := time.NewTicker(cfg.TickInterval) + defer tick.Stop() + + sweep(cfg) + for { + select { + case <-stop: + return + case <-tick.C: + sweep(cfg) + } + } +} + +// sweep fans out one goroutine per (asset, venue) pair. Slippage compute +// is CPU-cheap so the wait for orderbook JSON dominates. Timeout inside +// each source is 10s, so worst-case tick wall-clock is bounded regardless +// of upstream latency. +func sweep(cfg *Config) { + lastTickGauge.Set(float64(time.Now().Unix())) + + var wg sync.WaitGroup + for _, asset := range cfg.Assets { + asset := asset + + wg.Add(1) + go func() { + defer wg.Done() + start := time.Now() + book, err := FetchLighter(asset.Asset, asset.LighterMarketID) + if err != nil { + fetchErrorsCtr.WithLabelValues(asset.Asset, "lighter", classifyError(err.Error())).Inc() + healthGauge.WithLabelValues(asset.Asset, "lighter").Set(0) + fmt.Printf("[%s][lighter] err: %v\n", asset.Asset, err) + return + } + publish(book, cfg, time.Since(start)) + }() + + wg.Add(1) + go func() { + defer wg.Done() + start := time.Now() + book, err := FetchHyperliquid(asset.Asset, asset.HyperliquidCoin) + if err != nil { + fetchErrorsCtr.WithLabelValues(asset.Asset, "hyperliquid", classifyError(err.Error())).Inc() + healthGauge.WithLabelValues(asset.Asset, "hyperliquid").Set(0) + fmt.Printf("[%s][hyperliquid] err: %v\n", asset.Asset, err) + return + } + publish(book, cfg, time.Since(start)) + }() + } + wg.Wait() +} + +// publish drives every gauge for a single (asset, venue) book. Called +// once the fetch succeeded; failure paths increment fetch_errors and +// flip health to 0 back at the sweep site. +func publish(book *OrderBook, cfg *Config, latency time.Duration) { + asset, venue := book.Asset, book.Venue + + mid := Mid(book) + if mid == 0 { + fetchErrorsCtr.WithLabelValues(asset, venue, "empty_book").Inc() + healthGauge.WithLabelValues(asset, venue).Set(0) + fmt.Printf("[%s][%s] empty book (bids=%d asks=%d)\n", asset, venue, len(book.Bids), len(book.Asks)) + return + } + + topBidGauge.WithLabelValues(asset, venue).Set(book.Bids[0].Price) + topAskGauge.WithLabelValues(asset, venue).Set(book.Asks[0].Price) + spreadGauge.WithLabelValues(asset, venue).Set(SpreadBps(book)) + lastScrapeGauge.WithLabelValues(asset, venue).Set(float64(book.ScrapeTs)) + fetchLatencyGauge.WithLabelValues(asset, venue).Set(float64(latency.Milliseconds())) + healthGauge.WithLabelValues(asset, venue).Set(1) + + // Buy walk: walk asks ascending. Sell walk: walk bids descending. + // The venues return each side already in the right order for us. + maxBuyFill := walkMax(book.Asks) + maxSellFill := walkMax(book.Bids) + maxFillableGauge.WithLabelValues(asset, venue, "buy").Set(maxBuyFill) + maxFillableGauge.WithLabelValues(asset, venue, "sell").Set(maxSellFill) + + // Per size bucket, both sides. When the book doesn't have depth + // for the requested size, we skip the slippage gauge (the API + // contract says NaN would break some downstream consumers; the + // max_fillable gauge above is the caller's signal). + for _, size := range cfg.SizeBuckets { + label := sizeLabel(size) + + buyBps, _, buyOk := WalkOrderbook(book.Asks, size, mid) + if buyOk { + slippageGauge.WithLabelValues(asset, venue, "buy", label).Set(buyBps) + } else { + slippageGauge.DeleteLabelValues(asset, venue, "buy", label) + } + + // Sell side: walking bids descending gives an avg execution + // price BELOW mid, so slippageBps comes back negative. Flip + // the sign so the metric is always "cost to the trader" >= 0 + // in the happy path. + sellBps, _, sellOk := WalkOrderbook(book.Bids, size, mid) + if sellOk { + slippageGauge.WithLabelValues(asset, venue, "sell", label).Set(-sellBps) + } else { + slippageGauge.DeleteLabelValues(asset, venue, "sell", label) + } + } + + // One-line summary at the $10k reference size so the ops loop + // (docker logs / /logs endpoint) shows execution quality live. + refSize := 10000.0 + refLabel := sizeLabel(refSize) + refBuy, _, _ := WalkOrderbook(book.Asks, refSize, mid) + fmt.Printf("[%s][%s] mid=%.4f spread=%.1fbps slip$%s_buy=%.1fbps maxbuy=%.0f maxsell=%.0f lat=%dms\n", + asset, venue, mid, SpreadBps(book), refLabel, refBuy, maxBuyFill, maxSellFill, latency.Milliseconds()) +} + +// walkMax returns the total USD notional visible on the given side of +// the book, capped at 10M so a garbage row can't blow the gauge up. +func walkMax(side []Level) float64 { + var total float64 + for _, l := range side { + if l.Price <= 0 || l.Size <= 0 { + continue + } + total += l.Price * l.Size + if total > 10_000_000 { + return 10_000_000 + } + } + return total +} diff --git a/harnesses/perp-execution-scanner/cmd/script/metrics.go b/harnesses/perp-execution-scanner/cmd/script/metrics.go new file mode 100644 index 00000000..a509648e --- /dev/null +++ b/harnesses/perp-execution-scanner/cmd/script/metrics.go @@ -0,0 +1,152 @@ +package main + +import ( + "net/http" + "strconv" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Every metric label set here is part of the OCB benchmark spec contract. +// Renaming or dropping a label breaks the citable API and every consumer +// SPARQL/PromQL query on the bench page; dual-publish before any cutover. +// +// perp_execution_slippage_bps{asset, venue, side, size_usd} gauge +// perp_execution_spread_bps{asset, venue} gauge +// perp_execution_top_bid_usd{asset, venue} gauge +// perp_execution_top_ask_usd{asset, venue} gauge +// perp_execution_max_fillable_usd{asset, venue, side} gauge +// perp_execution_health{asset, venue} gauge (0|1) +// perp_execution_last_scrape_ts{asset, venue} gauge (unix) +// perp_execution_fetch_latency_ms{asset, venue} gauge +// perp_execution_fetch_errors_total{asset, venue, error_type} counter +var ( + slippageGauge = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_execution_slippage_bps", + Help: "Effective slippage in basis points vs top-of-book mid, walked on the live orderbook for a given USD notional per side.", + }, + []string{"asset", "venue", "side", "size_usd"}, + ) + spreadGauge = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_execution_spread_bps", + Help: "Top-of-book bid/ask spread in basis points, (best_ask-best_bid)/mid.", + }, + []string{"asset", "venue"}, + ) + topBidGauge = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_execution_top_bid_usd", + Help: "Best bid price in USD.", + }, + []string{"asset", "venue"}, + ) + topAskGauge = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_execution_top_ask_usd", + Help: "Best ask price in USD.", + }, + []string{"asset", "venue"}, + ) + maxFillableGauge = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_execution_max_fillable_usd", + Help: "Maximum USD notional fillable by walking the visible book on the given side.", + }, + []string{"asset", "venue", "side"}, + ) + healthGauge = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_execution_health", + Help: "1 when the last scrape succeeded and produced a two-sided book, 0 otherwise.", + }, + []string{"asset", "venue"}, + ) + lastScrapeGauge = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_execution_last_scrape_ts", + Help: "Unix timestamp of the last successful orderbook scrape.", + }, + []string{"asset", "venue"}, + ) + fetchLatencyGauge = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_execution_fetch_latency_ms", + Help: "Wall-clock time of the last successful book fetch, in milliseconds.", + }, + []string{"asset", "venue"}, + ) + fetchErrorsCtr = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "perp_execution_fetch_errors_total", + Help: "Total fetch failures per (asset, venue, error_type).", + }, + []string{"asset", "venue", "error_type"}, + ) + lastTickGauge = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "perp_execution_last_tick_unix", + Help: "Unix timestamp of the last harness sweep; liveness probe.", + }, + ) +) + +func init() { + prometheus.MustRegister( + slippageGauge, spreadGauge, topBidGauge, topAskGauge, + maxFillableGauge, healthGauge, lastScrapeGauge, + fetchLatencyGauge, fetchErrorsCtr, lastTickGauge, + ) +} + +// sizeLabel renders a bucket into the canonical Prom label used across +// the bench spec ("100", "1000", "10000", ...). Kept as a helper so the +// harness and the YAML methodology stay literally byte-for-byte aligned. +func sizeLabel(size float64) string { + return strconv.FormatFloat(size, 'f', -1, 64) +} + +func classifyError(msg string) string { + switch { + case contains(msg, "timeout"), contains(msg, "deadline"): + return "timeout" + case contains(msg, "401"), contains(msg, "403"), contains(msg, "unauthorized"): + return "auth" + case contains(msg, "429"): + return "rate_limit" + case contains(msg, "500"), contains(msg, "502"), contains(msg, "503"), contains(msg, "504"): + return "server_error" + case contains(msg, "404"): + return "not_found" + case contains(msg, "parse"), contains(msg, "bad_shape"): + return "parse" + default: + return "other" + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} + +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) }) + mux.Handle("/logs", logsHandler()) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/perp-execution-scanner/cmd/script/slippage.go b/harnesses/perp-execution-scanner/cmd/script/slippage.go new file mode 100644 index 00000000..b1ed9e8b --- /dev/null +++ b/harnesses/perp-execution-scanner/cmd/script/slippage.go @@ -0,0 +1,72 @@ +package main + +// WalkOrderbook walks a sorted side of an orderbook (asks ascending for a +// buy, bids descending for a sell) until either targetUSD of notional has +// been consumed or the levels are exhausted. +// +// Returns: +// slippageBps: signed effective execution price vs mid, in basis points. +// Positive means the trader paid worse than mid (buy side) +// or received worse than mid (sell side). Callers pass the +// mid used to compute the reference, and normalize sign at +// the record site so the metric is always "cost to trader" +// >= 0 in the happy path. +// filledUSD: how much notional the walk was actually able to consume. +// Always <= targetUSD. +// ok: true when filled >= 99% of targetUSD, i.e. the venue has +// enough depth to fill the size. When false, filledUSD is +// the max_fillable_usd and slippage bps should be ignored. +func WalkOrderbook(orders []Level, targetUSD float64, mid float64) (slippageBps float64, filledUSD float64, ok bool) { + if mid <= 0 || targetUSD <= 0 { + return 0, 0, false + } + var filled, base float64 + for _, o := range orders { + if o.Price <= 0 || o.Size <= 0 { + continue + } + avail := o.Size * o.Price + remaining := targetUSD - filled + take := avail + if take > remaining { + take = remaining + } + filled += take + base += take / o.Price + if filled >= targetUSD { + break + } + } + if base == 0 { + return 0, 0, false + } + avg := filled / base + slippageBps = (avg - mid) / mid * 10000 + return slippageBps, filled, filled >= targetUSD*0.99 +} + +// Mid computes the mid price from the top of book. Returns 0 when either +// side is empty; callers treat that as an unhealthy tick. +func Mid(book *OrderBook) float64 { + if len(book.Bids) == 0 || len(book.Asks) == 0 { + return 0 + } + bb := book.Bids[0].Price + ba := book.Asks[0].Price + if bb <= 0 || ba <= 0 { + return 0 + } + return (bb + ba) / 2 +} + +// SpreadBps is (best_ask - best_bid) / mid, in basis points. Returns 0 +// when the book is one-sided; callers check book health separately. +func SpreadBps(book *OrderBook) float64 { + mid := Mid(book) + if mid == 0 { + return 0 + } + bb := book.Bids[0].Price + ba := book.Asks[0].Price + return (ba - bb) / mid * 10000 +} diff --git a/harnesses/perp-execution-scanner/cmd/script/source_hyperliquid.go b/harnesses/perp-execution-scanner/cmd/script/source_hyperliquid.go new file mode 100644 index 00000000..91062520 --- /dev/null +++ b/harnesses/perp-execution-scanner/cmd/script/source_hyperliquid.go @@ -0,0 +1,86 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// FetchHyperliquid pulls an L2 orderbook snapshot from Hyperliquid's +// public info endpoint for a HIP-3 deployer-scoped coin (e.g. "xyz:BOT"): +// +// POST https://api.hyperliquid.xyz/info +// Content-Type: application/json +// {"type":"l2Book","coin":"xyz:BOT"} +// +// Response shape: `{"coin":"xyz:BOT","time":..., "levels":[[bids...], +// [asks...]]}` with each level as `{"px":"37.5","sz":"1.5","n":1}`. Bids +// come back descending, asks ascending; matches our OrderBook contract. +// +// No auth. Documented rate limit is 1200 requests/minute across all info +// calls; a single POST per tick per asset is trivial. +const hyperliquidInfoURL = "https://api.hyperliquid.xyz/info" + +type hlLevel struct { + Px string `json:"px"` + Sz string `json:"sz"` +} + +type hlL2BookResp struct { + Coin string `json:"coin"` + Time int64 `json:"time"` + Levels [][]hlLevel `json:"levels"` +} + +func FetchHyperliquid(asset string, coin string) (*OrderBook, error) { + body, err := json.Marshal(map[string]any{"type": "l2Book", "coin": coin}) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + client := &http.Client{Timeout: 10 * time.Second} + req, _ := http.NewRequest("POST", hyperliquidInfoURL, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-PerpExecutionScanner/1.0 contact@openchainbench.com") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(raw), 200)) + } + var r hlL2BookResp + if err := json.Unmarshal(raw, &r); err != nil { + return nil, fmt.Errorf("parse: %w", err) + } + if len(r.Levels) < 2 { + return nil, fmt.Errorf("bad_shape: levels len=%d", len(r.Levels)) + } + book := &OrderBook{ + Venue: "hyperliquid", + Asset: asset, + Bids: parseHLSide(r.Levels[0]), + Asks: parseHLSide(r.Levels[1]), + ScrapeTs: time.Now().Unix(), + } + return book, nil +} + +func parseHLSide(rows []hlLevel) []Level { + out := make([]Level, 0, len(rows)) + for _, o := range rows { + px, err1 := strconv.ParseFloat(o.Px, 64) + sz, err2 := strconv.ParseFloat(o.Sz, 64) + if err1 != nil || err2 != nil || px <= 0 || sz <= 0 { + continue + } + out = append(out, Level{Price: px, Size: sz}) + } + return out +} diff --git a/harnesses/perp-execution-scanner/cmd/script/source_lighter.go b/harnesses/perp-execution-scanner/cmd/script/source_lighter.go new file mode 100644 index 00000000..acbfa421 --- /dev/null +++ b/harnesses/perp-execution-scanner/cmd/script/source_lighter.go @@ -0,0 +1,76 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// FetchLighter pulls the top-100 orderbook for a market_id from Lighter's +// public zk-rollup info endpoint: +// +// GET https://mainnet.zklighter.elliot.ai/api/v1/orderBookOrders +// ?market_id={id}&limit=100 +// +// The response is `{"asks":[{price, remaining_base_amount, ...}, ...], +// "bids":[...]}`. Asks come back ascending price, bids descending, which +// matches our OrderBook conventions so no re-sorting is needed. +// +// No auth. Public rate limit ~1s; we tick at 30s so we stay well below. +const lighterOrderBookURL = "https://mainnet.zklighter.elliot.ai/api/v1/orderBookOrders" + +type lighterOrder struct { + Price string `json:"price"` + RemainingBaseAmount string `json:"remaining_base_amount"` +} + +type lighterBookResp struct { + Code int `json:"code"` + Asks []lighterOrder `json:"asks"` + Bids []lighterOrder `json:"bids"` +} + +func FetchLighter(asset string, marketID int) (*OrderBook, error) { + url := fmt.Sprintf("%s?market_id=%d&limit=100", lighterOrderBookURL, marketID) + client := &http.Client{Timeout: 10 * time.Second} + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpExecutionScanner/1.0 contact@openchainbench.com") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + var r lighterBookResp + if err := json.Unmarshal(body, &r); err != nil { + return nil, fmt.Errorf("parse: %w", err) + } + book := &OrderBook{ + Venue: "lighter", + Asset: asset, + Bids: parseLighterSide(r.Bids), + Asks: parseLighterSide(r.Asks), + ScrapeTs: time.Now().Unix(), + } + return book, nil +} + +func parseLighterSide(rows []lighterOrder) []Level { + out := make([]Level, 0, len(rows)) + for _, o := range rows { + px, err1 := strconv.ParseFloat(o.Price, 64) + sz, err2 := strconv.ParseFloat(o.RemainingBaseAmount, 64) + if err1 != nil || err2 != nil || px <= 0 || sz <= 0 { + continue + } + out = append(out, Level{Price: px, Size: sz}) + } + return out +} diff --git a/harnesses/perp-execution-scanner/cmd/script/types.go b/harnesses/perp-execution-scanner/cmd/script/types.go new file mode 100644 index 00000000..5a7faf5f --- /dev/null +++ b/harnesses/perp-execution-scanner/cmd/script/types.go @@ -0,0 +1,20 @@ +package main + +// Level is a single orderbook price level with size in base units. +type Level struct { + Price float64 + Size float64 +} + +// OrderBook is a normalized orderbook snapshot for one (venue, asset) pair. +// +// Bids are sorted descending by price, asks ascending. ScrapeTs is the wall +// clock at fetch return in unix seconds; used both as a freshness signal +// and to publish perp_execution_last_scrape_ts to Prom. +type OrderBook struct { + Venue string + Asset string + Bids []Level + Asks []Level + ScrapeTs int64 +} diff --git a/harnesses/perp-execution-scanner/go.mod b/harnesses/perp-execution-scanner/go.mod new file mode 100644 index 00000000..2e167084 --- /dev/null +++ b/harnesses/perp-execution-scanner/go.mod @@ -0,0 +1,17 @@ +module github.com/mobula/perp-execution-scanner + +go 1.24 + +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.9 // 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.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + golang.org/x/sys v0.22.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/harnesses/perp-execution-scanner/go.sum b/harnesses/perp-execution-scanner/go.sum new file mode 100644 index 00000000..d5318cf8 --- /dev/null +++ b/harnesses/perp-execution-scanner/go.sum @@ -0,0 +1,24 @@ +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/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.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +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/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.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= diff --git a/harnesses/portfolio-chain-coverage/.env.example b/harnesses/portfolio-chain-coverage/.env.example new file mode 100644 index 00000000..12ac345a --- /dev/null +++ b/harnesses/portfolio-chain-coverage/.env.example @@ -0,0 +1,37 @@ +# Provider API keys. Empty = provider skipped gracefully (partial cohort). +COINSTATS_API_KEY= +ZERION_API_KEY= +ZAPPER_API_KEY= +MOBULA_API_KEY= +MORALIS_API_KEY= + +# Hours between probe cycles. Default 24 — probes spend paid API +# credits (~300-340 calls per cycle for the full cohort), never lower +# the default without a credit-budget reason. +# PROBE_INTERVAL_HOURS=24 + +# Optional API host overrides (no rebuild needed). +# COINSTATS_BASE_URL=https://openapiv1.coinstats.app +# ZERION_BASE_URL=https://api.zerion.io +# ZAPPER_BASE_URL=https://public.zapper.xyz/graphql +# MOBULA_BASE_URL=https://api.mobula.io +# MORALIS_BASE_URL=https://deep-index.moralis.io +# MORALIS_SOL_BASE_URL=https://solana-gateway.moralis.io + +# Moralis has no chain-catalog endpoint; its net-worth call takes an +# explicit chain list. Comma-separated hex chain ids, defaults to the +# EVM mainnets on docs.moralis.com/supported-chains. +# MORALIS_CHAINS=0x1,0x89,0x38 + +# Hours between CoinStats full long-tail sweeps (~105 calls, ~4.8k +# credits each). Between sweeps every cycle spends only 2 cheap calls +# and merges cached long-tail results. Default 216 (9 days) fits a +# 20k credits/month plan with ~20% margin. +# COINSTATS_SWEEP_INTERVAL_HOURS=216 + +# Set to 1 to skip the startup probe cycle (deploy-storm days: every +# container restart otherwise burns a full cycle of paid credits). +# SKIP_INITIAL_CYCLE=1 + +# Enables GET /logs?tail=N (header X-Logs-Token). +# LOGS_TOKEN= diff --git a/harnesses/portfolio-chain-coverage/Dockerfile b/harnesses/portfolio-chain-coverage/Dockerfile new file mode 100644 index 00000000..e45ae9db --- /dev/null +++ b/harnesses/portfolio-chain-coverage/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/portfolio-chain-coverage ./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/portfolio-chain-coverage /app/portfolio-chain-coverage + +EXPOSE 2112 + +CMD ["/app/portfolio-chain-coverage"] diff --git a/harnesses/portfolio-chain-coverage/README.md b/harnesses/portfolio-chain-coverage/README.md new file mode 100644 index 00000000..29938281 --- /dev/null +++ b/harnesses/portfolio-chain-coverage/README.md @@ -0,0 +1,93 @@ +# portfolio-chain-coverage + +OpenChainBench harness measuring **Portfolio API Chain Coverage** — for each wallet-portfolio API provider, how many blockchains does the vendor *claim* to support, and on how many does their API *actually return real balances*? + +## What it measures + +Two honest numbers per provider, once per day: + +| Number | Meaning | How | +|---|---|---| +| **listed** | Chains the vendor self-declares via a machine-readable catalog endpoint | One catalog call per provider | +| **verified** | Chains where the vendor's portfolio API returned a real balance for the shared test-address set | 1 EVM sweep call + up to ~63 per-chain probes per provider | + +The gap between listed and verified is the story: a chain in a marketing list is not the same as a chain where the balance indexer actually works. + +## Providers tracked + +| Provider | Auth | listed source | verified probe | +|---|---|---|---| +| CoinStats | `X-API-KEY` header | `GET /wallet/blockchains` (`connectionId` rows) | `GET /wallet/balances?networks=all` (all EVM in one call) + `GET /wallet/balance?connectionId=` for every entry in the shared non-EVM probe set (`addresses.go`, ~138 chains) | +| Zerion | Basic auth (key as username, empty password) | `GET /v1/chains/` (`data[].id`) | `GET /v1/wallets//portfolio?currency=usd` for the shared EVM address + every funded 20-byte 0x probe wallet → merged `positions_distribution_by_chain` (EVM only, 5s spacing, sweep aborts on a second 429) | +| Zapper | `x-zapper-api-key` header | none exists → probe-visible networks, exported with `listed_source="probe"` | `POST /graphql` `portfolioV2 → tokenBalances → byNetwork` (single call covers both numbers) | +| Mobula | `Authorization` header | `GET /api/1/blockchains` | `GET /api/1/wallet/portfolio?wallet=&fetchAllChains=true` for the EVM address + every deduped address in the shared non-EVM probe set, best-effort (4xx tolerated) | +| Moralis | `X-API-Key` header | none exists → chains its net-worth call accepted, exported with `listed_source="probe"` | `GET /api/v2.2/wallets//net-worth?chains[]=…` (candidate list, see `MORALIS_CHAINS`) + Solana gateway `GET /account/mainnet//portfolio` best-effort (4xx tolerated) | + +A provider whose key env var is empty is **skipped gracefully** (logged once) — the harness runs with a partial cohort rather than failing. + +## Fairness rules + +- **Identical test addresses for every provider.** One shared EVM address (`0xF977814e90dA44bFA03b6295A0616a897441aceC`, Binance 8 hot wallet — covers every EVM chain in one sweep) plus a pinned per-chain set of ~138 public high-balance per-chain addresses (exchange cold wallets, protocol treasuries, Cosmos community-pool module accounts — full list with sourcing rules in `cmd/script/addresses.go`). Every provider that accepts an address type gets the identical address. +- **Verified threshold: balance value > $1** per chain — filters dust/spam-token noise while staying far below the real balances these wallets hold. When a response carries no USD pricing at all, native token amount > 0 counts instead. +- **listed vs verified are never mixed.** `listed` is what the vendor declares; `verified` is what the probe observed. Zapper and Moralis have no standalone catalog endpoint, so their listed counts come from the probe and are labeled `listed_source="probe"` (vs `"declared"` for the others) so dashboards can qualify the comparison. +- **Same retry policy for everyone.** Per-call timeout 20s; one retry after 30s on 5xx/timeout only, never on 4xx. +- **Publish-then-leave.** A failed cycle leaves the provider's previous gauge values in place (Prometheus retention carries them forward) and buckets the failure in the error counter; other providers are unaffected. + +## Probe budget + +Probes spend paid API credits, so the cadence is deliberately daily: + +- CoinStats: ~107 calls (1 catalog + 1 EVM sweep + ~105 per-chain probes) +- Zerion: ~55 calls (1 catalog + ~54 wallet sweeps, 5s spacing) +- Zapper: 1 call +- Mobula: ~117 calls (1 catalog + 1 EVM sweep + ~115 probe wallets best-effort) +- Moralis: ~19-37 calls (1 net-worth per candidate chain + native-balance fallbacks + 1 Solana best-effort) + +Total ≈ 300-340 upstream calls/day for the full cohort (~9k/month, far below every vendor's monthly quota). Rate limits bite on bursts, not volume: every multi-chain sweep spaces calls by 1.5s (`sweepSpacing`), so a cycle takes a few minutes and no vendor ever sees more than ~40 requests/minute. `portfolio_probe_calls_total{provider}` counts every attempt (retries included) — watch `increase(...[30d])` against each vendor's quota to catch drift. One full cycle runs at startup, then every `PROBE_INTERVAL_HOURS` (default 24 — **never lower the default**). Providers run sequentially with 5s spacing. + +## Metrics + +| Name | Type | Labels | Help | +|---|---|---|---| +| `portfolio_chains_listed` | gauge | provider, listed_source | Self-declared chain count. `listed_source` is `declared` (catalog endpoint) or `probe` (Zapper, Moralis: probe-visible networks). | +| `portfolio_chains_verified` | gauge | provider | Distinct chains with a real balance (> $1, or native amount > 0 when USD absent) for the canonical test addresses. | +| `portfolio_probe_latency_ms` | gauge | provider | Aggregate HTTP round-trip across all calls of the last probe cycle. | +| `portfolio_probe_errors_total` | counter | provider, kind | Probe failures bucketed as timeout / auth / rate_limit / server_error / not_found / parse / other. | +| `portfolio_last_probe_timestamp` | gauge | provider | Unix time of the last cycle that published at least one value. Staleness alarm for the daily cadence. | +| `portfolio_probe_calls_total` | counter | provider | Upstream HTTP attempts per provider (retries included). Monthly credit-budget watchdog. | +| `portfolio_chains_probed` | gauge | provider | Chains tested with a known-funded address and answered definitively. `verified/probed` = demonstrable indexer success rate. | + +## Env vars + +| Var | Required | Default | Purpose | +|---|---|---|---| +| `COINSTATS_API_KEY` | no* | — | CoinStats key. Empty = provider skipped. | +| `ZERION_API_KEY` | no* | — | Zerion key. Empty = provider skipped. | +| `ZAPPER_API_KEY` | no* | — | Zapper key. Empty = provider skipped. | +| `MOBULA_API_KEY` | no* | — | Mobula key. Empty = provider skipped. | +| `MORALIS_API_KEY` | no* | — | Moralis key. Empty = provider skipped. | +| `PROBE_INTERVAL_HOURS` | no | `24` | Hours between probe cycles. Do not lower without a credit-budget reason. | +| `COINSTATS_BASE_URL` / `ZERION_BASE_URL` / `ZAPPER_BASE_URL` / `MOBULA_BASE_URL` / `MORALIS_BASE_URL` / `MORALIS_SOL_BASE_URL` | no | vendor prod hosts | Override an API host without a rebuild. | +| `MORALIS_CHAINS` | no | EVM mainnets per docs.moralis.com/supported-chains | Comma-separated hex chain ids for the Moralis net-worth candidate list. | +| `LOGS_TOKEN` | no | — | Enables `GET /logs?tail=N` (header `X-Logs-Token`). Unset = endpoint disabled. | + +\* at least one key should be set for the harness to publish anything. + +## Endpoints + +| Path | Purpose | +|---|---| +| `GET /metrics` | Prometheus exposition. Scraped by the OCB Prometheus. | +| `GET /health` | Plain `ok`. Deploy probe. | +| `GET /logs?tail=N` | Ring-buffered stdout, gated by `LOGS_TOKEN`. | +| `GET /` | Banner string. | + +## Development + +```bash +go vet ./... +go build -o /tmp/pcc ./cmd/script +go test ./... +``` + +The response parsers are unit-tested against JSON fixtures in `cmd/script/parse_test.go` — vendor payload shape drift shows up as a `kind="parse"` error bucket in production and as a red test locally. diff --git a/harnesses/portfolio-chain-coverage/cmd/script/addresses.go b/harnesses/portfolio-chain-coverage/cmd/script/addresses.go new file mode 100644 index 00000000..62eb5dad --- /dev/null +++ b/harnesses/portfolio-chain-coverage/cmd/script/addresses.go @@ -0,0 +1,302 @@ +package main + +// chainProbe is one non-EVM probe target: a public high-balance +// address on one chain, identified by the CoinStats connectionId +// (the most granular chain key any cohort vendor exposes). Providers +// that take raw addresses (Mobula) consume the same addresses after +// deduplication, so the shared-address fairness rule holds: every +// provider gets the identical set and the identical $1 threshold. +// +// Address sourcing rule: public, stable, high-balance addresses only. +// exchange cold wallets, protocol treasuries, Cosmos community-pool +// module accounts, or explorer rich-list heads. Each one was +// validated live against the CoinStats balance endpoint on 2026-07-06 +// (every entry returned > $1). A vendor dropping a chain or an +// address draining shows up as a verified-count dip the next cycle, +// never as an error. +// +// EVM MAJORS are not listed here: one shared EVM address covers them +// in a single sweep call per vendor (networks=all / fetchAllChains), +// see evmTestAddress. EVM LONG-TAIL chains where that shared address +// holds no balance DO get their own funded entry below, so the sweep +// blind spot stays testable. The harness reconciles the two paths via +// the vendor's connectionId -> chain map so nothing counts twice. +type chainProbe struct { + // connectionID is the CoinStats chain key for this probe. + connectionID string + // addr is the public test address in the chain's native format. + addr string + // names are normalized aliases of the target chain (lowercase, + // alphanumerics only) used to intersect wallet-sweep misses with + // each vendor's OWN catalog: a funded wallet that returns nothing + // only counts against a vendor when the vendor actually lists the + // wallet's target chain. Without this, small-catalog vendors were + // debited misses on chains they never claimed. + names []string +} + +var chainProbes = []chainProbe{ + // Originals (kept first: they anchor day-one comparability). + {"solana", solTestAddress, []string{"solana"}}, + {"bitcoin", btcTestAddress, []string{"bitcoin", "btc"}}, + + // UTXO / payment chains. explorer rich-list heads, mostly + // exchange cold wallets. + {"doge-wallet", "DE5opaXjFgDhFBqL6tBDxTAQ56zkX6EToX", []string{"doge", "dogecoin"}}, + {"litecoin", "MQd1fJwqBJvwLuyhr17PhEFx1swiqDbPQS", []string{"litecoin"}}, + {"bitcoin_cash", "bitcoincash:qrmfkegyf83zh5kauzwgygf82sdahd5a55x9wse7ve", []string{"bitcoincash"}}, + {"bitcoin_sv", "1A6ud3LrKkPqkwrbGmxu84YTsaJX51SmW", []string{"bitcoinsv"}}, + {"dash", "XnT33zjrFKjt3ymfyQZs2FPiKNer3WVj14", []string{"dash"}}, + {"digibyte", "dgb1qnjf7e2a5ezft480kxzmhgg66pnzqk0aawxa06u", []string{"digibyte"}}, + {"zcash-wallet", "t1RyCw14wRXrh3mp21uxgr9ynjem7cNUkMH", []string{"zcash"}}, + {"kaspa-wallet", "kaspa:qpzpfwcsqsxhxwup26r55fd0ghqlhyugz8cp6y3wxuddc02vcxtjg75pspnwz", []string{"kaspa"}}, + {"ethereum_classic", "0x13CDee29cAd8e11523095900e2195088Ed6d02Ad", []string{"ethereumclassic", "etc"}}, + + // Major L1s. exchange cold wallets, treasuries, rich-list heads. + {"xrp", "rhQADfs6UxfP7iUPwsU7b3uwDVQLLgFcu8", []string{"xrp", "xrpl", "ripple"}}, + {"cardano", "addr1q8elqhkuvtyelgcedpup58r893awhg3l87a4rz5d5acatuj9y84nruafrmta2rewd5l46g8zxy4l49ly8kye79ddr3ksqal35g", []string{"cardano"}}, + {"tron", "TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR", []string{"tron"}}, + {"stellar", "GABFQIK63R2NETJM7T673EAMZN4RJLLGP3OFUEJU5SZVTGWUKULZJNL6", []string{"stellar"}}, + {"polkadot", "13UVJyLnbVp9RBZYFwFGyDvVd1y27Tt8tkntv6Q7JVPhFsTB", []string{"polkadot"}}, + {"kusama-wallet", "F3opxRbN5ZbjJNU511Kj2TLuzFcDq9BGduA9TgiECafpg29", []string{"kusama"}}, + {"near-wallet", "astro-stakers.poolv1.near", []string{"near", "nearprotocol"}}, + {"algorand", "N2C374IRX7HEX2YEQWJBTRSVRHRUV4ZSF76S54WV4COTHRUNYRCI47R3WU", []string{"algorand"}}, + {"tezos", "tz1gNjyzyT8L6WgNS4AdNMppsSFw76J4aDvT", []string{"tezos"}}, + {"vechain", "0xa4aDAfAef9Ec07BC4Dc6De146934C7119341eE25", []string{"vechain"}}, + {"eos", "kjhbgvcfghfd", []string{"eos"}}, + {"waves", "3P31zvGdh6ai6JK6zZ18TjYzJsa1B83YPoj", []string{"waves"}}, + {"ontology", "AFmseVrdL9f9oyCzZefL9tG6UbviEH9ugK", []string{"ontology"}}, + {"neo", "NVg7LjGcUSrgxgjX3zEgqaksfMaiS8Z6e1", []string{"neo"}}, + {"zilliqa", "zil1xq6mh35lgr646hux0ys96q0f0hqv3hex80trpf", []string{"zilliqa"}}, + {"iota", "0xeb8bc8b275fbc66500255f06de458ec5b6623b4171b17d8a26a47604860b3885", []string{"iota"}}, + {"elrond-wallet", "erd1rf4hv70arudgzus0ymnnsnc4pml0jkywg2xjvzslg0mz4nn2tg7q7k0t6p", []string{"elrond", "multiversx", "egld"}}, + {"internet-computer-wallet", "609d3e1e45103a82adc97d4f88c51f78dedb25701e8e51e8c4fec53448aadc29", []string{"internetcomputer", "icp"}}, + {"hedera-wallet", "0.0.2", []string{"hedera", "hederahashgraph"}}, + {"stacks-wallet", "SP2XXSW2KPPTY7KJDYS9RQ868D7JH58QSZKK8KXAV", []string{"stacks"}}, + {"xdc-wallet", "0x377b8ce04761754e8ac153b47805a9cf6b190873", []string{"xdc", "xinfin", "xinfinxdc"}}, + + // Cosmos ecosystem. each chain's distribution/community-pool or + // reserve module account: deterministic, public, only moves via + // governance, the most drain-resistant addresses available. + {"cosmos", "cosmos1jv65s3grqf6v6jl3dp4t6c9t9rk99cd88lyufl", []string{"cosmos"}}, + {"osmosis-wallet", "osmo1jv65s3grqf6v6jl3dp4t6c9t9rk99cd80yhvld", []string{"osmosis"}}, + {"juno-wallet", "juno1jv65s3grqf6v6jl3dp4t6c9t9rk99cd83d88wr", []string{"juno"}}, + {"injective-wallet", "inj1jv65s3grqf6v6jl3dp4t6c9t9rk99cd8dkncm8", []string{"injective"}}, + {"celestia-wallet", "celestia1jv65s3grqf6v6jl3dp4t6c9t9rk99cd8k44vnj", []string{"celestia"}}, + {"sei-wallet", "sei1jv65s3grqf6v6jl3dp4t6c9t9rk99cd82n4207", []string{"sei"}}, + {"kujira-wallet", "kujira1jv65s3grqf6v6jl3dp4t6c9t9rk99cd8khxyy4", []string{"kujira"}}, + {"akash-wallet", "akash1jv65s3grqf6v6jl3dp4t6c9t9rk99cd82yfms9", []string{"akash"}}, + {"dymension-wallet", "dym1jv65s3grqf6v6jl3dp4t6c9t9rk99cd84zg6v3", []string{"dymension"}}, + {"dydx-wallet", "dydx1jv65s3grqf6v6jl3dp4t6c9t9rk99cd8wx2cfg", []string{"dydx"}}, + {"kava-cosmos-wallet", "kava1jv65s3grqf6v6jl3dp4t6c9t9rk99cd8m2splc", []string{"kavacosmos"}}, + {"fetch-wallet", "fetch1jv65s3grqf6v6jl3dp4t6c9t9rk99cd85zdctg", []string{"fetch"}}, + {"axelar-wallet", "axelar1jv65s3grqf6v6jl3dp4t6c9t9rk99cd8r3j5z7", []string{"axelar"}}, + {"stride-wallet", "stride1jv65s3grqf6v6jl3dp4t6c9t9rk99cd8y5yqan", []string{"stride"}}, + {"thorchain-wallet", "thor1dheycdevq39qlkxs2a6wuuzyn4aqxhve4qxtxt", []string{"thorchain"}}, + {"band_protocol", "band1jv65s3grqf6v6jl3dp4t6c9t9rk99cd87sy73h", []string{"bandprotocol"}}, + {"secret-wallet", "secret1jv65s3grqf6v6jl3dp4t6c9t9rk99cd896s45r", []string{"secret"}}, + {"mantra-wallet", "mantra1jv65s3grqf6v6jl3dp4t6c9t9rk99cd8v5wc29", []string{"mantra"}}, + {"terra-wallet", "terra1jv65s3grqf6v6jl3dp4t6c9t9rk99cd8pm7utl", []string{"terraclassic", "terra"}}, + {"terra-wallet-2", "terra1jv65s3grqf6v6jl3dp4t6c9t9rk99cd8pm7utl", []string{"terra2", "terraphoenix"}}, + {"cronos-cosmos-wallet", "cro1jv65s3grqf6v6jl3dp4t6c9t9rk99cd8lyv94w", []string{"cronoscosmos", "cryptoorg", "cronospos"}}, + {"initia-wallet", "init1fl48vsnmsdzcv85q5d2q4z5ajdha8yu3mdfuj4", []string{"initia"}}, + {"babylon-wallet", "bbn1fl48vsnmsdzcv85q5d2q4z5ajdha8yu3z9c7xw", []string{"babylon"}}, + {"zigchain-wallet", "zig1fl48vsnmsdzcv85q5d2q4z5ajdha8yu353vaml", []string{"zigchain"}}, + + // Newer non-EVM L1/L2s. + {"aptos-wallet", "0x6dd484cf0a72f61d2cb7cd0530633f8e1fe05dcb69b4c29dd0257d3d2639377d", []string{"aptos"}}, + {"sui-wallet", "0x15610fa7ee546b96cb580be4060fae1c4bb15eca87f9a0aa931512bad445fc76", []string{"sui"}}, + {"ton-wallet", "EQDKHZ7e70CzqdvZCC83Z4WVR8POC_ZB0J1Y4zo88G-zCXmC", []string{"ton", "theopennetwork"}}, + {"starknet-wallet", "0x01176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", []string{"starknet"}}, + {"eclipse-wallet", "3KK8L7UYd7NV575w9vWR2o1kNqdSFvEPUwcTA5353cax", []string{"eclipse"}}, + {"fio-wallet", "FIO5Y3LfvVz3uoKNFHWwuQ8SoENTHT8ZhruQeqr1nnhYhJX1wqikv", []string{"fio"}}, + {"aleo-wallet", "aleo1tj0598jpstejk8yp7cldez3y4vekmzv482h8l6v59yqsw9kk6cxsc79p0f", []string{"aleo"}}, + {"supra-wallet", "0xd5699357c9e930472375d2709d4a9bae592ce7b0e1a05ba924bbde276f9db3bc", []string{"supra"}}, + {"minima-wallet", "MxG087AH0HPWAYJPQTQGYEMG03F1K2R1H43HVWYH19NB0RTW3SZWY7Q2F79810N", []string{"minima"}}, + {"bittensor-wallet", "5Hd2ze5ug8n1bo3UCAcQsf66VNjKqGos8u6apNfzcU86pg4N", []string{"bittensor", "tao"}}, + {"casper-wallet", "011c74ebfcc1b19bc3e578bec3ecfa2d484f2a00d7e9e8152c4c70f519f6a89f6a", []string{"casper"}}, + {"acala-wallet", "23M5ttkmR6Kco7bReRDve6bQUSAcwqebatp3fWGJYb4hDSDJ", []string{"acala"}}, + + // EVM long-tail: the shared EVM sweep address holds no balance on + // these chains, so each gets its own funded public address + // (explorer rich-list heads, labeled exchange wallets, canonical + // bridge/treasury holders). Validated live on 2026-07-06. + {"celo-wallet", "0xA5c453BC33FD9C5C798Ac24F666fa2B49E0a87fe", []string{"celo"}}, + {"boba-wallet", "0x2d02ce7eF2f359bdcF86E44f66345660725e5CcE", []string{"boba", "bobanetwork"}}, + {"okx-wallet", "0x8F8526dbfd6E38E3D8307702cA8469Bae6C56C15", []string{"okx", "okex", "okexchain", "oktc"}}, + {"harmony-wallet", "0x0D0707963952f2fBA59dD06f2b425ace40b492Fe", []string{"harmony", "harmonyshard0"}}, + {"aurora-wallet", "0xb0bD02F6a392aF548bDf1CfAeE5dFa0EefcC8EaB", []string{"aurora"}}, + {"canto-wallet", "0x0D0707963952f2fBA59dD06f2b425ace40b492Fe", []string{"canto"}}, + {"zkevm-polygon-wallet", "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", []string{"polygonzkevm", "zkevmpolygon"}}, + {"arbitrum-nova-wallet", "0xf89d7b9c864f589bbF53a82105107622B35EaA40", []string{"arbitrumnova"}}, + {"pulsechain-wallet", "0xbE740c0c8b3C13b2B1Af763aC17a83797A948fe4", []string{"pulsechain", "pulse"}}, + {"zora-wallet", "0x82E51a8304156F96C6f01e4aE3C2554D0dE5d156", []string{"zora", "zoranetwork"}}, + {"immutable-wallet", "0xb4C16FdC1963eDD6A91B580d27B520bd20AB85e0", []string{"immutable", "immutablezkevm"}}, + {"rootstock-wallet", "0x0000000000000000000000000000000001000006", []string{"rootstock", "rsk"}}, + {"mode-wallet", "0x82E51a8304156F96C6f01e4aE3C2554D0dE5d156", []string{"mode", "modenetwork"}}, + {"karak-wallet", "0x4200000000000000000000000000000000000016", []string{"karak"}}, + {"ink-wallet", "0x26317C59a67C289D38CC0FE9259d3C2a2784b9D8", []string{"ink", "inkonchain"}}, + {"bob-wallet", "0x4C18e3a2e35Ad4f324ecD34C88074271D0643edf", []string{"bob", "bobnetwork", "buildonbitcoin"}}, + {"taiko-wallet", "0x1670000000000000000000000000000000000001", []string{"taiko"}}, + {"bitlayer-wallet", "0xfF204e2681A6fA0e2C3FaDe68a1B28fb90E4Fc5F", []string{"bitlayer"}}, + {"bsquared-wallet", "0xD0eC0DCCcbe38A5ABFD166d67e30D0880039Aa29", []string{"bsquared", "b2network"}}, + {"ailayer-wallet", "0x80931F1fD3E542A819c91E1696c8662171eA4A5A", []string{"ailayer"}}, + {"soneium-wallet", "0x2F6F07CDcf3588944Bf4C42aC74ff24bF56e7590", []string{"soneium"}}, + {"abstract-wallet", "0xc882b111a75c0c657fc507c04fbfcd2cc984f071", []string{"abstract"}}, + {"unichain-wallet", "0x1F98400000000000000000000000000000000004", []string{"unichain"}}, + {"hyperevm-wallet", "0x2222222222222222222222222222222222222222", []string{"hyperevm", "hyperliquid"}}, + {"zetachain-wallet", "0x4feA76427B8345861e80A3540a8a9D936FD39391", []string{"zetachain"}}, + {"meter-wallet", "0x0d0707963952f2fba59dd06f2b425ace40b492Fe", []string{"meter"}}, + {"zircuit-wallet", "0x4200000000000000000000000000000000000006", []string{"zircuit"}}, + {"story-wallet", "0x91c7FdA5E6b0af14bB007D9F02E4d5E3902CeCc9", []string{"story", "storyprotocol"}}, + {"orderly-wallet", "0x89E2Fa90350DA66dF92c9Fc02Ad33409a1017886", []string{"orderly", "orderlynetwork"}}, + {"monad-wallet", "0x14c25602353402d0be03b386a9aa3f107dd7e34c", []string{"monad"}}, + {"megaeth-wallet", "0xE71CbF47Fff309813bcea54f3ecF49a5F129264D", []string{"megaeth"}}, + {"flare-wallet", "0x67FC6287f627614dc8dB353B331f9740955EC5d2", []string{"flare", "flarenetwork"}}, + {"blast-wallet", "0x1ab4973a48dc892cd9971ece8e01dcc7688f8f23", []string{"blast"}}, + {"kava-wallet", "0x24A4Fbb1fCe9b981cBfCeabD72AA6B2CD3E53CF5", []string{"kava", "kavaevm"}}, + {"beam-wallet", "0x0DC874Fb5260Bd8749e6e98fd95d161b7605774D", []string{"beam"}}, + {"ape-wallet", "0x5228d45b7f99839f3d7087649bb167089a099422", []string{"apechain", "ape"}}, + {"katana-wallet", "0xbE818E593E8B961c466523E8C1B7D3111B87Cca2", []string{"katana"}}, + {"ronin-wallet", "0xb32e9A84Ae0B55b8ab715e4Ac793a61B277bAFA3", []string{"ronin"}}, + + // Mobula / Zerion catalog exotics. These chains are NOT in the + // CoinStats catalog (its sweep skips them via the catalog filter); + // they exist so wallet-sweep vendors get a funded probe on every + // sourceable chain of their OWN lists. Funding verified against + // each chain's explorer/RPC on 2026-07-06. Wrapped-native + // contracts (WETH predeploys, WASTR, WCFX, ...) are deliberate: + // on quiet chains they are the most drift-stable large holders. + {"astar", "0x37795FdD8C165CaB4D6c05771D564d80439CD093", []string{"astar"}}, + {"bittorrent", "0xcBb9EDF6775e39748ea6483a7fa6a385cd7e9a4E", []string{"bittorrent", "bittorrentchain", "bttc"}}, + {"conflux-espace", "0x14b2D3bC65e74DAE1030EAFd8ac30c533c976A9b", []string{"conflux", "confluxespace"}}, + {"kcc", "0x4768B5168a8F2BfDD76dE03fAA834839Ccf75d9f", []string{"kucoin", "kcc"}}, + {"oasis-emerald", "0x21C718C22D52d0F3a789b752D4c2fD5908a8A733", []string{"oasis", "oasisemerald"}}, + {"oasis-sapphire", "0x8Bc2B030b299964eEfb5e1e0b36991352E56D2D3", []string{"oasissapphire"}}, + {"shibarium", "0x0D0707963952f2fBA59dD06f2b425ace40b492Fe", []string{"shibarium"}}, + {"smartbch", "0x3743eC0673453E5009310C727Ba4eaF7b3a1cc04", []string{"smartbch"}}, + {"velas", "0x871ffe7577a567b7be81fe264dd7b592d180235a", []string{"velas"}}, + {"wemix", "0x7D72b22a74A216Af4a002a1095C8C707d6eC1C5f", []string{"wemix"}}, + {"xlayer", "0xe538905cf8410324e03A5A23C1c177a474D59b2b", []string{"xlayer", "okbchain", "okb"}}, + {"alephium", "17R6Ptkz9i1LhiKyMhnitUMkgFygGeeQUFZvRx6GgV8Fc", []string{"alephium"}}, + {"bahamut", "0x0D0707963952f2fBA59dD06f2b425ace40b492Fe", []string{"bahamut"}}, + {"botanix", "0x0D2437F93Fed6EA64Ef01cCde385FB1263910C56", []string{"botanix"}}, + {"dfk", "0xCCb93dABD71c8Dad03Fc4CE5559dC3D89F67a260", []string{"dfk", "dfksubnet", "defikingdoms"}}, + {"graphlinq", "0x0D0707963952f2fBA59dD06f2b425ace40b492Fe", []string{"graphlinq"}}, + {"matchain", "0x0D0707963952f2fBA59dD06f2b425ace40b492Fe", []string{"matchain"}}, + {"shimmer-evm", "0xe93685f3bBA03016F02bD1828BaDD6195988D950", []string{"shimmer", "shimmerevm"}}, + {"vanar", "0x0D0707963952f2fBA59dD06f2b425ace40b492Fe", []string{"vanar"}}, + {"lisk", "0x4200000000000000000000000000000000000006", []string{"lisk"}}, + {"tomochain", "0x7CB30740C7646afAA15295E6F2303e628Dd9e5e5", []string{"tomochain", "viction", "vic"}}, + {"zklink-nova", "0x8280a4e7D5B3B658ec4580d3Bc30f5e50454F169", []string{"zklinknova", "zklink"}}, + {"cyber", "0x4200000000000000000000000000000000000006", []string{"cyber"}}, + {"rari", "0xf70da97812CB96acDF810712Aa562db8dfA3dbEF", []string{"rari", "rarichain"}}, + {"somnia", "0xBe367d410D96E1cAeF68C0632251072CDf1b8250", []string{"somnia"}}, + {"swellchain", "0x4200000000000000000000000000000000000006", []string{"swellchain", "swell"}}, + {"gravity-alpha", "0x5f07826ce32a77E028819E17b7fB274d4B6f31c7", []string{"gravity", "gravityalpha"}}, + {"lens", "0x6bDc36E20D267Ff0dd6097799f82e78907105e2F", []string{"lens"}}, + {"zero-network", "0xAc98B49576B1C892ba6BFae08fE1BB0d80Cf599c", []string{"zero", "zeronetwork"}}, + {"0g", "0x1Cd0690fF9a693f5EF2dD976660a8dAFc81A109c", []string{"0g", "zerogravity"}}, + {"zkcandy", "0x053F171c0D0Cc9d76247D4d1CdDb280bf1131390", []string{"zkcandy"}}, + {"cronos-zkevm", "0xC1bF55EE54E16229d9b369a5502Bfe5fC9F20b6d", []string{"cronoszkevm"}}, + + // Known-failing probes, kept DELIBERATELY: the address holds a + // large balance per the chain's own explorer, yet the vendor's + // probe returns empty. They count in probed but not verified, + // which is exactly the indexer gap this bench exists to surface. + {"degen-wallet", "0xa3491e7361abAA631ab84Ee34d535CD9A0adE66F", []string{"degen", "degenchain"}}, + {"pepecoin-wallet", "PeU3PGXMGcFcteA4NjDcQsTiKQBdn7if84", []string{"pepecoin"}}, +} + +// Vendor-catalog chains with NO probe entry and why (audited +// 2026-07-06, second pass): arthera (explorer + RPC dead), re-al +// (block production halted 2025-06), wonder (domain gone, chain +// defunct), tempo (no native gas token by design), polynomial +// (explorer bot-gated, no reachable RPC), astar-zkevm (sunset +// 2025-03-31), plus Mobula's 8 testnet entries (not probeable with +// mainnet wallets). +// +// Chains with NO probe entry and why (audited 2026-07-06): heco, +// redstone, nillion, duckchain (chains dead or explorer gone), evmos +// x2 (chain ceased operations 2025), celsius (defunct custodian), +// bnb_beacon (chain sunset), liquid (confidential balances, no rich +// list exists), robinhood (no public mainnet explorer yet), +// xrpl-wallet / filecoin-wallet (every candidate address format, +// including the vendor's own 0x form for Filecoin FEVM, got a 400 +// from the probe endpoint; excluded rather than counted as vendor +// failures because the rejection may be on our side). Acala joined +// the set once the ss58 treasury address format proved accepted. + +// evmProbeAddresses returns the deduplicated 20-byte 0x addresses +// from the probe set (EVM long-tail funded wallets). Zerion's wallet +// endpoint only accepts EVM addresses, one per call, so this is the +// slice of the shared set it can fairly receive. 66-char 0x entries +// (Aptos, Sui, Starknet, IOTA, Supra) are excluded: wrong address +// space. +func evmProbeAddresses() []string { + seen := map[string]bool{} + out := []string{} + for _, p := range chainProbes { + a := p.addr + if len(a) != 42 || a[:2] != "0x" || seen[a] { + continue + } + seen[a] = true + out = append(out, a) + } + return out +} + +// normalizeChainName lowercases and strips non-alphanumerics so +// vendor catalog names ("Polygon zkEVM", "polygon-zkevm") and probe +// aliases land in one comparable namespace. +func normalizeChainName(s string) string { + out := make([]rune, 0, len(s)) + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + out = append(out, r) + case r >= 'A' && r <= 'Z': + out = append(out, r+32) + } + } + return string(out) +} + +// probeNamesByAddr maps each probe address to the union of its target +// chains' normalized aliases (one address can back several entries, +// e.g. Terra 1/2 or the Gate.io wallet reused across EVM chains). +func probeNamesByAddr() map[string][]string { + out := map[string][]string{} + for _, p := range chainProbes { + out[p.addr] = append(out[p.addr], p.names...) + } + return out +} + +// anyNameInSet reports whether any alias is present in the vendor's +// normalized catalog set. +func anyNameInSet(set map[string]bool, names []string) bool { + for _, n := range names { + if set[n] { + return true + } + } + return false +} + +// uniqueProbeAddresses returns the deduplicated address list for +// providers that take raw wallet addresses instead of chain keys +// (same address can back several connectionIds, e.g. Terra 1/2). +func uniqueProbeAddresses() []string { + seen := map[string]bool{} + out := make([]string, 0, len(chainProbes)) + for _, p := range chainProbes { + if seen[p.addr] { + continue + } + seen[p.addr] = true + out = append(out, p.addr) + } + return out +} diff --git a/harnesses/portfolio-chain-coverage/cmd/script/config.go b/harnesses/portfolio-chain-coverage/cmd/script/config.go new file mode 100644 index 00000000..e641973f --- /dev/null +++ b/harnesses/portfolio-chain-coverage/cmd/script/config.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// Config holds runtime knobs. Set via env vars on the deploy target. +type Config struct { + // ProbeInterval is how often a full probe cycle runs across the + // provider cohort. Default is 24h and this is DELIBERATE: every + // probe call spends paid API credits on five commercial portfolio + // APIs, and chain-coverage numbers move on a weeks cadence, not + // minutes. One cycle is ~300-340 upstream calls total + // (per-chain sweeps, 1.5s spacing), so keep the cadence daily. Override via + // PROBE_INTERVAL_HOURS, but never ship a lower default. + ProbeInterval time.Duration +} + +func loadConfig() *Config { + c := &Config{ + ProbeInterval: 24 * time.Hour, + } + + if v := os.Getenv("PROBE_INTERVAL_HOURS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.ProbeInterval = time.Duration(n) * time.Hour + } + } + + keysPresent := 0 + for _, p := range Registry { + if strings.TrimSpace(os.Getenv(p.KeyEnv)) != "" { + keysPresent++ + } + } + + fmt.Printf("Config: providers=%d, keys_present=%d, probe_every=%v\n", + len(Registry), keysPresent, c.ProbeInterval) + return c +} + +// envDefault returns the trimmed env var value, or def when unset/empty. +// Same helper shape as rpc-capabilities so base URLs can be swapped +// without a rebuild (useful when a vendor moves its API host). +func envDefault(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} diff --git a/harnesses/portfolio-chain-coverage/cmd/script/http.go b/harnesses/portfolio-chain-coverage/cmd/script/http.go new file mode 100644 index 00000000..74f77a44 --- /dev/null +++ b/harnesses/portfolio-chain-coverage/cmd/script/http.go @@ -0,0 +1,160 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Per-call HTTP timeout. 20s is generous for portfolio APIs that fan +// out to dozens of chain indexers server-side; anything slower is a +// vendor problem worth surfacing as a timeout error. +const httpTimeout = 20 * time.Second + +// retryDelay is the wait before the single allowed retry. 30s gives a +// transient 5xx / edge timeout time to clear without burning credits +// on a hot loop. +const retryDelay = 30 * time.Second + +// sweepSpacing is the pause between two consecutive calls inside a +// provider's multi-chain sweep (CoinStats connectionId probes, Mobula +// wallet probes, Moralis per-chain net-worth). Rate limits bite on +// bursts, not on daily volume, so a fixed 1.5s gap keeps a ~60-call +// sweep well under any per-minute ceiling while adding only ~90s to a +// daily cycle. +const sweepSpacing = 1500 * time.Millisecond + +var httpClient = &http.Client{Timeout: httpTimeout} + +// zerionClient allows 45s: Zerion computes portfolios on demand and a +// cold-cache wallet (first visit of a probe address) regularly needs +// more than the shared 20s. The cost is bounded (max ~50 wallets per +// cycle) and a slow answer beats a recorded timeout for a chain the +// vendor actually covers. +var zerionClient = &http.Client{Timeout: 45 * time.Second} + +func clientFor(provider string) *http.Client { + if provider == "zerion" { + return zerionClient + } + return httpClient +} + +// httpError carries the status code so callers can branch on 4xx +// (e.g. Mobula's optional SOL/BTC probes tolerate a 400). +type httpError struct { + status int + body string +} + +func (e *httpError) Error() string { + return fmt.Sprintf("http %d: %s", e.status, e.body) +} + +// httpStatus returns the HTTP status behind err, or 0 for transport +// level failures (DNS, TLS, timeout). +func httpStatus(err error) int { + if he, ok := err.(*httpError); ok { + return he.status + } + return 0 +} + +// doCall executes one HTTP request with the standard probe semantics: +// per-call 20s timeout, single retry after 30s ONLY on 5xx or +// timeout/transport failure — never on 4xx (a 4xx is deterministic: +// retrying burns credits without changing the answer). Returns the +// response body, the total elapsed wall time across attempts, and an +// error for any non-2xx outcome. Every attempt (including the retry) +// increments portfolio_probe_calls_total{provider} so monthly credit +// consumption per vendor is observable in Prometheus. +func doCall(provider, method, url string, headers map[string]string, body []byte) ([]byte, time.Duration, error) { + countCall(provider) + client := clientFor(provider) + b, elapsed, err := doOnce(client, method, url, headers, body) + if err != nil && retryable(err) { + fmt.Printf(" [retry] %s %s failed (%v), retrying in %v\n", method, url, err, retryDelay) + time.Sleep(retryDelay) + countCall(provider) + b2, elapsed2, err2 := doOnce(client, method, url, headers, body) + return b2, elapsed + elapsed2, err2 + } + return b, elapsed, err +} + +func doOnce(client *http.Client, method, url string, headers map[string]string, body []byte) ([]byte, time.Duration, error) { + var rdr io.Reader + if body != nil { + rdr = bytes.NewReader(body) + } + req, err := http.NewRequest(method, url, rdr) + if err != nil { + return nil, 0, err + } + for k, v := range headers { + req.Header.Set(k, v) + } + if body != nil && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + // Go's default "Go-http-client/2.0" UA gets WAF-throttled by some + // providers (Zerion 429s the portfolio endpoint instantly on it + // while the identical curl request passes). Identify honestly. + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0 (+https://openchainbench.com)") + if req.Header.Get("Accept") == "" { + req.Header.Set("Accept", "application/json") + } + + start := time.Now() + resp, err := client.Do(req) + elapsed := time.Since(start) + if err != nil { + return nil, elapsed, err + } + defer resp.Body.Close() + + // 20MB cap: portfolio responses for a whale wallet can be large, + // but anything bigger than this is a runaway payload. + raw, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20)) + if err != nil { + return nil, elapsed, fmt.Errorf("read body: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return raw, elapsed, &httpError{status: resp.StatusCode, body: truncate(string(raw), 200)} + } + return raw, elapsed, nil +} + +// isQuotaStatus flags statuses that mean "the account, not the +// chain": credit exhaustion, auth, throttling. A probe cycle that +// hits one of these is truncated, not measured — publishing its +// partial counts would clobber good gauges with an artifact (seen +// live twice on 2026-07-06/07 with CoinStats 406 credit limits). +func isQuotaStatus(s int) bool { + return s == 401 || s == 402 || s == 403 || s == 406 || s == 429 +} + +// retryable: only transport/timeout failures and 5xx. 4xx never. +func retryable(err error) bool { + if status := httpStatus(err); status != 0 { + return status >= 500 + } + // Transport-level: DNS, TLS, connection refused, context deadline. + msg := err.Error() + return strings.Contains(msg, "timeout") || + strings.Contains(msg, "deadline") || + strings.Contains(msg, "connection") || + strings.Contains(msg, "EOF") || + strings.Contains(msg, "no such host") +} + +func truncate(s string, n int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/harnesses/portfolio-chain-coverage/cmd/script/loghub.go b/harnesses/portfolio-chain-coverage/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/portfolio-chain-coverage/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/portfolio-chain-coverage/cmd/script/main.go b/harnesses/portfolio-chain-coverage/cmd/script/main.go new file mode 100644 index 00000000..efd1dd29 --- /dev/null +++ b/harnesses/portfolio-chain-coverage/cmd/script/main.go @@ -0,0 +1,162 @@ +// portfolio-chain-coverage is a small Prom-exporter harness that +// measures how many blockchains each wallet-portfolio API vendor +// actually covers — split into two honest numbers per provider: +// +// portfolio_chains_listed{provider, listed_source} self-declared +// portfolio_chains_verified{provider} probe-verified +// +// "listed" is what the vendor claims via a machine-readable catalog +// endpoint; "verified" is the number of chains where their portfolio +// API returned a real balance (> $1) for canonical high-activity test +// addresses shared identically across every provider. The gap between +// the two is the story the bench tells. +// +// Probes run once per PROBE_INTERVAL_HOURS (default 24h — the calls +// spend paid API credits, ~300-340 calls per cycle across the cohort, +// so never lower the default). Gauges are publish-then-leave: a failed +// cycle for one provider carries the previous value forward via Prom +// retention and buckets the failure in portfolio_probe_errors_total. +// +// HTTP server is fixed at :2112 per the OCB harness convention so the +// shared Prometheus scrape target matches every other harness. +package main + +import ( + "fmt" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" +) + +// providerSpacing is the pause between two sequential provider +// probes inside one cycle. Keeps the harness from looking like a +// burst client to any shared upstream edge. +const providerSpacing = 5 * time.Second + +func main() { + installLogCapture() // capture stdout into /logs ring buffer + fmt.Println("=== portfolio-chain-coverage harness ===") + fmt.Println("OpenChainBench - wallet-portfolio API chain coverage: self-declared vs probe-verified.") + fmt.Println("Exposes /metrics on :2112.") + fmt.Println() + + cfg := loadConfig() + for _, p := range Registry { + fmt.Printf(" - %-10s key_env=%s key_set=%v\n", + p.Slug, p.KeyEnv, strings.TrimSpace(os.Getenv(p.KeyEnv)) != "") + } + fmt.Println() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + wg.Add(1) + go func() { + defer wg.Done() + fmt.Println("Starting Prometheus metrics server on :2112") + if err := StartMetricsServer(":2112"); err != nil { + fmt.Printf("Metrics server error: %v\n", err) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + runProbeLoop(cfg, stop) + }() + + <-sigChan + fmt.Println("\nShutting down...") + close(stop) + wg.Wait() +} + +func runProbeLoop(cfg *Config, stop <-chan struct{}) { + tick := time.NewTicker(cfg.ProbeInterval) + defer tick.Stop() + + // SKIP_INITIAL_CYCLE=1 suppresses the startup probe. Set it on + // deploy-storm days: every container restart otherwise runs a + // full cycle, and 7 redeploys in one day burned a month of + // CoinStats credits on 2026-07-07. Steady state leaves it unset + // so a normal deploy refreshes the gauges immediately. + if envDefault("SKIP_INITIAL_CYCLE", "") == "1" { + fmt.Println("[cycle] SKIP_INITIAL_CYCLE=1: waiting for the first tick") + } else { + runCycle() + } + for { + select { + case <-stop: + return + case <-tick.C: + runCycle() + } + } +} + +// skipLogged remembers which keyless providers were already announced +// so the skip line is logged once, not once per day. Only touched from +// the single probe goroutine, so no lock is needed. +var skipLogged = map[string]bool{} + +// runCycle runs one full probe pass: sequential providers with 5s +// spacing. Providers whose key env var is empty are skipped gracefully +// so the harness keeps publishing a partial cohort. +func runCycle() { + fmt.Printf("[cycle] starting probe cycle at %s\n", time.Now().UTC().Format(time.RFC3339)) + first := true + + for _, p := range Registry { + key := strings.TrimSpace(os.Getenv(p.KeyEnv)) + if key == "" { + if !skipLogged[p.Slug] { + fmt.Printf("[cycle] skipping %s: %s is empty (set it to enable this provider)\n", p.Slug, p.KeyEnv) + skipLogged[p.Slug] = true + } + continue + } + + if !first { + time.Sleep(providerSpacing) + } + first = false + + fmt.Printf("[%s] probing...\n", p.Slug) + cov := p.Probe(key) + publish(p.Slug, cov) + } + fmt.Printf("[cycle] probe cycle complete\n") +} + +// publish writes one provider's coverage into the gauges. Fields at +// -1 are unknown this cycle and left untouched (publish-then-leave). +func publish(slug string, cov coverage) { + published := false + if cov.listed >= 0 { + portfolioChainsListed.WithLabelValues(slug, cov.listedSource).Set(float64(cov.listed)) + published = true + } + if cov.verified >= 0 { + portfolioChainsVerified.WithLabelValues(slug).Set(float64(cov.verified)) + published = true + } + if cov.probed >= 0 { + portfolioChainsProbed.WithLabelValues(slug).Set(float64(cov.probed)) + published = true + } + if cov.latencyMs > 0 { + portfolioProbeLatencyMs.WithLabelValues(slug).Set(cov.latencyMs) + } + if published { + portfolioLastProbeTimestamp.WithLabelValues(slug).Set(float64(time.Now().Unix())) + } + fmt.Printf("[%s] listed=%d (source=%s) probed=%d verified=%d latency_ms=%.0f\n", + slug, cov.listed, cov.listedSource, cov.probed, cov.verified, cov.latencyMs) +} diff --git a/harnesses/portfolio-chain-coverage/cmd/script/metrics.go b/harnesses/portfolio-chain-coverage/cmd/script/metrics.go new file mode 100644 index 00000000..d207bb40 --- /dev/null +++ b/harnesses/portfolio-chain-coverage/cmd/script/metrics.go @@ -0,0 +1,131 @@ +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" +) + +// All gauges are keyed by `provider=`. Naming convention is +// `portfolio_` so a reader can tell at a glance the value +// comes from the portfolio-chain-coverage harness. Gauges are +// publish-then-leave: a failed cycle for one provider leaves its +// previous values in place (Prom retention carries them forward) and +// the failure is bucketed in portfolio_probe_errors_total. +var ( + portfolioChainsListed = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "portfolio_chains_listed", + Help: "Number of chains the provider self-declares support for via a machine-readable catalog endpoint. listed_source=declared means a standalone chain-catalog endpoint (CoinStats /wallet/blockchains, Zerion /v1/chains/, Mobula /api/1/blockchains); listed_source=probe means no catalog endpoint exists and the count is the networks visible in the portfolio probe response (Zapper portfolioV2 byNetwork, Moralis net-worth accepted chains).", + }, + []string{"provider", "listed_source"}, + ) + + portfolioChainsVerified = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "portfolio_chains_verified", + Help: "Number of distinct chains where the provider's wallet-portfolio API returned a real balance for the canonical test addresses (Binance 8 EVM hot wallet + fixed SOL + fixed BTC address, identical across providers). A chain counts when its balance value is > $1, or when the native token amount is > 0 and no USD value is present in the response.", + }, + []string{"provider"}, + ) + + portfolioProbeLatencyMs = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "portfolio_probe_latency_ms", + Help: "Aggregate HTTP round-trip in milliseconds across all calls of the provider's last probe cycle (2-3 calls per provider, including the single allowed retry).", + }, + []string{"provider"}, + ) + + portfolioProbeErrors = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "portfolio_probe_errors_total", + Help: "Total probe failures per provider, bucketed by kind: timeout, auth, rate_limit, server_error, not_found, parse, other.", + }, + []string{"provider", "kind"}, + ) + + portfolioLastProbeTimestamp = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "portfolio_last_probe_timestamp", + Help: "Unix timestamp of the last probe cycle that published at least one value for the provider. Liveness/staleness probe for the daily cadence.", + }, + []string{"provider"}, + ) + + portfolioChainsProbed = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "portfolio_chains_probed", + Help: "Number of distinct chains probed with an address known to hold a real balance and answered definitively by the provider's API this cycle. verified/probed = demonstrable indexer success rate. Providers where per-chain funding cannot be established (Zerion, Moralis) report probed = verified.", + }, + []string{"provider"}, + ) + + portfolioProbeCalls = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "portfolio_probe_calls_total", + Help: "Total upstream HTTP calls issued per provider, retries included. Watch increase() over 30d against each vendor's monthly quota to catch credit-budget drift before it rate-limits the harness.", + }, + []string{"provider"}, + ) +) + +// countCall tallies one upstream HTTP attempt for a provider. +func countCall(provider string) { + portfolioProbeCalls.WithLabelValues(provider).Inc() +} + +// classifyError buckets an error string into a small finite enum so +// portfolio_probe_errors_total stays bounded in cardinality. Same +// shape as pm-cohort-stats' classifier so OCB dashboards can reuse +// one template across harnesses. +func classifyError(msg string) string { + switch { + case contains(msg, "timeout"), contains(msg, "deadline"): + return "timeout" + case contains(msg, "401"), contains(msg, "403"), contains(msg, "unauthorized"): + return "auth" + case contains(msg, "429"): + return "rate_limit" + case contains(msg, "500"), contains(msg, "502"), contains(msg, "503"), contains(msg, "504"): + return "server_error" + case contains(msg, "404"): + return "not_found" + case contains(msg, "parse"), contains(msg, "unmarshal"), contains(msg, "unexpected"): + return "parse" + default: + return "other" + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// recordError logs and buckets one probe failure for a provider. +func recordError(provider string, err error) { + kind := classifyError(err.Error()) + portfolioProbeErrors.WithLabelValues(provider, kind).Inc() +} + +// 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("portfolio-chain-coverage harness · OpenChainBench")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/portfolio-chain-coverage/cmd/script/parse_test.go b/harnesses/portfolio-chain-coverage/cmd/script/parse_test.go new file mode 100644 index 00000000..9b51927e --- /dev/null +++ b/harnesses/portfolio-chain-coverage/cmd/script/parse_test.go @@ -0,0 +1,338 @@ +package main + +import ( + "sort" + "testing" +) + +// ─── CoinStats ────────────────────────────────────────────────────── + +func TestParseCoinStatsBlockchains(t *testing.T) { + fixture := `[ + {"name":"Ethereum","connectionId":"ethereum","chain":"ethereum"}, + {"name":"Solana","connectionId":"solana","chain":"solana"}, + {"name":"Bitcoin","connectionId":"bitcoin","chain":"bitcoin"}, + {"name":"Broken row"} + ]` + n, chainOf, err := parseCoinStatsBlockchains([]byte(fixture)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != 3 { + t.Fatalf("listed = %d, want 3 (row without connectionId must not count)", n) + } + if chainOf["solana"] != "solana" { + t.Fatalf("chainOf[solana] = %q, want solana", chainOf["solana"]) + } +} + +func TestParseCoinStatsBlockchainsBadShape(t *testing.T) { + if _, _, err := parseCoinStatsBlockchains([]byte(`{"error":"nope"}`)); err == nil { + t.Fatal("expected error on non-array shape") + } +} + +func TestCountDeduped(t *testing.T) { + sweep := map[string]bool{"celo": true, "arbitrum-one": true} + probes := map[string]bool{ + "celo-wallet": true, // chain key already in sweep -> no double count + "solana": true, // distinct chain + "terra-wallet": true, // shares chain key with terra-wallet-2 but + "terra-wallet-2": true, // distinct networks -> both count + "mystery-wallet": true, // unknown connectionId -> counts as-is + } + chainOf := map[string]string{ + "celo-wallet": "celo", + "solana": "solana", + "terra-wallet": "terra", + "terra-wallet-2": "terra", + } + if got := countDeduped(sweep, probes, chainOf); got != 6 { + t.Fatalf("countDeduped = %d, want 6 (2 sweep + 4 probes, celo deduped)", got) + } +} + +func TestParseCoinStatsMultiBalances(t *testing.T) { + fixture := `[ + {"blockchain":"ethereum","balances":[ + {"coinId":"ethereum","amount":1.5,"price":3000}, + {"coinId":"dust","amount":0.000001,"price":0.01} + ]}, + {"blockchain":"polygon","balances":[ + {"coinId":"spam-token","amount":9999,"price":0.00001} + ]}, + {"blockchain":"nopriced","balances":[ + {"coinId":"weird","amount":42} + ]}, + {"blockchain":"empty","balances":[]} + ]` + chains, err := parseCoinStatsMultiBalances([]byte(fixture)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + sort.Strings(chains) + // ethereum: $4500 > $1 -> verified. + // polygon: $0.09 -> not verified. + // nopriced: no USD anywhere but amount > 0 -> verified. + // empty: nothing -> not verified. + want := []string{"ethereum", "nopriced"} + if len(chains) != len(want) { + t.Fatalf("chains = %v, want %v", chains, want) + } + for i := range want { + if chains[i] != want[i] { + t.Fatalf("chains = %v, want %v", chains, want) + } + } +} + +func TestParseCoinStatsSingleBalance(t *testing.T) { + verified, err := parseCoinStatsSingleBalance([]byte( + `[{"coinId":"bitcoin","amount":248000,"price":100000}]`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !verified { + t.Fatal("high-value balance should verify") + } + + verified, err = parseCoinStatsSingleBalance([]byte( + `[{"coinId":"bitcoin","amount":0.0000001,"price":100000}]`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if verified { + t.Fatal("$0.01 of dust must not verify") + } + + verified, err = parseCoinStatsSingleBalance([]byte(`[]`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if verified { + t.Fatal("empty balance list must not verify") + } +} + +// ─── Zerion ───────────────────────────────────────────────────────── + +func TestParseZerionChains(t *testing.T) { + fixture := `{"links":{"self":"https://api.zerion.io/v1/chains/"},"data":[ + {"type":"chains","id":"ethereum","attributes":{"name":"Ethereum"}}, + {"type":"chains","id":"base","attributes":{"name":"Base"}}, + {"type":"chains","id":"arbitrum","attributes":{"name":"Arbitrum"}} + ]}` + n, ids, err := parseZerionChains([]byte(fixture)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ids["ethereum"] { + t.Fatal("normalized id set must contain ethereum") + } + if n != 3 { + t.Fatalf("listed = %d, want 3", n) + } +} + +func TestParseZerionPortfolio(t *testing.T) { + fixture := `{"data":{"type":"portfolio","id":"perf","attributes":{ + "positions_distribution_by_type":{"wallet":123.0}, + "positions_distribution_by_chain":{ + "ethereum":1250000.55, + "base":42.0, + "polygon":0.20, + "scroll":0 + } + }}}` + chains, err := parseZerionPortfolio([]byte(fixture)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // ethereum + base clear $1; polygon (=$0.20) and scroll (=$0) do not. + if len(chains) != 2 { + t.Fatalf("verified = %v, want [base ethereum]", chains) + } + sort.Strings(chains) + if chains[0] != "base" || chains[1] != "ethereum" { + t.Fatalf("chains = %v, want [base ethereum]", chains) + } +} + +// ─── Zapper ───────────────────────────────────────────────────────── + +func TestParseZapperPortfolio(t *testing.T) { + fixture := `{"data":{"portfolioV2":{"tokenBalances":{"byNetwork":{"edges":[ + {"node":{"network":{"name":"Ethereum","slug":"ethereum","chainId":1},"balanceUSD":345678.12}}, + {"node":{"network":{"name":"Base","slug":"base","chainId":8453},"balanceUSD":12.5}}, + {"node":{"network":{"name":"Degen","slug":"degen","chainId":666666666},"balanceUSD":0.03}}, + {"node":{"network":{"name":"Base","slug":"base","chainId":8453},"balanceUSD":99.0}} + ]}}}}}` + listed, verified, err := parseZapperPortfolio([]byte(fixture)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // 3 distinct networks visible (base deduped), 2 above $1. + if listed != 3 { + t.Fatalf("listed = %d, want 3", listed) + } + if verified != 2 { + t.Fatalf("verified = %d, want 2", verified) + } +} + +func TestParseZapperPortfolioGraphQLError(t *testing.T) { + fixture := `{"errors":[{"message":"Cannot query field \"bogus\" on type \"PortfolioV2\""}],"data":null}` + if _, _, err := parseZapperPortfolio([]byte(fixture)); err == nil { + t.Fatal("expected error when GraphQL returns an errors array") + } +} + +// ─── Mobula ───────────────────────────────────────────────────────── + +func TestParseMobulaBlockchainsWrapped(t *testing.T) { + fixture := `{"data":[{"name":"Ethereum"},{"name":"Base"},{"name":"Solana"},{"name":"Bitcoin"}]}` + n, _, err := parseMobulaBlockchains([]byte(fixture)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != 4 { + t.Fatalf("listed = %d, want 4", n) + } +} + +func TestParseMobulaBlockchainsBareArray(t *testing.T) { + fixture := `[{"name":"Ethereum"},{"name":"Base"}]` + n, rows, err := parseMobulaBlockchains([]byte(fixture)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != 2 { + t.Fatalf("listed = %d, want 2", n) + } + if len(rows) != 2 || rows[0].Name != "Ethereum" { + t.Fatalf("rows = %v, want raw catalog names", rows) + } +} + +func TestParseMobulaBlockchainsBadShape(t *testing.T) { + if _, _, err := parseMobulaBlockchains([]byte(`"nope"`)); err == nil { + t.Fatal("expected error on unexpected shape") + } +} + +func TestParseMobulaPortfolio(t *testing.T) { + fixture := `{"data":{"total_wallet_balance":123456.78,"assets":[ + {"asset":{"symbol":"ETH"},"price":3000, + "cross_chain_balances":{ + "Ethereum":{"balance":10.5,"chainId":"evm:1"}, + "Base":{"balance":0.0001,"chainId":"evm:8453"}, + "Arbitrum":{"balance":2.0,"balanceUSD":6000,"chainId":"evm:42161"} + }}, + {"asset":{"symbol":"OBSCURE"},"price":0, + "cross_chain_balances":{ + "WeirdChain":{"balance":5.0} + }}, + {"asset":{"symbol":"USDC"},"price":1, + "cross_chain_balances":{ + "Base":{"balance":250.0,"chainId":"evm:8453"} + }} + ]}}` + chains, err := parseMobulaPortfolio([]byte(fixture)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + sort.Strings(chains) + // Ethereum: 10.5*3000 > $1. Base: dust on ETH but $250 USDC on the + // later asset -> verified. Arbitrum: explicit balanceUSD. WeirdChain: + // no pricing anywhere + amount > 0 -> verified via native fallback. + want := []string{"Arbitrum", "Base", "Ethereum", "WeirdChain"} + if len(chains) != len(want) { + t.Fatalf("chains = %v, want %v", chains, want) + } + for i := range want { + if chains[i] != want[i] { + t.Fatalf("chains = %v, want %v", chains, want) + } + } +} + +// ─── Moralis ──────────────────────────────────────────────────────── + +func TestParseMoralisNetWorth(t *testing.T) { + fixture := `{"total_networth_usd":"4507.10","chains":[ + {"chain":"eth","networth_usd":"4500.00"}, + {"chain":"polygon","networth_usd":"0.09"}, + {"chain":"base","networth_usd":"7.01"}, + {"chain":"eth","networth_usd":"4500.00"}, + {"chain":"cronos","networth_usd":"not-a-number"} + ],"unsupported_chain_ids":["0xdead"],"unavailable_chains":[]}` + listed, verified, err := parseMoralisNetWorth([]byte(fixture)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // eth deduped; unsupported candidates never echo back into chains. + if listed != 4 { + t.Fatalf("listed = %d, want 4", listed) + } + // eth + base clear $1; polygon is dust; cronos value unparseable. + if verified != 2 { + t.Fatalf("verified = %d, want 2", verified) + } +} + +func TestParseMoralisNetWorthBadShape(t *testing.T) { + if _, _, err := parseMoralisNetWorth([]byte(`{"message":"invalid key"}`)); err == nil { + t.Fatal("expected error on missing chains array") + } +} + +func TestParseMoralisSolPortfolio(t *testing.T) { + ok, err := parseMoralisSolPortfolio([]byte( + `{"nativeBalance":{"solana":"12.5","lamports":"12500000000"},"tokens":[]}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("native amount > 0 should verify") + } + + ok, err = parseMoralisSolPortfolio([]byte( + `{"nativeBalance":{"solana":"0"},"tokens":[{"amount":"250.0"}]}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("token amount > 0 should verify") + } + + ok, err = parseMoralisSolPortfolio([]byte( + `{"nativeBalance":{"solana":"0"},"tokens":[]}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("empty portfolio must not verify") + } +} + +func TestParseMoralisNativeBalance(t *testing.T) { + ok, err := parseMoralisNativeBalance([]byte(`{"balance":"141326813691128959308262"}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("positive wei balance should verify") + } + + ok, err = parseMoralisNativeBalance([]byte(`{"balance":"0"}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("zero balance must not verify") + } + + if _, err := parseMoralisNativeBalance([]byte(`{"message":"nope"}`)); err == nil { + t.Fatal("expected error on missing balance field") + } +} diff --git a/harnesses/portfolio-chain-coverage/cmd/script/registry.go b/harnesses/portfolio-chain-coverage/cmd/script/registry.go new file mode 100644 index 00000000..31e11243 --- /dev/null +++ b/harnesses/portfolio-chain-coverage/cmd/script/registry.go @@ -0,0 +1,87 @@ +package main + +// Provider is one wallet-portfolio API vendor measured by this harness. +// The Slug field MUST match the OCB site's provider registry so the +// Prom selector `{provider=""}` matches what the bench page +// reads. +// +// KeyEnv is the env var holding the vendor API key. When the env var +// is EMPTY the provider is SKIPPED gracefully (logged once) so the +// harness keeps running with a partial cohort — a missing key must +// never take the whole exporter down or zero out other providers. +// +// Probe runs one full daily measurement for the provider and returns +// a coverage struct. Fields set to -1 mean "unknown this cycle, do +// not publish" so the previous gauge value carries forward via Prom +// retention (publish-then-leave, same convention as pm-cohort-stats). +type Provider struct { + Slug string + Name string + KeyEnv string + Probe func(key string) coverage +} + +// coverage is the outcome of one provider probe cycle. +type coverage struct { + // listed is the number of chains the vendor self-declares support + // for via a machine-readable catalog endpoint. -1 = unknown. + listed int + + // listedSource records where the listed count comes from: + // "declared" — a standalone chain-catalog endpoint + // "probe" — no catalog endpoint exists; listed = networks + // visible in the portfolio probe response (Zapper) + listedSource string + + // verified is the number of distinct chains where the vendor's + // portfolio API returned a real balance (> $1, or native amount + // > 0 when no USD value is present) for the canonical test + // addresses. -1 = unknown. + verified int + + // probed is the number of distinct chains where the probe got a + // definitive answer from the API using an address KNOWN to hold a + // real balance there (funding validated when the address was + // pinned). verified/probed is therefore the indexer's demonstrable + // success rate; listed minus probed is the untestable residue (no + // funded public address available). Providers where funding + // cannot be established per chain (Zerion single-call EVM, + // Moralis candidates) report probed = verified, a conservative + // floor. -1 = unknown. + probed int + + // latencyMs is the aggregate HTTP round-trip across every call + // made during the probe (including the one allowed retry). + latencyMs float64 +} + +// Canonical test addresses, identical for every provider so the +// comparison is fair. High-activity, well-known public wallets that +// hold balances on many chains: +// +// EVM: Binance 8 hot wallet — holds > $1 on virtually every EVM +// chain a portfolio API can index. +// SOL: high-balance Solana wallet. +// BTC: high-balance Bitcoin P2SH address. +const ( + evmTestAddress = "0xF977814e90dA44bFA03b6295A0616a897441aceC" + solTestAddress = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM" + btcTestAddress = "34xp4vRoCGJym3xR7yCVPFHoCNxv4Twseo" +) + +// verifiedUsdThreshold is the USD floor above which a chain counts as +// probe-verified. $1 filters out dust/spam-token noise while staying +// far below the real balances the test wallets hold. +const verifiedUsdThreshold = 1.0 + +// Registry is the canonical list of measured portfolio API providers. +// Order = probe order (sequential, 5s spacing between providers). +// Adding a provider: append here, add its source_.go fetcher, +// append on the OCB site, redeploy both. +var Registry = []Provider{ + {Slug: "coinstats", Name: "CoinStats", KeyEnv: "COINSTATS_API_KEY", Probe: probeCoinStats}, + {Slug: "zerion", Name: "Zerion", KeyEnv: "ZERION_API_KEY", Probe: probeZerion}, + {Slug: "zapper", Name: "Zapper", KeyEnv: "ZAPPER_API_KEY", Probe: probeZapper}, + {Slug: "mobula", Name: "Mobula", KeyEnv: "MOBULA_API_KEY", Probe: probeMobula}, + {Slug: "moralis", Name: "Moralis", KeyEnv: "MORALIS_API_KEY", Probe: probeMoralis}, +} diff --git a/harnesses/portfolio-chain-coverage/cmd/script/source_coinstats.go b/harnesses/portfolio-chain-coverage/cmd/script/source_coinstats.go new file mode 100644 index 00000000..0c933218 --- /dev/null +++ b/harnesses/portfolio-chain-coverage/cmd/script/source_coinstats.go @@ -0,0 +1,290 @@ +package main + +import ( + "encoding/json" + "fmt" + "strconv" + "time" +) + +// CoinStats openapiv1. Auth is the raw key in the X-API-KEY header. +// +// listed: GET /wallet/blockchains — machine-readable catalog; one +// row per supported connection, identified by connectionId. +// verified: 3 calls per cycle — +// GET /wallet/balances?address=&networks=all covers +// every EVM chain in one shot (response is an array of +// {blockchain, balances[]} groups), plus one +// GET /wallet/balance?...&connectionId=solana and one +// ...&connectionId=bitcoin for the non-EVM chains. +const coinstatsBaseDefault = "https://openapiv1.coinstats.app" + +// CoinStats budget mode. A full long-tail sweep costs ~105 calls at +// ~45 credits each (~4.8k credits, measured 2026-07-07); a 20k/month +// plan affords 3-4 of them. The sweep therefore reruns only every +// COINSTATS_SWEEP_INTERVAL_HOURS (default 216h = 9 days, ~14.5k +// credits/month) while every regular cycle spends just 2 cheap calls +// (catalog + EVM networks=all) and merges the cached long-tail +// results, so published verified/probed stay complete and fresh at +// every cycle for ~1.4k credits/month more. A restart empties the +// cache and the next cycle runs a full sweep. +var coinstatsSweepCache struct { + when time.Time + verifiedProbe map[string]bool + answeredProbe map[string]bool +} + +func coinstatsSweepInterval() time.Duration { + if v := envDefault("COINSTATS_SWEEP_INTERVAL_HOURS", ""); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return time.Duration(n) * time.Hour + } + } + return 216 * time.Hour +} + +func probeCoinStats(key string) coverage { + base := envDefault("COINSTATS_BASE_URL", coinstatsBaseDefault) + hdr := map[string]string{"X-API-KEY": key, "Accept": "application/json"} + cov := coverage{listed: -1, listedSource: "declared", verified: -1, probed: -1} + var total time.Duration + quotaHit := false + + // --- listed: self-declared chain catalog ----------------------- + raw, el, err := doCall("coinstats", "GET", base+"/wallet/blockchains", hdr, nil) + total += el + chainOf := map[string]string{} + if err != nil { + quotaHit = quotaHit || isQuotaStatus(httpStatus(err)) + recordError("coinstats", err) + fmt.Printf("[coinstats] blockchains catalog failed: %v\n", err) + } else if n, m, perr := parseCoinStatsBlockchains(raw); perr != nil { + recordError("coinstats", perr) + fmt.Printf("[coinstats] blockchains parse failed: %v\n", perr) + } else { + cov.listed = n + chainOf = m + } + + // --- verified: balance probes ----------------------------------- + // Two key namespaces are in play: the EVM sweep returns catalog + // chain keys (arbitrum-one, binance_smart, ...) while per-chain + // probes are keyed by connectionId (arbitrum-wallet, ...). The + // catalog's connectionId -> chain map bridges them so a chain + // verified by both paths never counts twice. connectionIds that + // share a chain key (terra-wallet / terra-wallet-2) stay distinct: + // they are different networks. + verifiedSweep := map[string]bool{} // catalog chain keys + verifiedProbe := map[string]bool{} // connectionIds + answeredProbe := map[string]bool{} // connectionIds with a definitive reply + anyProbeOK := false + + // All EVM chains in one call. + url := fmt.Sprintf("%s/wallet/balances?address=%s&networks=all", base, evmTestAddress) + raw, el, err = doCall("coinstats", "GET", url, hdr, nil) + total += el + if err != nil { + quotaHit = quotaHit || isQuotaStatus(httpStatus(err)) + recordError("coinstats", err) + fmt.Printf("[coinstats] EVM balances probe failed: %v\n", err) + } else if chains, perr := parseCoinStatsMultiBalances(raw); perr != nil { + recordError("coinstats", perr) + fmt.Printf("[coinstats] EVM balances parse failed: %v\n", perr) + } else { + for _, c := range chains { + verifiedSweep[c] = true + } + anyProbeOK = true + } + + // Non-EVM: one call per connectionId over the shared probe set + // (see addresses.go), spaced so ~60 quick GETs never read as a + // burst. A 4xx on one chain is an expected answer (connectionId + // dropped from the catalog, address format rejected), not a + // provider fault, so it is logged but never error-bucketed. + // Budget mode: the sweep only reruns when the cache aged out; + // otherwise the previous sweep's results are merged below. + sweepDue := coinstatsSweepCache.verifiedProbe == nil || + time.Since(coinstatsSweepCache.when) >= coinstatsSweepInterval() + if !sweepDue { + for k, v := range coinstatsSweepCache.verifiedProbe { + verifiedProbe[k] = v + } + for k, v := range coinstatsSweepCache.answeredProbe { + answeredProbe[k] = v + } + fmt.Printf("[coinstats] long-tail sweep cached (age %s, next in %s), 2-call pulse cycle\n", + time.Since(coinstatsSweepCache.when).Round(time.Hour), + (coinstatsSweepInterval() - time.Since(coinstatsSweepCache.when)).Round(time.Hour)) + } + for _, probe := range chainProbes { + if !sweepDue { + break + } + if len(chainOf) > 0 { + if _, inCatalog := chainOf[probe.connectionID]; !inCatalog { + // The vendor does not list this chain: nothing to + // test, no call to spend, no miss to debit. + continue + } + } + time.Sleep(sweepSpacing) + url := fmt.Sprintf("%s/wallet/balance?address=%s&connectionId=%s", base, probe.addr, probe.connectionID) + raw, el, err := doCall("coinstats", "GET", url, hdr, nil) + total += el + if err != nil { + if status := httpStatus(err); status == 400 || status == 404 { + // Deterministic per-chain refusal of a funded + // address: probed-but-not-verified, an honest + // indexer gap. ONLY 400/404 qualify — quota, auth + // and throttle failures (401/402/403/406/429) say + // nothing about the chain and must never turn into + // a published zero (seen live: a 406 credit-limit + // day published verified=0 and wiped the gauges). + fmt.Printf("[coinstats] %s probe rejected (http %d), skipping\n", probe.connectionID, status) + answeredProbe[probe.connectionID] = true + continue + } + quotaHit = quotaHit || isQuotaStatus(httpStatus(err)) + recordError("coinstats", err) + fmt.Printf("[coinstats] %s balance probe failed: %v\n", probe.connectionID, err) + continue + } + ok, perr := parseCoinStatsSingleBalance(raw) + if perr != nil { + recordError("coinstats", perr) + fmt.Printf("[coinstats] %s balance parse failed: %v\n", probe.connectionID, perr) + continue + } + answeredProbe[probe.connectionID] = true + if ok { + verifiedProbe[probe.connectionID] = true + } + anyProbeOK = true + } + + if quotaHit { + // Truncated by credits/auth/throttle: the counts are an + // artifact of the outage, not a measurement. Publish nothing + // and let publish-then-leave carry the previous cycle. + fmt.Printf("[coinstats] quota-class failures during cycle, publishing nothing\n") + cov.listed, cov.verified, cov.probed = -1, -1, -1 + } else if anyProbeOK { + if sweepDue { + coinstatsSweepCache.when = time.Now() + coinstatsSweepCache.verifiedProbe = verifiedProbe + coinstatsSweepCache.answeredProbe = answeredProbe + } + cov.verified = countDeduped(verifiedSweep, verifiedProbe, chainOf) + cov.probed = countDeduped(verifiedSweep, answeredProbe, chainOf) + } + cov.latencyMs = float64(total.Milliseconds()) + return cov +} + +// coinStatsBalanceRow is one coin balance row. CoinStats does not +// document whether `price` is always present, so USD valuation falls +// back to native-amount-only semantics when it is absent. +type coinStatsBalanceRow struct { + CoinID string `json:"coinId"` + Amount float64 `json:"amount"` + Price float64 `json:"price"` +} + +// usd returns the row's USD value, or 0 when no price is present. +func (r coinStatsBalanceRow) usd() float64 { return r.Amount * r.Price } + +// parseCoinStatsBlockchains counts catalog rows carrying a non-empty +// connectionId (the vendor's stable chain identifier) and returns the +// connectionId -> chain-key map used to reconcile per-connectionId +// probes with the EVM sweep's chain keys. +func parseCoinStatsBlockchains(raw []byte) (int, map[string]string, error) { + var rows []struct { + ConnectionID string `json:"connectionId"` + Chain string `json:"chain"` + } + if err := json.Unmarshal(raw, &rows); err != nil { + return 0, nil, fmt.Errorf("parse blockchains: %w", err) + } + n := 0 + chainOf := map[string]string{} + for _, r := range rows { + if r.ConnectionID != "" { + n++ + if r.Chain != "" { + chainOf[r.ConnectionID] = r.Chain + } + } + } + return n, chainOf, nil +} + +// countDeduped merges the EVM sweep (chain keys) with per-connectionId +// probe results: a probe only adds to the count when its chain key is +// not already covered by the sweep. Unknown connectionIds fall back to +// counting as-is (worst case: one duplicate during a catalog outage). +func countDeduped(sweep, probes map[string]bool, chainOf map[string]string) int { + n := len(sweep) + for cid := range probes { + if ck, okc := chainOf[cid]; okc && sweep[ck] { + continue + } + n++ + } + return n +} + +// parseCoinStatsMultiBalances handles the networks=all response: an +// array of {blockchain, balances[]} groups. A chain is verified when +// its summed USD value is > $1, or when the response carries no +// prices at all for that chain and some native amount is > 0. +func parseCoinStatsMultiBalances(raw []byte) ([]string, error) { + var groups []struct { + Blockchain string `json:"blockchain"` + Balances []coinStatsBalanceRow `json:"balances"` + } + if err := json.Unmarshal(raw, &groups); err != nil { + return nil, fmt.Errorf("parse multi balances: %w", err) + } + var out []string + for _, g := range groups { + if g.Blockchain == "" { + continue + } + if balancesVerified(g.Balances) { + out = append(out, g.Blockchain) + } + } + return out, nil +} + +// parseCoinStatsSingleBalance handles the single-connectionId +// response: a flat array of balance rows for one chain. +func parseCoinStatsSingleBalance(raw []byte) (bool, error) { + var rows []coinStatsBalanceRow + if err := json.Unmarshal(raw, &rows); err != nil { + return false, fmt.Errorf("parse single balance: %w", err) + } + return balancesVerified(rows), nil +} + +// balancesVerified applies the shared threshold: total USD > $1, or +// native amount > 0 when no USD pricing is present at all. +func balancesVerified(rows []coinStatsBalanceRow) bool { + totalUsd := 0.0 + anyPrice := false + anyAmount := false + for _, r := range rows { + if r.Price > 0 { + anyPrice = true + } + if r.Amount > 0 { + anyAmount = true + } + totalUsd += r.usd() + } + if anyPrice { + return totalUsd > verifiedUsdThreshold + } + return anyAmount +} diff --git a/harnesses/portfolio-chain-coverage/cmd/script/source_mobula.go b/harnesses/portfolio-chain-coverage/cmd/script/source_mobula.go new file mode 100644 index 00000000..8e3aa15a --- /dev/null +++ b/harnesses/portfolio-chain-coverage/cmd/script/source_mobula.go @@ -0,0 +1,262 @@ +package main + +import ( + "encoding/json" + "fmt" + neturl "net/url" + "strings" + "time" +) + +// Mobula v1. Auth is the raw key in the Authorization header. +// +// listed: GET /api/1/blockchains — catalog of supported chains. The +// response shape is tolerated both as {data:[...]} and as a +// bare top-level array. +// verified: GET /api/1/wallet/portfolio with EXPLICIT blockchains= +// targeting — distinct chain keys inside +// data.assets[].cross_chain_balances with value > $1. +// Explicit targeting is used because it is the vendor's +// most precise documented invocation (mirroring the +// per-chain probes every other vendor gets) and because +// fetchAllChains=true was observed to silently skip ~25 +// catalog chains that the explicit path returns perfectly +// (verified against on-chain RPCs, 2026-07-07). The main +// sweep wallet targets the whole catalog minus testnets; +// probe wallets target their own chains. fetchAllChains +// remains the fallback when the catalog call failed. +// 4xx on a probe wallet means the wallet type is +// unsupported and is tolerated silently (expected answer, +// not a fault). +const mobulaBaseDefault = "https://api.mobula.io" + +func probeMobula(key string) coverage { + base := envDefault("MOBULA_BASE_URL", mobulaBaseDefault) + hdr := map[string]string{"Authorization": key, "Accept": "application/json"} + cov := coverage{listed: -1, listedSource: "declared", verified: -1, probed: -1} + var total time.Duration + + // --- listed: self-declared chain catalog ----------------------- + raw, el, err := doCall("mobula", "GET", base+"/api/1/blockchains", hdr, nil) + total += el + catalog := map[string]bool{} + var catalogRows []mobulaCatalogRow + if err != nil { + recordError("mobula", err) + fmt.Printf("[mobula] blockchains catalog failed: %v\n", err) + } else if n, rows, perr := parseMobulaBlockchains(raw); perr != nil { + recordError("mobula", perr) + fmt.Printf("[mobula] blockchains parse failed: %v\n", perr) + } else { + cov.listed = n + catalogRows = rows + for _, r := range rows { + for _, v := range []string{r.Name, r.Chain} { + if nn := normalizeChainName(v); nn != "" { + catalog[nn] = true + } + } + } + } + + // --- verified: portfolio probes --------------------------------- + verified := map[string]bool{} + // probed = verified chains + missed target CHAINS (deduped across + // wallets, intersected with the vendor's own catalog, excluding + // chains another wallet already verified). The EVM sweep itself + // only counts chains it verified (funding elsewhere is unknowable + // from one address). + missedChains := map[string]bool{} + anyProbeOK := false + + // EVM sweep first, then the shared non-EVM probe set (identical + // addresses to every other provider, see addresses.go). Non-EVM + // wallets are best-effort: Mobula's endpoint takes raw addresses, + // so a 4xx just means it does not index that wallet type. + wallets := append([]string{evmTestAddress}, uniqueProbeAddresses()...) + namesByAddr := probeNamesByAddr() + // Mainnet catalog names for explicit targeting. Testnets are + // excluded: they are unverifiable with mainnet whale wallets and + // must not inflate verified via priceless testnet gas balances. + var mainnetNames []string + normToRaw := map[string]string{} + for _, r := range catalogRows { + nn := normalizeChainName(r.Name) + if nn == "" || strings.Contains(nn, "testnet") || strings.Contains(nn, "bartio") { + continue + } + mainnetNames = append(mainnetNames, r.Name) + normToRaw[nn] = r.Name + if cn := normalizeChainName(r.Chain); cn != "" { + normToRaw[cn] = r.Name + } + } + fullCSV := neturl.QueryEscape(strings.Join(mainnetNames, ",")) + for i, wallet := range wallets { + optional := i > 0 // non-EVM support is best-effort + if i > 0 { + time.Sleep(sweepSpacing) + } + // Explicit targeting: probe wallets ask for their own target + // chains, the main sweep wallet asks for the whole mainnet + // catalog. Fall back to fetchAllChains when the catalog call + // failed this cycle. + query := "fetchAllChains=true" + if len(mainnetNames) > 0 { + if i == 0 { + query = "blockchains=" + fullCSV + } else { + seen := map[string]bool{} + var targets []string + for _, n := range namesByAddr[wallet] { + if raw, ok := normToRaw[n]; ok && !seen[raw] { + seen[raw] = true + targets = append(targets, raw) + } + } + if len(targets) > 0 { + query = "blockchains=" + neturl.QueryEscape(strings.Join(targets, ",")) + } + } + } + url := fmt.Sprintf("%s/api/1/wallet/portfolio?wallet=%s&%s", base, wallet, query) + raw, el, err := doCall("mobula", "GET", url, hdr, nil) + total += el + if err != nil { + status := httpStatus(err) + if optional && (status == 400 || status == 404) { + // Expected when the endpoint does not index this + // wallet type; not a provider fault. + fmt.Printf("[mobula] optional wallet %s not supported (http %d), skipping\n", wallet, status) + continue + } + recordError("mobula", err) + fmt.Printf("[mobula] portfolio probe failed for %s: %v\n", wallet, err) + continue + } + chains, perr := parseMobulaPortfolio(raw) + if perr != nil { + recordError("mobula", perr) + fmt.Printf("[mobula] portfolio parse failed for %s: %v\n", wallet, perr) + continue + } + before := len(verified) + for _, c := range chains { + verified[c] = true + } + if optional && len(verified) == before { + // Funded wallet answered with nothing new: each of its + // target chains that the vendor itself lists counts as a + // missed CHAIN, deduped across wallets. When the catalog + // call failed, no miss is counted at all — probed + // degrades to the verified floor rather than guessing. + for _, n := range namesByAddr[wallet] { + if catalog[n] { + missedChains[n] = true + } + } + } + anyProbeOK = true + } + + if anyProbeOK { + verifiedNorm := map[string]bool{} + for ch := range verified { + verifiedNorm[normalizeChainName(ch)] = true + } + missed := 0 + for n := range missedChains { + if !verifiedNorm[n] { + missed++ + } + } + cov.verified = len(verified) + cov.probed = len(verified) + missed + } + cov.latencyMs = float64(total.Milliseconds()) + return cov +} + +// mobulaCatalogRow is one catalog entry (raw names, used both for +// probe-miss intersection and for explicit blockchains= targeting). +type mobulaCatalogRow struct { + Name string `json:"name"` + Chain string `json:"chain"` +} + +// parseMobulaBlockchains counts catalog entries and returns the raw +// rows, tolerating both {data:[...]} and a bare top-level array. +func parseMobulaBlockchains(raw []byte) (int, []mobulaCatalogRow, error) { + var wrapped struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(raw, &wrapped); err == nil && wrapped.Data != nil { + return len(wrapped.Data), mobulaCatalogRows(wrapped.Data), nil + } + var arr []json.RawMessage + if err := json.Unmarshal(raw, &arr); err == nil { + return len(arr), mobulaCatalogRows(arr), nil + } + return 0, nil, fmt.Errorf("parse blockchains: unexpected shape: %s", truncate(string(raw), 120)) +} + +func mobulaCatalogRows(rows []json.RawMessage) []mobulaCatalogRow { + out := make([]mobulaCatalogRow, 0, len(rows)) + for _, r := range rows { + var row mobulaCatalogRow + if json.Unmarshal(r, &row) != nil { + continue + } + out = append(out, row) + } + return out +} + +// parseMobulaPortfolio returns the distinct chain keys inside +// data.assets[].cross_chain_balances whose value clears the shared +// threshold. Per-chain USD is taken from balanceUSD when present, +// otherwise reconstructed as balance * asset price; when no pricing +// exists at all, native amount > 0 counts (same fallback as the +// other providers). +func parseMobulaPortfolio(raw []byte) ([]string, error) { + var resp struct { + Data struct { + Assets []struct { + Price float64 `json:"price"` + CrossChainBalances map[string]struct { + Balance float64 `json:"balance"` + BalanceUSD float64 `json:"balanceUSD"` + } `json:"cross_chain_balances"` + } `json:"assets"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("parse portfolio: %w", err) + } + + set := map[string]bool{} + for _, asset := range resp.Data.Assets { + for chain, bal := range asset.CrossChainBalances { + if chain == "" || set[chain] { + continue + } + usd := bal.BalanceUSD + if usd == 0 && asset.Price > 0 { + usd = bal.Balance * asset.Price + } + if usd > verifiedUsdThreshold { + set[chain] = true + continue + } + // No pricing anywhere: fall back to native amount. + if asset.Price == 0 && bal.BalanceUSD == 0 && bal.Balance > 0 { + set[chain] = true + } + } + } + out := make([]string, 0, len(set)) + for c := range set { + out = append(out, c) + } + return out, nil +} diff --git a/harnesses/portfolio-chain-coverage/cmd/script/source_moralis.go b/harnesses/portfolio-chain-coverage/cmd/script/source_moralis.go new file mode 100644 index 00000000..a87b9c23 --- /dev/null +++ b/harnesses/portfolio-chain-coverage/cmd/script/source_moralis.go @@ -0,0 +1,284 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + "time" +) + +// Moralis deep-index v2.2. Auth is the raw key in the X-API-Key +// header. +// +// Moralis exposes NO standalone machine-readable chain-catalog +// endpoint and every wallet call takes an explicit chains list, so +// listed carries listed_source="probe" (same convention as Zapper): +// +// listed = candidate chains the net-worth endpoint accepted this +// cycle, plus Solana when its separate gateway probe +// answers. One call PER CHAIN: a multi-chain call fails +// whole with "Wallet has too many ERC20 token balances +// for " on the Binance 8 whale wallet (observed +// on 0x38), which would collapse the entire EVM probe. +// verified = accepted chains whose networth_usd clears the shared +// threshold. When net-worth refuses the wallet's token +// count on a chain, the chain is still acknowledged by +// the vendor, so the probe falls back to the native +// balance endpoint for that chain (no USD pricing there, +// so the shared native-amount rule applies). Solana +// verifies on a native amount above 0 for the same +// reason. +// +// The candidate list mirrors docs.moralis.com/supported-chains (EVM +// mainnets, checked 2026-07). Override without a rebuild via +// MORALIS_CHAINS (comma-separated hex chain ids); a candidate Moralis +// rejects with a 4xx is an expected answer (not listed, not an error +// bucket), so list drift is self-healing. +const ( + moralisBaseDefault = "https://deep-index.moralis.io" + moralisSolBaseDefault = "https://solana-gateway.moralis.io" +) + +var moralisDefaultChains = []string{ + "0x1", // ethereum + "0x89", // polygon + "0x38", // bsc + "0xa4b1", // arbitrum + "0x2105", // base + "0xa", // optimism + "0xe708", // linea + "0xa86a", // avalanche + "0x19", // cronos + "0x64", // gnosis + "0x15b38", // chiliz + "0x504", // moonbeam + "0x2eb", // flow evm + "0x7e4", // ronin + "0x46f", // lisk + "0x171", // pulsechain + "0x531", // sei evm + // monad (0x8f) removed: this key's plan gates it with 401 every + // cycle. A 2026-07-07 scan of 21 further candidate chain ids + // (moonriver, blast, zksync, mantle, scroll, zora, ...) returned + // 401 for all of them too — the list above IS this plan's full + // surface. Re-scan when the plan changes. +} + +// moralisChainWallet overrides the probe wallet for candidate chains +// where the shared EVM address holds no balance: the funded probe +// wallet for that chain (see addresses.go) is used instead, so a +// working indexer verifies instead of reporting an empty net worth. +var moralisChainWallet = map[string]string{ + "0x7e4": "0xb32e9A84Ae0B55b8ab715e4Ac793a61B277bAFA3", // ronin + "0x171": "0xbE740c0c8b3C13b2B1Af763aC17a83797A948fe4", // pulsechain + "0x8f": "0x14c25602353402d0be03b386a9aa3f107dd7e34c", // monad +} + +func probeMoralis(key string) coverage { + base := envDefault("MORALIS_BASE_URL", moralisBaseDefault) + solBase := envDefault("MORALIS_SOL_BASE_URL", moralisSolBaseDefault) + hdr := map[string]string{"X-API-Key": key, "Accept": "application/json"} + cov := coverage{listed: -1, listedSource: "probe", verified: -1, probed: -1} + var total time.Duration + + chains := moralisDefaultChains + if v := envDefault("MORALIS_CHAINS", ""); v != "" { + chains = nil + for _, c := range strings.Split(v, ",") { + if c = strings.TrimSpace(c); c != "" { + chains = append(chains, c) + } + } + } + + // --- EVM: one net-worth call per candidate chain ---------------- + listed, verified := -1, -1 + for i, c := range chains { + if i > 0 { + time.Sleep(sweepSpacing) + } + accepted, ok, el := probeMoralisChain(base, hdr, c) + total += el + if !accepted { + continue + } + if listed < 0 { + listed, verified = 0, 0 + } + listed++ + if ok { + verified++ + } + } + + // --- Solana: separate gateway, best-effort ---------------------- + // Same tolerance as Mobula's optional wallets: a 4xx means the + // key's plan does not include the Solana gateway, which is an + // expected answer, not a fault. + solURL := fmt.Sprintf("%s/account/mainnet/%s/portfolio", solBase, solTestAddress) + raw, el, err := doCall("moralis", "GET", solURL, hdr, nil) + total += el + if err != nil { + if status := httpStatus(err); status == 400 || status == 404 { + fmt.Printf("[moralis] optional solana portfolio not available (http %d), skipping\n", status) + } else { + recordError("moralis", err) + fmt.Printf("[moralis] solana portfolio probe failed: %v\n", err) + } + } else if ok, perr := parseMoralisSolPortfolio(raw); perr != nil { + recordError("moralis", perr) + fmt.Printf("[moralis] solana portfolio parse failed: %v\n", perr) + } else { + if listed < 0 { + listed, verified = 0, 0 + } + listed++ + if ok { + verified++ + } + } + + cov.listed = listed + cov.verified = verified + // Per-chain funding cannot be established for this provider's + // probe shape, so probed reports the conservative floor. + cov.probed = cov.verified + cov.latencyMs = float64(total.Milliseconds()) + return cov +} + +// probeMoralisChain measures one candidate chain. Returns whether the +// vendor acknowledged the chain (listed), whether a real balance was +// observed (verified), and the elapsed HTTP time. +func probeMoralisChain(base string, hdr map[string]string, chain string) (accepted, verifiedOK bool, total time.Duration) { + wallet := evmTestAddress + if w, ok := moralisChainWallet[chain]; ok { + wallet = w + } + q := url.Values{} + q.Set("chains[0]", chain) + q.Set("exclude_spam", "true") + q.Set("exclude_unverified_contracts", "true") + u := fmt.Sprintf("%s/api/v2.2/wallets/%s/net-worth?%s", base, wallet, q.Encode()) + raw, el, err := doCall("moralis", "GET", u, hdr, nil) + total += el + + if err == nil { + l, v, perr := parseMoralisNetWorth(raw) + if perr != nil { + recordError("moralis", perr) + fmt.Printf("[moralis] net-worth parse failed for %s: %v\n", chain, perr) + return false, false, total + } + return l > 0, v > 0, total + } + + status := httpStatus(err) + if status == 400 && strings.Contains(strings.ToLower(err.Error()), "too many") { + // The vendor knows the wallet's token count on this chain, so + // the chain is acknowledged; net worth is just refused for + // whale wallets. Fall back to the native balance endpoint + // (no USD pricing there, shared native-amount rule applies). + u2 := fmt.Sprintf("%s/api/v2.2/%s/balance?chain=%s", base, wallet, url.QueryEscape(chain)) + raw2, el2, err2 := doCall("moralis", "GET", u2, hdr, nil) + total += el2 + if err2 != nil { + recordError("moralis", err2) + fmt.Printf("[moralis] native balance fallback failed for %s: %v\n", chain, err2) + return true, false, total + } + ok, perr := parseMoralisNativeBalance(raw2) + if perr != nil { + recordError("moralis", perr) + fmt.Printf("[moralis] native balance parse failed for %s: %v\n", chain, perr) + return true, false, total + } + return true, ok, total + } + if status == 400 || status == 404 { + // Candidate not supported (anymore): expected answer, not a + // provider fault. Keeps the error counter honest when the + // documented chain list drifts. Quota/auth/throttle codes + // fall through to the error path instead. + fmt.Printf("[moralis] chain %s rejected (http %d), skipping\n", chain, status) + return false, false, total + } + recordError("moralis", err) + fmt.Printf("[moralis] net-worth probe failed for %s: %v\n", chain, err) + return false, false, total +} + +// parseMoralisNetWorth returns (accepted chain count, chains whose +// networth_usd clears the shared threshold). Values are USD strings +// by contract, so no native fallback applies here. +func parseMoralisNetWorth(raw []byte) (int, int, error) { + var resp struct { + Chains []struct { + Chain string `json:"chain"` + NetworthUSD string `json:"networth_usd"` + } `json:"chains"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return 0, 0, fmt.Errorf("parse net-worth: %w", err) + } + if resp.Chains == nil { + return 0, 0, fmt.Errorf("parse net-worth: no chains array: %s", truncate(string(raw), 120)) + } + seen := map[string]bool{} + listed, verified := 0, 0 + for _, c := range resp.Chains { + if c.Chain == "" || seen[c.Chain] { + continue + } + seen[c.Chain] = true + listed++ + if usd, perr := strconv.ParseFloat(c.NetworthUSD, 64); perr == nil && usd > verifiedUsdThreshold { + verified++ + } + } + return listed, verified, nil +} + +// parseMoralisNativeBalance reads the wei string from the native +// balance endpoint. Any positive amount verifies the chain (no USD +// pricing in this response, shared fallback rule). +func parseMoralisNativeBalance(raw []byte) (bool, error) { + var resp struct { + Balance string `json:"balance"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return false, fmt.Errorf("parse native balance: %w", err) + } + if resp.Balance == "" { + return false, fmt.Errorf("parse native balance: no balance field: %s", truncate(string(raw), 120)) + } + return resp.Balance != "0", nil +} + +// parseMoralisSolPortfolio reports whether the Solana portfolio holds +// anything. The response carries no USD pricing, so the shared +// fallback applies: a native amount above 0 verifies the chain. +func parseMoralisSolPortfolio(raw []byte) (bool, error) { + var r struct { + NativeBalance struct { + Solana string `json:"solana"` + } `json:"nativeBalance"` + Tokens []struct { + Amount string `json:"amount"` + } `json:"tokens"` + } + if err := json.Unmarshal(raw, &r); err != nil { + return false, fmt.Errorf("parse solana portfolio: %w", err) + } + if amt, err := strconv.ParseFloat(r.NativeBalance.Solana, 64); err == nil && amt > 0 { + return true, nil + } + for _, t := range r.Tokens { + if amt, err := strconv.ParseFloat(t.Amount, 64); err == nil && amt > 0 { + return true, nil + } + } + return false, nil +} diff --git a/harnesses/portfolio-chain-coverage/cmd/script/source_zapper.go b/harnesses/portfolio-chain-coverage/cmd/script/source_zapper.go new file mode 100644 index 00000000..210686c2 --- /dev/null +++ b/harnesses/portfolio-chain-coverage/cmd/script/source_zapper.go @@ -0,0 +1,130 @@ +package main + +import ( + "encoding/json" + "fmt" + "time" +) + +// Zapper public GraphQL. Auth is the raw key in the x-zapper-api-key +// header. +// +// Zapper exposes NO standalone machine-readable chain-catalog +// endpoint, so listed and verified both come from the single +// portfolioV2 probe (1 call per cycle): +// +// listed = distinct networks visible in tokenBalances.byNetwork +// (exported with listed_source="probe" so dashboards can +// distinguish it from a declared catalog) +// verified = those networks with balanceUSD > $1 +// +// Field casing verified against build.zapper.xyz/docs/api/endpoints/ +// portfolio on 2026-07-03: portfolioV2 → tokenBalances → +// byNetwork(first:) → edges → node { network { name slug chainId } +// balanceUSD }. +const zapperBaseDefault = "https://public.zapper.xyz/graphql" + +const zapperQuery = `query PortfolioNetworks($addresses: [Address!]!) { + portfolioV2(addresses: $addresses) { + tokenBalances { + byNetwork(first: 200) { + edges { + node { + network { name slug chainId } + balanceUSD + } + } + } + } + } +}` + +func probeZapper(key string) coverage { + base := envDefault("ZAPPER_BASE_URL", zapperBaseDefault) + hdr := map[string]string{ + "x-zapper-api-key": key, + "Content-Type": "application/json", + } + cov := coverage{listed: -1, listedSource: "probe", verified: -1, probed: -1} + + body, err := json.Marshal(map[string]any{ + "query": zapperQuery, + "variables": map[string]any{"addresses": []string{evmTestAddress}}, + }) + if err != nil { + recordError("zapper", err) + return cov + } + + var total time.Duration + raw, el, err := doCall("zapper", "POST", base, hdr, body) + total += el + if err != nil { + recordError("zapper", err) + fmt.Printf("[zapper] portfolioV2 probe failed: %v\n", err) + } else if listed, verified, perr := parseZapperPortfolio(raw); perr != nil { + recordError("zapper", perr) + fmt.Printf("[zapper] portfolioV2 parse failed: %v\n", perr) + } else { + cov.listed = listed + cov.verified = verified + } + + // Single-call probe: per-network funding cannot be established, + // so probed reports the conservative floor. + cov.probed = cov.verified + cov.latencyMs = float64(total.Milliseconds()) + return cov +} + +// parseZapperPortfolio returns (probe-visible network count, networks +// with balanceUSD > $1). GraphQL transports schema errors inside a +// 200 body, so the errors array is checked before data. +func parseZapperPortfolio(raw []byte) (int, int, error) { + var resp struct { + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + Data struct { + PortfolioV2 struct { + TokenBalances struct { + ByNetwork struct { + Edges []struct { + Node struct { + Network struct { + Name string `json:"name"` + Slug string `json:"slug"` + ChainID json.Number `json:"chainId"` + } `json:"network"` + BalanceUSD float64 `json:"balanceUSD"` + } `json:"node"` + } `json:"edges"` + } `json:"byNetwork"` + } `json:"tokenBalances"` + } `json:"portfolioV2"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return 0, 0, fmt.Errorf("parse portfolioV2: %w", err) + } + if len(resp.Errors) > 0 { + return 0, 0, fmt.Errorf("graphql error: %s", resp.Errors[0].Message) + } + + seen := map[string]bool{} + verified := 0 + for _, e := range resp.Data.PortfolioV2.TokenBalances.ByNetwork.Edges { + id := e.Node.Network.Slug + if id == "" { + id = e.Node.Network.Name + } + if id == "" || seen[id] { + continue + } + seen[id] = true + if e.Node.BalanceUSD > verifiedUsdThreshold { + verified++ + } + } + return len(seen), verified, nil +} diff --git a/harnesses/portfolio-chain-coverage/cmd/script/source_zerion.go b/harnesses/portfolio-chain-coverage/cmd/script/source_zerion.go new file mode 100644 index 00000000..b73a5879 --- /dev/null +++ b/harnesses/portfolio-chain-coverage/cmd/script/source_zerion.go @@ -0,0 +1,175 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" +) + +// Zerion v1. Auth is HTTP Basic with the API key as username and an +// empty password. +// +// listed: GET /v1/chains/ — data[].id is the vendor's stable chain +// identifier. +// verified: GET /v1/wallets//portfolio?currency=usd per wallet +// in the shared EVM set — the shared sweep address plus +// every funded 20-byte 0x probe wallet (see addresses.go). +// Each call's positions_distribution_by_chain contributes +// its > $1 chains. Zerion's wallet surface is EVM-only, so +// non-EVM probe addresses are never submitted. +const zerionBaseDefault = "https://api.zerion.io" + +func probeZerion(key string) coverage { + base := envDefault("ZERION_BASE_URL", zerionBaseDefault) + hdr := map[string]string{ + "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(key+":")), + "Accept": "application/json", + } + cov := coverage{listed: -1, listedSource: "declared", verified: -1, probed: -1} + var total time.Duration + + // --- listed: self-declared chain catalog ----------------------- + raw, el, err := doCall("zerion", "GET", base+"/v1/chains/", hdr, nil) + total += el + catalog := map[string]bool{} + if err != nil { + recordError("zerion", err) + fmt.Printf("[zerion] chains catalog failed: %v\n", err) + } else if n, ids, perr := parseZerionChains(raw); perr != nil { + recordError("zerion", perr) + fmt.Printf("[zerion] chains parse failed: %v\n", perr) + } else { + cov.listed = n + catalog = ids + } + + // --- verified: one portfolio call per wallet in the EVM set ---- + // Zerion's dev tier throttles bursts on wallet endpoints (a call + // issued right after another 429s while the isolated request + // passes), so the sweep spaces calls wider than the shared + // sweepSpacing, allows ONE long-backoff retry for the whole + // cycle, and aborts the remaining sweep on a second 429 rather + // than hammering the limit — publish-then-leave carries the + // previous gauges forward. + verified := map[string]bool{} + missedChains := map[string]bool{} + anyOK := false + retried429 := false + wallets := append([]string{evmTestAddress}, evmProbeAddresses()...) + namesByAddr := probeNamesByAddr() + for i, wallet := range wallets { + time.Sleep(zerionSweepSpacing) + url := fmt.Sprintf("%s/v1/wallets/%s/portfolio?currency=usd", base, wallet) + raw, el, err = doCall("zerion", "GET", url, hdr, nil) + total += el + if err != nil && strings.Contains(err.Error(), "http 429") { + if retried429 { + recordError("zerion", err) + fmt.Printf("[zerion] second 429, aborting sweep at wallet %d/%d, publishing nothing\n", i+1, len(wallets)) + anyOK = false + break + } + retried429 = true + fmt.Printf("[zerion] portfolio 429, retrying once in 60s\n") + time.Sleep(60 * time.Second) + raw, el, err = doCall("zerion", "GET", url, hdr, nil) + total += el + } + if err != nil { + recordError("zerion", err) + fmt.Printf("[zerion] portfolio probe failed for %s: %v\n", wallet, err) + continue + } + chains, perr := parseZerionPortfolio(raw) + if perr != nil { + recordError("zerion", perr) + fmt.Printf("[zerion] portfolio parse failed for %s: %v\n", wallet, perr) + continue + } + before := len(verified) + for _, c := range chains { + verified[c] = true + } + if i > 0 && len(verified) == before { + // Funded probe wallet answered with nothing new: each of + // its target chains that Zerion itself lists AND that no + // other wallet already verified counts as one missed + // CHAIN (deduped across wallets — counting wallets + // overcounted probed past listed). A failed catalog call + // counts no miss at all. + for _, n := range namesByAddr[wallet] { + if catalog[n] { + missedChains[n] = true + } + } + } + anyOK = true + } + if anyOK { + verifiedNorm := map[string]bool{} + for id := range verified { + verifiedNorm[normalizeChainName(id)] = true + } + missed := 0 + for n := range missedChains { + if !verifiedNorm[n] { + missed++ + } + } + cov.verified = len(verified) + cov.probed = len(verified) + missed + } + cov.latencyMs = float64(total.Milliseconds()) + return cov +} + +// parseZerionChains counts data[].id entries in the chain catalog and +// returns the normalized id set for probe-miss intersection. +func parseZerionChains(raw []byte) (int, map[string]bool, error) { + var resp struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return 0, nil, fmt.Errorf("parse chains: %w", err) + } + n := 0 + set := map[string]bool{} + for _, c := range resp.Data { + if c.ID != "" { + n++ + set[normalizeChainName(c.ID)] = true + } + } + return n, set, nil +} + +// zerionSweepSpacing is wider than the shared sweepSpacing because +// Zerion's dev tier is the burst-touchiest upstream in the cohort. +const zerionSweepSpacing = 5 * time.Second + +// parseZerionPortfolio returns the chain ids carrying > $1 in +// attributes.positions_distribution_by_chain. The map values are +// already USD (currency=usd), so no fallback path is needed. +func parseZerionPortfolio(raw []byte) ([]string, error) { + var resp struct { + Data struct { + Attributes struct { + PositionsDistributionByChain map[string]float64 `json:"positions_distribution_by_chain"` + } `json:"attributes"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("parse portfolio: %w", err) + } + var out []string + for chain, usd := range resp.Data.Attributes.PositionsDistributionByChain { + if usd > verifiedUsdThreshold { + out = append(out, chain) + } + } + return out, nil +} diff --git a/harnesses/portfolio-chain-coverage/go.mod b/harnesses/portfolio-chain-coverage/go.mod new file mode 100644 index 00000000..6e626bbb --- /dev/null +++ b/harnesses/portfolio-chain-coverage/go.mod @@ -0,0 +1,17 @@ +module portfolio-chain-coverage + +go 1.24 + +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.9 // 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.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + golang.org/x/sys v0.22.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/harnesses/portfolio-chain-coverage/go.sum b/harnesses/portfolio-chain-coverage/go.sum new file mode 100644 index 00000000..d5318cf8 --- /dev/null +++ b/harnesses/portfolio-chain-coverage/go.sum @@ -0,0 +1,24 @@ +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/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.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +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/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.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= diff --git a/harnesses/rpc-capabilities/README.md b/harnesses/rpc-capabilities/README.md index 3d1ec9aa..58171d4c 100644 --- a/harnesses/rpc-capabilities/README.md +++ b/harnesses/rpc-capabilities/README.md @@ -8,7 +8,7 @@ Three concerns, one process, one set of (provider × chain) clients: | Bench | Metric | How | |---|---|---| -| №010 RPC latency | `eth_blockNumber` round-trip p50/p90/p99 per provider per chain | Probe every 30 s, observe latency, emit gauge + histogram | +| №010 RPC latency | `eth_getBlockByNumber(latest)` round-trip p50/p90/p99 per provider per chain | Probe every 30 s, observe latency, emit gauge + histogram | | №011 RPC reliability | 24 h error rate per provider per chain, classified | Counter labeled by result: `ok / http_err / jsonrpc_err / stale / timeout` | | №012 Archive RPC coverage | Whether `eth_getBalance` resolves at depths {300, 7.2k, 216k, 1.3M, 5M} from head | One probe per depth per provider, run hourly | diff --git a/harnesses/rpc-capabilities/cmd/script/config.go b/harnesses/rpc-capabilities/cmd/script/config.go index ca6d706e..80fb0337 100644 --- a/harnesses/rpc-capabilities/cmd/script/config.go +++ b/harnesses/rpc-capabilities/cmd/script/config.go @@ -55,6 +55,34 @@ type Chain struct { // (3 providers), Zora / Abstract / HyperEVM (≤2 keyless providers). func chains() []Chain { return []Chain{ + // ─── Monad mainnet (chain 143) — added 2026-07-08, all endpoints + // live-verified (eth_chainId=143 + anti-cache probe). Five official + // mirrors exist behind different infra vendors; we probe the primary + // rpc.monad.xyz as the chain-official plus the multi-chain gateways. + { + Slug: "monad", + Name: "Monad", + Providers: []Provider{ + {Slug: "monad-official", Name: "Monad Official", URL: envDefault("RPC_URL_MONAD_OFFICIAL", "https://rpc.monad.xyz")}, + {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_MONAD_DRPC", "https://monad-mainnet.drpc.org")}, + {Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_MONAD_TENDERLY", "https://monad.gateway.tenderly.co")}, + {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_MONAD_BLOXROUTE", "https://monad.rpc.blxrbdn.com")}, + {Slug: "onfinality", Name: "OnFinality", URL: envDefault("RPC_URL_MONAD_ONFINALITY", "https://monad-mainnet.api.onfinality.io/public")}, + }, + }, + // ─── MegaETH mainnet (chain 4326) — added 2026-07-08, all endpoints + // live-verified. Official endpoint uses dynamic compute-unit limiting; + // 1 probe/30s/region stays far under it. + { + Slug: "megaeth", + Name: "MegaETH", + Providers: []Provider{ + {Slug: "megaeth-official", Name: "MegaETH Official", URL: envDefault("RPC_URL_MEGAETH_OFFICIAL", "https://mainnet.megaeth.com/rpc")}, + {Slug: "1rpc", Name: "1RPC", URL: envDefault("RPC_URL_MEGAETH_1RPC", "https://public.1rpc.io/megaeth")}, + {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_MEGAETH_DRPC", "https://megaeth.drpc.org")}, + {Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_MEGAETH_TENDERLY", "https://megaeth.gateway.tenderly.co")}, + }, + }, // ─── Ethereum mainnet (9 providers) ──────────────────────── { Slug: "ethereum", diff --git a/harnesses/rpc-capabilities/cmd/script/metrics.go b/harnesses/rpc-capabilities/cmd/script/metrics.go index a7db76ca..df15637f 100644 --- a/harnesses/rpc-capabilities/cmd/script/metrics.go +++ b/harnesses/rpc-capabilities/cmd/script/metrics.go @@ -20,7 +20,7 @@ var ( rpcLatency = promauto.NewGaugeVec( prometheus.GaugeOpts{ Name: "rpc_latency_milliseconds", - Help: "Latest observed HTTP round-trip in milliseconds for `eth_blockNumber` against a public RPC endpoint.", + Help: "Latest observed HTTP round-trip in milliseconds for `eth_getBlockByNumber(latest)` against a public RPC endpoint.", }, []string{"provider", "chain", "region"}, ) @@ -28,7 +28,7 @@ var ( rpcLatencyHist = promauto.NewHistogramVec( prometheus.HistogramOpts{ Name: "rpc_latency_milliseconds_histogram", - Help: "Histogram of public RPC `eth_blockNumber` latencies — drives the p50/p90/p99 leaderboard via `histogram_quantile` / `quantile_over_time`.", + Help: "Histogram of public RPC `eth_getBlockByNumber(latest)` latencies — drives the p50/p90/p99 leaderboard via `histogram_quantile` / `quantile_over_time`.", Buckets: []float64{50, 100, 150, 200, 300, 500, 750, 1000, 1500, 2000, 3000, 5000, 10000}, }, []string{"provider", "chain", "region"}, diff --git a/harnesses/rpc-capabilities/cmd/script/probe.go b/harnesses/rpc-capabilities/cmd/script/probe.go index b170a604..2e5a3272 100644 --- a/harnesses/rpc-capabilities/cmd/script/probe.go +++ b/harnesses/rpc-capabilities/cmd/script/probe.go @@ -51,6 +51,10 @@ func (t *chainTips) get(chain string) uint64 { var tips = newChainTips() +// rpcEnvelope keeps Result as a plain string; still used by archive.go +// (eth_blockNumber head lookup + eth_getBalance probes both return hex +// strings). The latency probe below parses an object result and has +// its own envelope. type rpcEnvelope struct { Result string `json:"result"` Error *struct { @@ -59,11 +63,34 @@ type rpcEnvelope struct { } `json:"error"` } -// callBlockNumber issues `eth_blockNumber` and classifies the response -// into one of: ok, http_err, jsonrpc_err, timeout. Staleness is added -// by the caller because it requires the cross-provider tip context. -func callBlockNumber(ctx context.Context, url string) (block uint64, result string, latencyMs float64, err error) { - body := []byte(`{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}`) +type rpcBlockEnvelope struct { + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +type blockHeader struct { + Number string `json:"number"` +} + +// callLatestBlock issues `eth_getBlockByNumber("latest", false)` and +// classifies the response into one of: ok, http_err, jsonrpc_err, +// timeout. Staleness is added by the caller because it requires the +// cross-provider tip context. +// +// Anti-cache probe design. The previous probe (`eth_blockNumber`) is +// served straight from some providers' edge caches without touching a +// 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) { + body := []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["latest",false],"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)") @@ -90,17 +117,24 @@ func callBlockNumber(ctx context.Context, url string) (block uint64, result stri if err != nil { return 0, "http_err", latencyMs, err } - var r rpcEnvelope + 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 r.Result == "" { + if len(r.Result) == 0 || string(r.Result) == "null" { return 0, "jsonrpc_err", latencyMs, fmt.Errorf("empty result") } - n, err := strconv.ParseUint(strings.TrimPrefix(r.Result, "0x"), 16, 64) + var hdr blockHeader + 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("header missing number") + } + n, err := strconv.ParseUint(strings.TrimPrefix(hdr.Number, "0x"), 16, 64) if err != nil { return 0, "jsonrpc_err", latencyMs, err } @@ -135,7 +169,7 @@ func probeOne(ctx context.Context, c Chain, p Provider) { tick := func() { probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() - block, result, latency, err := callBlockNumber(probeCtx, p.URL) + block, result, latency, err := callLatestBlock(probeCtx, p.URL) if result == "ok" { tips.update(c.Slug, block) diff --git a/harnesses/rpc-keyed-latency/Dockerfile b/harnesses/rpc-keyed-latency/Dockerfile new file mode 100644 index 00000000..63108cfc --- /dev/null +++ b/harnesses/rpc-keyed-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/rpc-keyed-latency/README.md b/harnesses/rpc-keyed-latency/README.md new file mode 100644 index 00000000..563c7b88 --- /dev/null +++ b/harnesses/rpc-keyed-latency/README.md @@ -0,0 +1,49 @@ +# rpc-keyed-latency + +Latency + reliability of **signup-gated free-tier** RPC endpoints (Alchemy, +Infura, Chainstack, Ankr, Helius), probed continuously from 3 regions. +Companion to `rpc-capabilities` which covers **no-key** public endpoints — +same anti-cache probe, same classification rules, so the two tiers stay +methodologically comparable. + +| Bench | Measures | How | +|---|---|---| +| Keyed RPC latency | `eth_getBlockByNumber(latest)` (EVM) / `getSlot` (Solana) round-trip p50/p90/p99 per provider per chain | Probe every 60 s per region, rotating request id (anti-cache), gauge + histogram with `tier="keyed"` | + +## Env contract + +Endpoints are **never** committed — every URL embeds an API key. A +(provider, chain) cell is probed iff its env var is set: + +``` +RPC_KEYED_URL__ e.g. RPC_KEYED_URL_INFURA_ETHEREUM +``` + +Providers: `INFURA` `ALCHEMY` `CHAINSTACK` `ANKR` `HELIUS`. +Chains: `ETHEREUM` `BASE` `ARBITRUM` `OPTIMISM` `BNB` `POLYGON` `SOLANA`. + +Tuning: + +- `REGION` — us-east | eu-west | sgp (else derived from `RAILWAY_REPLICA_REGION`) +- `RPC_KEYED_PROBE_SECONDS` — default 60 +- `RPC_KEYED_BUDGET_` — per-REGION monthly request budget override + +## Quota guard + +Each region gets 1/3 of a provider's monthly free quota (defaults in +`config.go`, derived from the 2026-07 free-tier audit). Probing for a +provider pauses at **90% of the region budget** until calendar-month +rollover and emits `rpc_keyed_quota_used_ratio` + `result="quota_paused"` +counters. Never exhaust a free key: a dead key blanks the bench until +manual rotation (see the CoinStats credits incident). + +The counter is in-memory: a restart under-counts, which is safe because +budgets already target ≤2/3 of the provider's real quota. + +## Metric namespace + +Same metric names as `rpc-capabilities` (`rpc_latency_milliseconds`, +`rpc_call_total`, `rpc_health`) so shared recording rules apply, with a +`tier="keyed"` label. Provider slugs are disjoint from the no-key +cohort; if a provider ever appears in both tiers its keyed slug must be +distinct (e.g. `drpc-free-tier`). diff --git a/harnesses/rpc-keyed-latency/cmd/script/config.go b/harnesses/rpc-keyed-latency/cmd/script/config.go new file mode 100644 index 00000000..446f8478 --- /dev/null +++ b/harnesses/rpc-keyed-latency/cmd/script/config.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// Endpoint is one (provider × chain) keyed RPC endpoint we probe. +// URLs come exclusively from env (RPC_KEYED_URL__) +// because every one of them embeds an API key — nothing here is +// committed to the repo. A (provider, chain) pair only enters the +// probe matrix when its env var is set, so partially-enabled +// providers (e.g. Ankr while Optimism is still locked) just skip the +// missing cells. +type Endpoint struct { + Provider string // Prometheus label + OCB provider slug + Chain string // canonical OCB chain slug + Kind string // "evm" | "solana" — selects the probe payload + URL string +} + +// matrix declares which (provider, chain) cells we look for in env. +// Kind is derived from the chain. +var providers = []string{"infura", "alchemy", "chainstack", "ankr", "helius", "quicknode"} +var chainsEVM = []string{"ethereum", "base", "arbitrum", "optimism", "bnb", "polygon"} + +func endpoints() []Endpoint { + var out []Endpoint + for _, p := range providers { + for _, c := range chainsEVM { + if url := envURL(p, c); url != "" { + out = append(out, Endpoint{Provider: p, Chain: c, Kind: "evm", URL: url}) + } + } + if url := envURL(p, "solana"); url != "" { + out = append(out, Endpoint{Provider: p, Chain: "solana", Kind: "solana", URL: url}) + } + } + return out +} + +func envURL(provider, chain string) string { + key := fmt.Sprintf("RPC_KEYED_URL_%s_%s", + strings.ToUpper(provider), strings.ToUpper(chain)) + return strings.TrimSpace(os.Getenv(key)) +} + +// Per-region monthly request budgets (this service = one region; the +// three regional services share one API key per provider, so each +// region gets 1/3 of the provider's effective monthly quota). +// Defaults derive from the 2026-07 free-tier audit, converted to +// eth_getBlockByNumber-equivalent requests: +// infura ~1.1M/mo total → 370k per region +// alchemy ~1.5M/mo → 450k +// chainstack 3M/mo → 900k +// ankr ~1M/mo → 300k +// helius 1M/mo → 300k +// Override per provider with RPC_KEYED_BUDGET_. +var defaultBudgets = map[string]int64{ + "infura": 370_000, + "alchemy": 450_000, + "chainstack": 900_000, + "ankr": 300_000, + "helius": 300_000, + // quicknode: paid Mobula account (shared endpoints), budget covers + // 2 chains at 60s cadence with ample margin without eating the + // production credit pool. + "quicknode": 150_000, +} + +func budgetFor(provider string) int64 { + if v := strings.TrimSpace(os.Getenv("RPC_KEYED_BUDGET_" + strings.ToUpper(provider))); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { + return n + } + } + if b, ok := defaultBudgets[provider]; ok { + return b + } + return 100_000 +} diff --git a/harnesses/rpc-keyed-latency/cmd/script/main.go b/harnesses/rpc-keyed-latency/cmd/script/main.go new file mode 100644 index 00000000..beb6ccc8 --- /dev/null +++ b/harnesses/rpc-keyed-latency/cmd/script/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" +) + +// Metrics server listens on :2112 — the OCB convention for every +// harness scraped by the shared Prometheus at +// .railway.internal:2112. + +var currentRegion = loadRegion() + +func loadRegion() string { + if r := strings.TrimSpace(os.Getenv("REGION")); r != "" { + return r + } + if r := normalizeRailwayRegion(os.Getenv("RAILWAY_REPLICA_REGION")); r != "" { + return r + } + return "eu-west" +} + +func normalizeRailwayRegion(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + if raw == "" { + return "" + } + switch { + case strings.HasPrefix(raw, "us-"), strings.HasPrefix(raw, "northamerica"): + return "us-east" + case strings.HasPrefix(raw, "europe"), strings.HasPrefix(raw, "eu-"): + return "eu-west" + case strings.HasPrefix(raw, "asia"), strings.HasPrefix(raw, "ap-"): + return "sgp" + default: + return raw + } +} + +func main() { + fmt.Println("=== RPC Keyed Free-Tier Latency Harness ===") + fmt.Println("OpenChainBench - latency of signup-gated free-tier RPC endpoints.") + fmt.Printf("Region: %s | probe interval: %s | quota guard at 90%%\n", currentRegion, probeInterval()) + fmt.Println() + + eps := endpoints() + if len(eps) == 0 { + fmt.Println("[fatal] no RPC_KEYED_URL_* env vars set — nothing to probe") + os.Exit(1) + } + for _, e := range eps { + fmt.Printf(" - %-12s %-10s budget(region)=%d req/mo\n", e.Provider, e.Chain, budgetFor(e.Provider)) + } + fmt.Println() + addr := ":2112" + if v := strings.TrimSpace(os.Getenv("METRICS_ADDR")); v != "" { + addr = v + } + fmt.Printf("Metrics server: %s/metrics\n", addr) + go func() { + if err := StartMetricsServer(addr); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + os.Exit(1) + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + StartProbeLoop(ctx) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + s := <-sig + fmt.Printf("\n[shutdown] received %v\n", s) + cancel() +} diff --git a/harnesses/rpc-keyed-latency/cmd/script/metrics.go b/harnesses/rpc-keyed-latency/cmd/script/metrics.go new file mode 100644 index 00000000..978012a4 --- /dev/null +++ b/harnesses/rpc-keyed-latency/cmd/script/metrics.go @@ -0,0 +1,70 @@ +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" +) + +// Metric names intentionally match the no-key rpc-capabilities harness +// so the shared Prometheus recording rules (ocb:rpc_latency_*) pick up +// keyed providers with zero rule changes. The `tier="keyed"` label +// disambiguates: no series collision is possible today because provider +// slugs are disjoint between the two harnesses (infura/alchemy/… vs +// publicnode/drpc/…). If a provider ever appears in BOTH tiers, its +// keyed slug must stay distinct (e.g. `drpc-free-tier`) — the per-chain +// no-key bench formulas select providers by exact slug. +var ( + rpcLatency = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "rpc_latency_milliseconds", + Help: "Latest observed HTTP round-trip in milliseconds for `eth_getBlockByNumber(latest)` (EVM) / `getSlot` (Solana) against a keyed free-tier RPC endpoint.", + }, + []string{"provider", "chain", "region", "tier"}, + ) + + rpcLatencyHist = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "rpc_latency_milliseconds_histogram", + Help: "Histogram of keyed free-tier RPC probe latencies — drives p50/p90/p99 via `quantile_over_time`.", + Buckets: []float64{50, 100, 150, 200, 300, 500, 750, 1000, 1500, 2000, 3000, 5000, 10000}, + }, + []string{"provider", "chain", "region", "tier"}, + ) + + rpcCallTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "rpc_call_total", + Help: "Keyed RPC probe outcomes: ok | http_err | jsonrpc_err | stale | timeout | quota_paused.", + }, + []string{"provider", "chain", "region", "result", "tier"}, + ) + + rpcHealth = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "rpc_health", + Help: "1 when the last keyed probe was fresh + valid, 0 otherwise.", + }, + []string{"provider", "chain", "region", "tier"}, + ) + + quotaUsedRatio = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "rpc_keyed_quota_used_ratio", + Help: "Fraction of this region's monthly request budget consumed per provider (probing pauses at 0.90).", + }, + []string{"provider", "region"}, + ) +) + +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + 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/rpc-keyed-latency/cmd/script/probe.go b/harnesses/rpc-keyed-latency/cmd/script/probe.go new file mode 100644 index 00000000..b3d4554e --- /dev/null +++ b/harnesses/rpc-keyed-latency/cmd/script/probe.go @@ -0,0 +1,301 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" +) + +const ( + defaultProbeSeconds = 60 + probeTimeout = 8 * time.Second + // Quota guard trips at 90% of the region's monthly budget: the + // point is to NEVER exhaust a free API key (a dead key blanks the + // bench until someone rotates it — same failure mode as the + // CoinStats credits incident). + quotaGuardRatio = 0.90 + + staleBlockGapEVM uint64 = 20 // ~4 min on Ethereum + staleSlotGapSolana uint64 = 300 // ~2 min of slots +) + +func probeInterval() time.Duration { + if v := strings.TrimSpace(os.Getenv("RPC_KEYED_PROBE_SECONDS")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 15 { + return time.Duration(n) * time.Second + } + } + return defaultProbeSeconds * time.Second +} + +// --------------------------------------------------------------------------- +// Quota guard: in-memory per-provider monthly request counter. Resets on +// calendar-month rollover AND on process restart — restarts under-count, +// which is safe (budgets already target ≤2/3 of the real quota, and the +// guard is belt-and-suspenders on top of that headroom). +// --------------------------------------------------------------------------- + +type quotaGuard struct { + mu sync.Mutex + month string + counts map[string]int64 +} + +var quota = "aGuard{counts: make(map[string]int64)} + +// allow reserves one request for provider. Returns false when the +// region budget guard has tripped for the current month. +func (q *quotaGuard) allow(provider, region string) bool { + q.mu.Lock() + defer q.mu.Unlock() + m := time.Now().UTC().Format("2006-01") + if m != q.month { + q.month = m + q.counts = make(map[string]int64) + } + budget := budgetFor(provider) + used := q.counts[provider] + ratio := float64(used) / float64(budget) + quotaUsedRatio.WithLabelValues(provider, region).Set(ratio) + if ratio >= quotaGuardRatio { + return false + } + q.counts[provider] = used + 1 + return true +} + +// --------------------------------------------------------------------------- +// Cross-provider chain tips for staleness classification (same design +// as the no-key harness: rolling max so an honest endpoint is not +// flagged stale when the reference itself lags). +// --------------------------------------------------------------------------- + +type chainTips struct { + mu sync.RWMutex + val map[string]uint64 +} + +var tips = &chainTips{val: make(map[string]uint64)} + +func (t *chainTips) update(chain string, v uint64) { + t.mu.Lock() + if v > t.val[chain] { + t.val[chain] = v + } + t.mu.Unlock() +} + +func (t *chainTips) get(chain string) uint64 { + t.mu.RLock() + defer t.mu.RUnlock() + return t.val[chain] +} + +// --------------------------------------------------------------------------- +// Probes. EVM mirrors the no-key harness anti-cache probe exactly +// (eth_getBlockByNumber("latest", false) + rotating id) so keyed and +// no-key numbers stay methodologically comparable. Solana probes +// getSlot at processed commitment — the lightest call that still +// travels to a validator-backed node. +// --------------------------------------------------------------------------- + +type rpcBlockEnvelope struct { + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +type blockHeader struct { + Number string `json:"number"` +} + +func doPost(ctx context.Context, url string, body []byte) (raw []byte, latencyMs float64, classified string, err error) { + 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 nil, latencyMs, "timeout", err + } + return nil, latencyMs, "http_err", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + _, _ = io.Copy(io.Discard, resp.Body) + return nil, latencyMs, "http_err", fmt.Errorf("status %d", resp.StatusCode) + } + raw, err = io.ReadAll(resp.Body) + if err != nil { + return nil, latencyMs, "http_err", err + } + return raw, latencyMs, "", nil +} + +func probeEVM(ctx context.Context, url string) (head uint64, result string, latencyMs float64, err error) { + body := []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["latest",false],"id":%d}`, + time.Now().UnixNano(), + )) + raw, latencyMs, classified, err := doPost(ctx, url, body) + if classified != "" { + return 0, classified, 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 blockHeader + if err := json.Unmarshal(r.Result, &hdr); err != nil || hdr.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 n, "ok", latencyMs, nil +} + +func probeSolana(ctx context.Context, url string) (slot uint64, result string, latencyMs float64, err error) { + body := []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":"getSlot","params":[{"commitment":"processed"}],"id":%d}`, + time.Now().UnixNano(), + )) + raw, latencyMs, classified, err := doPost(ctx, url, body) + if classified != "" { + return 0, classified, 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) + } + n, err := strconv.ParseUint(strings.Trim(string(r.Result), `"`), 10, 64) + if err != nil { + return 0, "jsonrpc_err", latencyMs, err + } + return n, "ok", latencyMs, nil +} + +// --------------------------------------------------------------------------- +// Probe loop +// --------------------------------------------------------------------------- + +func StartProbeLoop(ctx context.Context) { + for _, e := range endpoints() { + e := e + go probeOne(ctx, e) + } +} + +func probeOne(ctx context.Context, e Endpoint) { + interval := probeInterval() + jitter := time.Duration(int64(interval) * urlJitter(e.URL+e.Chain) / 100) + select { + case <-ctx.Done(): + return + case <-time.After(jitter): + } + + t := time.NewTicker(interval) + defer t.Stop() + + tick := func() { + if !quota.allow(e.Provider, currentRegion) { + rpcCallTotal.WithLabelValues(e.Provider, e.Chain, currentRegion, "quota_paused", "keyed").Inc() + rpcHealth.WithLabelValues(e.Provider, e.Chain, currentRegion, "keyed").Set(0) + rpcLatency.DeleteLabelValues(e.Provider, e.Chain, currentRegion, "keyed") + fmt.Printf("[%s/%s] quota guard tripped (>=90%% of monthly region budget) — paused until month rollover\n", e.Chain, e.Provider) + return + } + + probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + + var head uint64 + var result string + var latency float64 + var err error + if e.Kind == "solana" { + head, result, latency, err = probeSolana(probeCtx, e.URL) + } else { + head, result, latency, err = probeEVM(probeCtx, e.URL) + } + + if result == "ok" { + tips.update(e.Chain, head) + tip := tips.get(e.Chain) + gap := staleBlockGapEVM + if e.Kind == "solana" { + gap = staleSlotGapSolana + } + if tip > 0 && head+gap < tip { + result = "stale" + } + } + rpcCallTotal.WithLabelValues(e.Provider, e.Chain, currentRegion, result, "keyed").Inc() + if result == "ok" { + // Same rule as the no-key harness: latency is recorded ONLY + // for fresh valid responses (error responses are often faster + // than real work and would poison the p50). + rpcLatency.WithLabelValues(e.Provider, e.Chain, currentRegion, "keyed").Set(latency) + rpcLatencyHist.WithLabelValues(e.Provider, e.Chain, currentRegion, "keyed").Observe(latency) + rpcHealth.WithLabelValues(e.Provider, e.Chain, currentRegion, "keyed").Set(1) + fmt.Printf("[%s/%s] head=%d latency=%.0fms\n", e.Chain, e.Provider, head, latency) + } else { + rpcLatency.DeleteLabelValues(e.Provider, e.Chain, currentRegion, "keyed") + rpcHealth.WithLabelValues(e.Provider, e.Chain, currentRegion, "keyed").Set(0) + // Go http errors embed the full request URL, which carries the + // API key in the path/query for every provider here. Redact it + // before logging so keys never land in service logs. + msg := "" + if err != nil { + msg = strings.ReplaceAll(err.Error(), e.URL, "<"+e.Provider+"-endpoint>") + } + fmt.Printf("[%s/%s] %s latency=%.0fms err=%s\n", e.Chain, e.Provider, result, latency, msg) + } + } + + tick() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + tick() + } + } +} + +func urlJitter(s string) int64 { + var sum int64 + for _, c := range s { + sum = (sum*131 + int64(c)) % 100 + } + if sum < 0 { + sum = -sum + } + return sum +} diff --git a/harnesses/rpc-keyed-latency/go.mod b/harnesses/rpc-keyed-latency/go.mod new file mode 100644 index 00000000..0b461bb6 --- /dev/null +++ b/harnesses/rpc-keyed-latency/go.mod @@ -0,0 +1,18 @@ +module rpc-keyed-latency + +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/rpc-keyed-latency/go.sum b/harnesses/rpc-keyed-latency/go.sum new file mode 100644 index 00000000..d6b8ca98 --- /dev/null +++ b/harnesses/rpc-keyed-latency/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/rpc-keyed-latency/railway.toml b/harnesses/rpc-keyed-latency/railway.toml new file mode 100644 index 00000000..2abbb1f0 --- /dev/null +++ b/harnesses/rpc-keyed-latency/railway.toml @@ -0,0 +1,7 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/health" +restartPolicyType = "ON_FAILURE" diff --git a/infrastructure/monitoring/prometheus/prometheus.yml b/infrastructure/monitoring/prometheus/prometheus.yml index 2c4d3e94..6e7ce52f 100644 --- a/infrastructure/monitoring/prometheus/prometheus.yml +++ b/infrastructure/monitoring/prometheus/prometheus.yml @@ -171,6 +171,21 @@ scrape_configs: benchmark: rpc-capabilities 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 + # ocb:rpc_latency_* recording rules with zero rule changes. + - job_name: 'rpc-keyed-latency' + honor_labels: true + static_configs: + - targets: + - 'rpc-keyed-us.railway.internal:2112' # us-east + - 'rpc-keyed-eu.railway.internal:2112' # eu-west + - 'rpc-keyed-sgp.railway.internal:2112' # sgp + labels: + benchmark: rpc-keyed-latency + metrics_path: /metrics + # OpenChainBench bench №013 - Ethereum gas oracle prediction accuracy. # Polls Blocknative + PublicNode feeHistory + Owlracle + Etherscan v2, # waits 2 blocks, computes realized p25/p50/p90 priority fee from the diff --git a/public/logos/allium.png b/public/logos/allium.png new file mode 100644 index 00000000..f3ed465d Binary files /dev/null and b/public/logos/allium.png differ diff --git a/public/logos/ankr.png b/public/logos/ankr.png new file mode 100644 index 00000000..64c8f9f5 Binary files /dev/null and b/public/logos/ankr.png differ diff --git a/public/logos/blockchair.png b/public/logos/blockchair.png new file mode 100644 index 00000000..13a5c6da Binary files /dev/null and b/public/logos/blockchair.png differ diff --git a/public/logos/blockscout.svg b/public/logos/blockscout.svg index 734b7318..3dadaf70 100644 --- a/public/logos/blockscout.svg +++ b/public/logos/blockscout.svg @@ -1,13 +1,3 @@ - - - - - - - - - - - - + + diff --git a/public/logos/chainstack.svg b/public/logos/chainstack.svg new file mode 100644 index 00000000..cf0f6b49 --- /dev/null +++ b/public/logos/chainstack.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logos/infura.png b/public/logos/infura.png new file mode 100644 index 00000000..ee81f843 Binary files /dev/null and b/public/logos/infura.png differ diff --git a/public/logos/megaeth.png b/public/logos/megaeth.png new file mode 100644 index 00000000..7311b295 Binary files /dev/null and b/public/logos/megaeth.png differ diff --git a/public/logos/monad.png b/public/logos/monad.png new file mode 100644 index 00000000..32bc56e6 Binary files /dev/null and b/public/logos/monad.png differ diff --git a/public/logos/oklink.png b/public/logos/oklink.png new file mode 100644 index 00000000..528dfdfd Binary files /dev/null and b/public/logos/oklink.png differ diff --git a/public/logos/onfinality.png b/public/logos/onfinality.png new file mode 100644 index 00000000..2a1ccf25 Binary files /dev/null and b/public/logos/onfinality.png differ diff --git a/public/logos/routescan.png b/public/logos/routescan.png new file mode 100644 index 00000000..fc043cf5 Binary files /dev/null and b/public/logos/routescan.png differ diff --git a/public/logos/subscan.png b/public/logos/subscan.png new file mode 100644 index 00000000..eef46569 Binary files /dev/null and b/public/logos/subscan.png differ diff --git a/public/logos/zerion.svg b/public/logos/zerion.svg new file mode 100644 index 00000000..2c5a5abb --- /dev/null +++ b/public/logos/zerion.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/api/builder/[slug]/daily-history/route.ts b/src/app/api/builder/[slug]/daily-history/route.ts index 0c5b678f..b096b817 100644 --- a/src/app/api/builder/[slug]/daily-history/route.ts +++ b/src/app/api/builder/[slug]/daily-history/route.ts @@ -83,7 +83,7 @@ export async function GET( day_unix: Math.floor(new Date(`${p.day}T00:00:00Z`).getTime() / 1000), fees_usd: p.fees, volume_usd: p.vol, - users: p.users ?? 0, + users: 0, })); const asOf = diff --git a/src/app/globals.css b/src/app/globals.css index a97dd466..97592e09 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -223,7 +223,7 @@ table { * * `.label-mono` remains for backward compat. The three sized variants below * harmonise the three near-identical Tailwind class strings repeated ~133 - * times across `src/**/*.tsx` (text-[10px]/[11px]/xs + 0.16em/0.18em + the + * times across src TSX files (text-[10px]/[11px]/xs + 0.16em/0.18em + the * matching ink ramp colour). Centralising them here means a copy-edit to * the tracking or color ramp lands in one place instead of every call site. */ .label-mono { diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index a2a85533..84bee9f5 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -9,7 +9,6 @@ import { loadAllAnswers } from "@/lib/answers"; import { CHAIN_BY_SLUG, CHAINS, getBenchmarksForChain } from "@/lib/chains"; import { canonicalChainSlug } from "@/lib/chain-aliases"; import { getProvider, getProviders, getProviderSlugs } from "@/lib/providers"; -import { isHlBuilderSlug } from "@/lib/hl-builder-stats"; import { CATEGORIES } from "@/lib/categories"; import { SITE } from "@/data/site"; import type { Benchmark } from "@/types/benchmark"; @@ -220,17 +219,10 @@ async function buildFullSitemap(): Promise { // so listing them in the sitemap pollutes it with permanent // redirects. The canonical /chains/ URLs are emitted below // by chainRoutes. - // - // Same treatment for HL frontend slugs (PR #841): /products/ - // 307-redirects to /hyperliquid/. Emitting the /products URL - // in the sitemap makes the deploy-time smoke test fail on redirect, - // and search engines see a permanent-redirect chain instead of a - // clean canonical. const validatedSlugs = ( await Promise.all( providerSlugs.map(async (slug) => { if (CHAIN_BY_SLUG.has(slug)) return null; - if (await isHlBuilderSlug(slug)) return null; const p = await getProvider(slug); return p ? slug : null; }), @@ -416,31 +408,23 @@ async function buildFullSitemap(): Promise { // Category hub pages. Closed enum from CATEGORIES; prerendered // routes that group benches by domain (Blockchains, Bridges, …). - // Skip empty categories: the page component 404s when no bench matches, - // which fails the deploy-time sitemap smoke test and blocks every CI - // deploy on main (observed 2026-07-03 with the Wallets category when - // it had zero live benches). Empty categories stay in the enum so the - // route lights up automatically the moment a bench populates it. // Per-URL = max lastRunAt of benches in the category. // Benchmark.category is the display label ("Aggregators"), CATEGORIES // entry has both label and slug — match on label. - const liveCategoryLabels = new Set(benchmarks.map((b) => b.category)); - const categoryRoutes: MetadataRoute.Sitemap = CATEGORIES - .filter((c) => liveCategoryLabels.has(c.label)) - .map((c) => { - const catBenches = benchmarks.filter((b) => b.category === c.label); - const last = catBenches.reduce((acc, b) => { - if (!b.lastRunAt) return acc; - const t = new Date(b.lastRunAt); - return t > acc ? t : acc; - }, new Date(0)); - return { - url: `${SITE.url}/benchmarks/category/${c.slug}`, - lastModified: last.getTime() > 0 ? last : catalogTs, - changeFrequency: "weekly" as const, - priority: 0.6, - }; - }); + const categoryRoutes: MetadataRoute.Sitemap = CATEGORIES.map((c) => { + const catBenches = benchmarks.filter((b) => b.category === c.label); + const last = catBenches.reduce((acc, b) => { + if (!b.lastRunAt) return acc; + const t = new Date(b.lastRunAt); + return t > acc ? t : acc; + }, new Date(0)); + return { + url: `${SITE.url}/benchmarks/category/${c.slug}`, + lastModified: last.getTime() > 0 ? last : catalogTs, + changeFrequency: "weekly" as const, + priority: 0.6, + }; + }); return [ ...staticRoutes, diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index cbaaaac1..bee4be25 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -196,16 +196,6 @@ export function LedgerTable({ // The chart's panel tabs still surface those providers via // seriesByProvider when the reader switches metric, so coverage isn't // lost — only the noisy ledger rows are pruned. - // Unresponsive cohort members: probed every cycle, (nearly) every call - // fails, so no latency percentile exists in the current view. Rendered - // as unranked, muted rows pinned BELOW the ranked field — visible with - // their success rate instead of silently vanishing when Prom staleness - // drops the latency series — and excluded from ranks, the field mean, - // and the data-bar scale so they can't distort any claim above. - const unresponsiveRows = [...results] - .filter((r) => r.unresponsive) - .sort((a, b) => b.successRate - a.successRate); - // Field-mean Δ — duplicated here so the sort comparator can rank by it // without waiting on per-Row computation. Kept aligned with the same // formula the Row uses for the displayed Δ% (see deltaPct below). @@ -260,6 +250,16 @@ export function LedgerTable({ } }; + // Unresponsive cohort members: probed every cycle, (nearly) every call + // fails, so no latency percentile exists in the current view. Rendered + // as unranked, muted rows pinned BELOW the ranked field — visible with + // their success rate instead of silently vanishing when Prom staleness + // drops the latency series — and excluded from ranks, the field mean, + // and the data-bar scale so they can't distort any claim above. + const unresponsiveRows = [...results] + .filter((r) => r.unresponsive) + .sort((a, b) => b.successRate - a.successRate); + const sortedAll = [...results] .filter((r) => { // Unresponsive rows render in their own unranked block below. diff --git a/src/components/provider-logo.tsx b/src/components/provider-logo.tsx index f843286d..6b1ea45e 100644 --- a/src/components/provider-logo.tsx +++ b/src/components/provider-logo.tsx @@ -92,6 +92,7 @@ const NEEDS_LIGHT_CHIP = new Set([ "merkle", "moralis", "nodies", + "zerion", ]); // White-on-transparent logos — invisible on a white chip. They get a diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index 05682982..f72751eb 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -85,6 +85,24 @@ export const PROVIDER_REGISTRY: Record = { "Solana RPC and data provider. Enhanced transactions, webhooks, DAS API for assets, and standard JSON-RPC.", twitter: "@heliuslabs", }, + zerion: { + url: "https://zerion.io/api", + description: + "Wallet data API behind the Zerion app. Transactions, balances, positions and portfolio across 25+ chains, sub-second indexing claims, free tier at 60k calls/month.", + twitter: "@zerion", + }, + allium: { + url: "https://www.allium.so", + description: + "Enterprise blockchain data platform. Real-time wallet and activity APIs across 100+ chains, plus SQL analytics; serves institutional data teams.", + twitter: "@alliumlabs", + }, + goldrush: { + url: "https://goldrush.dev", + description: + "Multi-chain wallet data API by Covalent. Transactions, balances and NFT data across 100+ chains with a unified schema; free tier at 100k credits/month.", + twitter: "@GoldRush_dev", + }, dune: { url: "https://dune.com", description: @@ -470,6 +488,48 @@ export const PROVIDER_REGISTRY: Record = { }, // ─── Public RPC providers ───────────────────────────────────── + "monad-official": { + url: "https://docs.monad.xyz", + description: + "Monad Foundation's primary public RPC (rpc.monad.xyz, QuickNode-backed, 25 rps). Four additional official mirrors run on Alchemy, Goldsky, Ankr and Foundation infrastructure.", + twitter: "@monad_xyz", + }, + "megaeth-official": { + url: "https://docs.megaeth.com", + description: + "MegaETH's official public RPC (mainnet.megaeth.com). Compute-unit and bandwidth limited; WebSocket endpoint exposes the 10 ms mini-block realtime API.", + twitter: "@megaeth_labs", + }, + onfinality: { + url: "https://onfinality.io", + description: + "Multi-chain infrastructure provider. Public keyless endpoints on 80+ networks with generous daily limits, plus dedicated and API-key tiers.", + twitter: "@OnFinality", + }, + quicknode: { + url: "https://www.quicknode.com", + description: + "Keyed RPC infrastructure across 30+ chains. Endpoint-scoped API tokens; free and paid plans are served from the same shared fleet, dedicated clusters on higher tiers.", + twitter: "@QuickNode", + }, + infura: { + url: "https://www.infura.io", + description: + "RPC infrastructure by Consensys, now part of the MetaMask Developer platform. Keyed endpoints across 40+ networks; free tier meters 3M credits per day.", + twitter: "@infura_io", + }, + ankr: { + url: "https://www.ankr.com/rpc/", + description: + "Multi-chain RPC service covering 65+ chains on the freemium tier. One API key across chains; some networks are premium-gated.", + twitter: "@ankr", + }, + chainstack: { + url: "https://chainstack.com", + description: + "Managed blockchain node platform. Deploys dedicated Global Nodes per chain with keyed HTTPS/WSS endpoints; free plan includes one node and 3M requests per month.", + twitter: "@ChainstackHQ", + }, publicnode: { url: "https://www.publicnode.com", description: diff --git a/src/lib/categories.ts b/src/lib/categories.ts index b1eaa988..d432767f 100644 --- a/src/lib/categories.ts +++ b/src/lib/categories.ts @@ -29,7 +29,8 @@ export type Category = | "Trading" | "Wallets" | "RPCs" - | "NFT APIs"; + | "NFT APIs" + | "Explorers"; export type CategoryEntry = { /** URL slug. Lowercase, kebab-case, ASCII. */ @@ -85,6 +86,13 @@ export const CATEGORIES: readonly CategoryEntry[] = [ description: "Live OpenChainBench measurements for RPC and node providers. Compare capability coverage, transaction landing latency, and reliability across the endpoints that power production dApps.", }, + { + slug: "explorers", + label: "Explorers", + heading: "Explorer benchmarks", + 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: "nft-apis", label: "NFT APIs", diff --git a/src/lib/category-colors.ts b/src/lib/category-colors.ts index a396ba02..e06035d5 100644 --- a/src/lib/category-colors.ts +++ b/src/lib/category-colors.ts @@ -13,6 +13,7 @@ export const CATEGORY_COLOR: Record = { Bridges: "var(--color-warn, #c08a3c)", Wallets: "#7a6db8", RPCs: "#5da0a3", + Explorers: "#7a6fa8", // NFT APIs: indigo, distinct from the warm orange/red accents already // in use. Picks up OpenSea/Alchemy/Moralis brand palettes which all // cluster around blues/purples. diff --git a/src/lib/chains.ts b/src/lib/chains.ts index fb0f424b..7bfa5d32 100644 --- a/src/lib/chains.ts +++ b/src/lib/chains.ts @@ -211,6 +211,23 @@ export const CHAINS: ChainEntry[] = [ description: "Based rollup. Ethereum L1 validators sequence Taiko blocks directly, inheriting L1 liveness and censorship resistance.", }, + // ─── Performance chains added 2026-07-08 (benches 071-072) ─── + { + slug: "monad", + label: "Monad", + category: "L1", + nativeSymbol: "MON", + description: + "Parallel-execution EVM Layer 1 (mainnet Nov 2025). 400 ms blocks, MonadBFT finality in about 800 ms, full EVM bytecode compatibility.", + }, + { + slug: "megaeth", + label: "MegaETH", + category: "L2", + nativeSymbol: "ETH", + description: + "Real-time Ethereum L2 (mainnet Feb 2026). 10 ms mini-blocks batched into 1 s EVM blocks, data availability on EigenDA, ZK fraud proofs via Kailua.", + }, // ─── Long-tail chains added with the 055-066 RPC cluster (2026-07-03) ─── { slug: "sonic", diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index e24e7e08..433f4e77 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -41,6 +41,9 @@ const RAW: Record = { opbnb: "/logos/bnb.png", aptos: "/logos/aptos.svg", sonic: "/logos/sonic.png", + monad: "/logos/monad.png", + megaeth: "/logos/megaeth.png", + onfinality: "/logos/onfinality.png", berachain: "/logos/berachain.png", // ─── Providers ─── @@ -96,6 +99,9 @@ const RAW: Record = { // ─── Public RPC providers ─── publicnode: "/logos/publicnode.avif", + infura: "/logos/infura.png", + ankr: "/logos/ankr.png", + chainstack: "/logos/chainstack.svg", drpc: "/logos/drpc.webp", "1rpc": "/logos/1rpc.svg", cloudflare: "/logos/cloudflare.svg", @@ -179,6 +185,17 @@ const RAW: Record = { coinpaprika: "/logos/coinpaprika.svg", coinstats: "/logos/coinstats.svg", + // ─── portfolio-chain-coverage bench providers (bench № 067) ─── + zerion: "/logos/zerion.svg", + allium: "/logos/allium.png", + goldrush: "/logos/covalent.svg", + + // ─── explorer-chain-coverage bench providers (bench № 068) ─── + routescan: "/logos/routescan.png", + blockchair: "/logos/blockchair.png", + subscan: "/logos/subscan.png", + oklink: "/logos/oklink.png", + // ─── Hyperliquid frontends (bench № 030) ─── "phantom-perps": "/logos/phantom-perps.svg", axiom: "/logos/axiom.png", @@ -338,6 +355,8 @@ const ALIASES: Record = { "optimism-official": "optimism", // Long-tail RPC cluster (055-066). "sonic-official": "sonic", + "monad-official": "monad", + "megaeth-official": "megaeth", "celo-official": "celo", "blast-official": "blast", "taiko-official": "taiko", diff --git a/src/lib/spec-schema.ts b/src/lib/spec-schema.ts index d57e8694..9f73f760 100644 --- a/src/lib/spec-schema.ts +++ b/src/lib/spec-schema.ts @@ -157,6 +157,7 @@ export const Category = z.enum([ "Wallets", "RPCs", "NFT APIs", + "Explorers", ]); // Em-dash (—) and en-dash (–) are the classic "AI tells" that hurt our brand diff --git a/src/lib/spec.ts b/src/lib/spec.ts index 90b95405..5bf4ce85 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -261,7 +261,9 @@ const loadBenchmarkUnfilteredCached = unstable_cache( // Cached v18 entries would silently drop dead providers instead. // v20: +12 long-tail RPC benches 055-066 (sonic…soneium). Bench SET // changed; cached v19 entries would miss the new chains. - ["bench-unfiltered-v20"], + // v21: +bench 067 (portfolio-chain-coverage). Bench SET changed. + // v22: +bench 068 (explorer-chain-coverage) + Explorers category. + ["bench-unfiltered-v22"], { revalidate: 300, tags: ["benchmarks"] }, ); @@ -408,7 +410,9 @@ const loadAllBenchmarksCached = unstable_cache( // v23: bumped with bench-unfiltered-v19 (unresponsive provider rows). // v24: bumped with bench-unfiltered-v20 (+12 long-tail RPC benches // 055-066; sitemap/citable/products must pick up the new slugs). - ["all-benchmarks-v24"], + // v25: bumped with bench-unfiltered-v21 (+bench 067 portfolio-chain-coverage). + // v26: bumped with bench-unfiltered-v22 (+bench 068 explorer-chain-coverage). + ["all-benchmarks-v26"], { revalidate: 300, tags: ["benchmarks"] }, ); export const loadAllBenchmarks = cache(loadAllBenchmarksCached); @@ -481,7 +485,9 @@ const loadBenchmarkFiltered = unstable_cache( // region variants flag providers dead on that slice). // v12: bumped with bench-unfiltered-v20 (+12 long-tail RPC benches // 055-066). - ["bench-filters-v12"], + // v13: bumped with bench-unfiltered-v21 (+bench 067 portfolio-chain-coverage). + // v14: bumped with bench-unfiltered-v22 (+bench 068 explorer-chain-coverage). + ["bench-filters-v14"], { revalidate: 300, tags: ["benchmarks"] } ); diff --git a/src/types/benchmark.ts b/src/types/benchmark.ts index 4bb759e4..db13e88d 100644 --- a/src/types/benchmark.ts +++ b/src/types/benchmark.ts @@ -208,7 +208,7 @@ export type Benchmark = { region?: { value: string; label: string }[]; kind?: { value: string; label: string }[]; }; - category: "Aggregators" | "Bridges" | "Blockchains" | "Trading" | "Wallets" | "RPCs" | "NFT APIs"; + category: "Aggregators" | "Bridges" | "Blockchains" | "Trading" | "Wallets" | "RPCs" | "NFT APIs" | "Explorers"; results: ProviderResult[]; /** Per-chain leader, computed only on the unfiltered ("All chains") view * when the spec declares `dimensions.chain`. Key = chain slug from the