diff --git a/.env.example b/.env.example index 804459876..3cc8887fe 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,18 @@ # Leave unset locally — the spec files already embed their URL. # PROMETHEUS_URL=https://prometheus-production-0859.up.railway.app -# Optional. Slack incoming webhook for the /api/report bug-report route. -# When unset the route silently no-ops. +# Where /api/report delivers a reader's bug report. Telegram is tried first +# and Slack is the fallback; with neither set the route answers 503 and the +# form says reporting is not configured. +# +# TELEGRAM_CHAT_ID is the destination, not the bot: send the bot any message, +# then read it from +# https://api.telegram.org/bot/getUpdates -> result[].message.chat.id +# A personal chat id is a positive integer; a group's is negative. +# TELEGRAM_BOT_TOKEN=123456:AA... +# TELEGRAM_CHAT_ID=123456789 +# +# Legacy. Still honoured when Telegram is unset. # SLACK_REPORT_WEBHOOK_URL=https://hooks.slack.com/services/... # Optional. Live-page WebSocket relay. Public (NEXT_PUBLIC_) so it's baked diff --git a/benchmarks/arbitrum-rpc.yml b/benchmarks/arbitrum-rpc.yml index e708250c6..5fe0d0a37 100644 --- a/benchmarks/arbitrum-rpc.yml +++ b/benchmarks/arbitrum-rpc.yml @@ -3,9 +3,9 @@ slug: arbitrum-rpc number: "045" title: "Arbitrum RPC endpoints: free public URLs and private API-key providers by latency" -seo_title: "Arbitrum RPC endpoints: public and private, ranked by latency" +seo_title: "Arbitrum RPC endpoints: public and private, by latency" seo_description: "{{best_name}} leads free Arbitrum RPC at {{best_p50}} (p50, 24h); URLs for all {{count}} no-key endpoints. {{best_name:tier:keyed}} leads the private (API-key) cohort at {{best_p50:tier:keyed}}." -subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Arbitrum RPC endpoint (probed every 60 seconds) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock and QuickNode (every 120 seconds), from 3 regions. The two cohorts are ranked separately, never against each other. +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Arbitrum RPC endpoint (probed every 60 seconds) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock, QuickNode and Dwellir (every 120 seconds), from 3 regions. The two cohorts are ranked separately, never against each other. category: RPCs status: live @@ -17,7 +17,7 @@ seo_intro: | Arbitrum is the second-largest cohort in the cluster: {{count}} 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 median of {{p50:arbitrum-official}} with a p99 of {{p99:arbitrum-official}} over the last 24 hours. Every provider answers the identical `eth_getBlockByNumber` probe every 60 seconds from us-east, eu-west and Singapore. abstract: | - Arbitrum: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}; the chain-official endpoint measures {{p50:arbitrum-official}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock and QuickNode on their keyed Arbitrum endpoints every 120 seconds: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. + Arbitrum: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}; the chain-official endpoint measures {{p50:arbitrum-official}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock, QuickNode and Dwellir on their keyed Arbitrum endpoints every 120 seconds: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. 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, {{count}} providers, every 60 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: @@ -25,7 +25,7 @@ methodology: - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}`. Plain HTTP POST, identical for every endpoint, no API key in any request. The request id rotates, but that does not guarantee a cache miss: many caches normalise `id` away. Measured 2026-10-08, fixed and rotating ids answer within 2 to 4 percent of each other." - "Latency measurement, call-result classification (`ok`, `http_err`, `jsonrpc_err`, `stale`, `timeout`) and the archive-depth audit follow the cluster method documented on [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities#methodology); this page is the Arbitrum-scoped view of the same harness, with the same exclusion rules." - "Chain scope: every query on this page is pinned to chain=\"arbitrum\". Provider coverage: {{count}} no-key public-read endpoints live today, each listed with its URL in the endpoints table on this page. Exclusions follow the cluster-wide rules documented on the parent benchmark." - - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock and QuickNode probed on their keyed Arbitrum endpoints every 120 seconds from the same 3 regions, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." + - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock, QuickNode and Dwellir probed on their keyed Arbitrum endpoints every 120 seconds from the same 3 regions, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." - "Keys live in the probe environment, never in the repo. Plan tiers, disclosed: Alchemy: a Free plan key (30M CU per month, 300 compute units per second), the tier any reader gets; Alchemy states free and paid keys share one routing; Chainstack: a Global Node on the Growth plan; QuickNode: an endpoint on a Build plan ($49 per month), shared fleet; GetBlock: an endpoint on the Enterprise plan, shared by the three probe regions. Chainstack, GetBlock and QuickNode provided their endpoints." - "Dwellir plan tier, disclosed: a Developer plan key ($49 per month, 25 million API responses, 100 responses per second; 1 RPC response = 1 API credit, no compute units). Shared nodes, the same product a reader buys. Dwellir publishes no Solana endpoint, so it is absent from that page rather than shown as unavailable." - "GetBlock regional pairing: each probe region uses GetBlock's nearest endpoint, us-east to New York, eu-west to Frankfurt, sgp to Singapore. Alchemy, Chainstack and QuickNode publish one routed entry point per chain instead, which in the general case does land a probe on a nearby node. Some of any gap between the two arrangements is deployment topology rather than node speed, and a deployment in one of these regions would see the same benefit GetBlock's endpoint gives the probe." @@ -252,6 +252,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="arbitrum",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="alchemy",chain="arbitrum",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="alchemy",chain="arbitrum",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="alchemy",chain="arbitrum",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="alchemy",chain="arbitrum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="arbitrum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="arbitrum",tier="keyed"}) @@ -276,6 +277,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="arbitrum",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="chainstack",chain="arbitrum",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="chainstack",chain="arbitrum",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="chainstack",chain="arbitrum",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="chainstack",chain="arbitrum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="arbitrum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="arbitrum",tier="keyed"}) @@ -300,6 +302,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="getblock",chain="arbitrum",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="getblock",chain="arbitrum",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="getblock",chain="arbitrum",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="getblock",chain="arbitrum",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="getblock",chain="arbitrum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="arbitrum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="arbitrum",tier="keyed"}) @@ -324,6 +327,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="arbitrum",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="quicknode",chain="arbitrum",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="quicknode",chain="arbitrum",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="quicknode",chain="arbitrum",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="quicknode",chain="arbitrum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="arbitrum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="arbitrum",tier="keyed"}) @@ -348,6 +352,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir",chain="arbitrum",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="dwellir",chain="arbitrum",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="dwellir",chain="arbitrum",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="dwellir",chain="arbitrum",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="dwellir",chain="arbitrum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="arbitrum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="arbitrum",tier="keyed"}) @@ -372,6 +377,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="alchemy",chain="arbitrum",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="alchemy",chain="arbitrum",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="alchemy",chain="arbitrum",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="alchemy",chain="arbitrum",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="alchemy",chain="arbitrum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="arbitrum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="arbitrum",tier="keyed"}) @@ -395,6 +401,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="chainstack",chain="arbitrum",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="chainstack",chain="arbitrum",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="chainstack",chain="arbitrum",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="chainstack",chain="arbitrum",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="chainstack",chain="arbitrum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="arbitrum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="arbitrum",tier="keyed"}) @@ -418,6 +425,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="getblock",chain="arbitrum",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="getblock",chain="arbitrum",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="getblock",chain="arbitrum",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="getblock",chain="arbitrum",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="getblock",chain="arbitrum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="arbitrum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="arbitrum",tier="keyed"}) @@ -441,6 +449,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="quicknode",chain="arbitrum",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="quicknode",chain="arbitrum",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="quicknode",chain="arbitrum",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="quicknode",chain="arbitrum",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="quicknode",chain="arbitrum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="arbitrum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="arbitrum",tier="keyed"}) @@ -464,6 +473,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="dwellir",chain="arbitrum",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="dwellir",chain="arbitrum",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="dwellir",chain="arbitrum",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="dwellir",chain="arbitrum",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="dwellir",chain="arbitrum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="arbitrum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="arbitrum",tier="keyed"}) @@ -479,3 +489,4 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="dwellir",chain="arbitrum",tier="keyed",region="sgp"}) series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="dwellir",chain="arbitrum",tier="keyed",region="sgp"}[1h])) + diff --git a/benchmarks/arc-rpc-ws.yml b/benchmarks/arc-rpc-ws.yml new file mode 100644 index 000000000..f181f9b52 --- /dev/null +++ b/benchmarks/arc-rpc-ws.yml @@ -0,0 +1,174 @@ +# OpenChainBench. Bench № 303 + +slug: arc-rpc-ws +number: "303" +title: Fastest private Arc WebSocket push, per-block race across keyed RPC providers +seo_title: "Fastest Arc WebSocket RPC push 2026, private endpoints" +seo_description: "{{best_name}} has the lowest median lag behind the first Arc block push among Chainstack, GetBlock, QuickNode and Dwellir." +subtitle: Per-block WebSocket push lag in milliseconds versus the first provider to deliver the same Arc block, across API-key endpoints probed from us-east, eu-west and Singapore. + +category: RPCs +status: live +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-keyed-latency +metric: Push lag +unit: ms +higher_is_better: false + +seo_intro: | + A bot that subscribes to eth_subscribe(newHeads) does not care what a + provider's median HTTP latency is. It cares who hands it block N first, + and by how much the others trail. This page races the private (Arc) + cohort: one WebSocket per provider per region, open ten minutes in every + hour across the whole cohort, every frame + timestamped on arrival, each block scored against whichever provider + delivered it earliest. {{best_name}} currently leads at {{best_p50}} + (p50, 24h) across {{count}} keyed providers. + + Because the measurement is relative, the leader reads near zero by + construction. The numbers to read are the gap to the runner-up and the + shape of the trailing distribution, p50 against p99, not the leader's + absolute figure. + +abstract: | + Private, API-key Arc endpoints raced on WebSocket delivery. One harness + holds an eth_subscribe(newHeads) subscription per provider in + each of three regions and timestamps every push on receipt. Per block, the + earliest arrival across providers in that region defines T0; each provider's + sample is its own arrival minus T0, in milliseconds. + + The same process runs the HTTP arm of the keyed cohort, which is deliberate: + comparing arrival times recorded by separate processes would compare clocks + as much as providers, and the differences here are tens of milliseconds. + +methodology: + - "Race, not latency: all providers on a chain wait for the same block, so only the spread between them belongs to them. Time from block production to first arrival is the chain's block time and is not published as a provider result." + - "One socket per provider per region, eth_subscribe(newHeads), opened for the same ten minutes of every hour across the whole cohort and closed with the window. Every frame is timestamped on receipt. The window is aligned to the clock rather than to each connection, because a race only means something if every provider is listening to the same blocks." + - "Sampled rather than continuous since 2026-10-17, and the reason is ours: a permanent subscription on nine chains pushed about 138 million messages a month per provider against 583 thousand HTTP probes, and one vendor reached its monthly limit. The three fastest chains left the arm and the rest listen a sixth of the time. Racing a provider until its account is exhausted would remove it from nine other pages." + - "Histogram buckets floor at 0.5 ms. The winner reads 0 by construction, so a coarse first bucket collapses every leader's p50 into an interpolation artifact inside it." + - "Reconnections are a published result, not an implementation detail: a provider that drops twice in two minutes is unusable to a subscriber whatever its median says." + - "GetBlock does not offer this chain from Singapore, so it is raced from us-east and eu-west only and its Singapore column is empty by provision rather than by failure." + - "Alchemy is measured on the HTTP cohort but not here, and the reason is ours rather than theirs: its endpoint bills WebSocket per message, these subscriptions pushed about 116 million frames a month against the Free plan key that row discloses, and the key is shared with the HTTP probes. Racing a provider until its account is exhausted would remove it from nine other pages, so it waits for a message budget guard." + - "Validation Cloud is listed on the HTTP cohorts of this chain but not here: its WebSocket endpoint refuses the upgrade in all three regions (`websocket: bad handshake`), so there is nothing to race. Measured over 3h on 2026-10-17: 542 reconnection attempts, zero frames. The row returns when the endpoint opens." + - "Same keyed endpoints as the HTTP cohort on this chain, same keys, same three regions. Keys live in the probe environment, never in this repository. Plan tiers are disclosed on the HTTP cohort's methodology." + +expected_n: 78000 + +prometheus: + window: 24h + freshness_metric: rpc_ws_head_seen_total + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is fixed for +# the whole bench, so cells key on provider and region. +rank_matrix_query: histogram_quantile(0.50, sum by (provider, region, le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{chain="arc",tier="keyed"})) + +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +faq: + - q: "Which private RPC provider pushes new Arc blocks first over WebSocket?" + a: "{{best_name}} has the lowest median lag behind the first arrival over 24h; a figure inside the first histogram bucket reads as zero. {{worst_name}} trails most, at {{worst_p50}} (p50, 24h). The board re-scores on every Arc block. Read the p99 column before wiring a latency-sensitive bot to one provider." + - q: "Why does the fastest provider show a lag near zero?" + a: "By construction. Each block is a race: the earliest arrival defines T0 and every sample is measured against it, so the provider that wins most races accumulates mostly zero samples. A single vantage point cannot measure absolute production-to-client latency without a node co-located with block producers; it can measure fairly which provider delivers the same block earlier over the same path. Read the gap to the runner-up and the trailing distribution, not the leader's absolute number." + - q: "Is this the same as the HTTP latency cohort on this chain?" + a: "Same providers, same keys, same regions, different question. The HTTP cohort measures a request you send; this measures a push you wait for, and the two orderings are not the same. GetBlock does not offer this chain from Singapore, so it is raced from us-east and eu-west only and its Singapore column is empty by provision rather than by failure." + - q: "Could a provider win by pushing blocks before validating them?" + a: "In principle, at the cost of occasionally pushing something that gets retracted. That is why coverage is published next to the lag: a provider that pushes early but wrong shows gaps, and the coverage column counts blocks seen against the cohort maximum. Hash-level cross-checking against an independent reference is not in this version." + +providers: + - slug: chainstack + name: Chainstack + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Arc block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="arc",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="arc",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="arc",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="arc",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="chainstack",chain="arc",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="chainstack",chain="arc",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="chainstack",chain="arc",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="arc",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="chainstack",chain="arc",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="arc",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="arc",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="arc",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="arc",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="arc",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="arc",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="arc",tier="keyed",region="sgp"}[1h]))) + + - slug: getblock + name: GetBlock + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Arc block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="arc",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="arc",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="arc",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="arc",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="getblock",chain="arc",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="getblock",chain="arc",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="getblock",chain="arc",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="arc",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="getblock",chain="arc",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="arc",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="arc",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="arc",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="arc",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="arc",tier="keyed",region="eu-west"}[1h]))) + + - slug: quicknode + name: QuickNode + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Arc block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="arc",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="arc",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="arc",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="arc",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="quicknode",chain="arc",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="quicknode",chain="arc",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="quicknode",chain="arc",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="arc",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="quicknode",chain="arc",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="arc",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="arc",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="arc",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="arc",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="arc",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="arc",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="arc",tier="keyed",region="sgp"}[1h]))) + + - slug: dwellir + name: Dwellir + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Arc block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="arc",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="arc",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="arc",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="arc",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="dwellir",chain="arc",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="dwellir",chain="arc",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="dwellir",chain="arc",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="arc",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="dwellir",chain="arc",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="arc",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="arc",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="arc",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="arc",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="arc",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="arc",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="arc",tier="keyed",region="sgp"}[1h]))) + diff --git a/benchmarks/arc-rpc.yml b/benchmarks/arc-rpc.yml index 6c36a4bf9..50fb62491 100644 --- a/benchmarks/arc-rpc.yml +++ b/benchmarks/arc-rpc.yml @@ -5,7 +5,7 @@ number: "270" title: "Arc RPC endpoints: free public URLs and private API-key providers by latency" seo_title: "Arc RPC endpoints: public and private, ranked by latency" seo_description: "{{best_name}} leads free Arc RPC at {{best_p50}} (p50, 24h); URLs for all {{count}} no-key endpoints. {{best_name:tier:keyed}} leads the private (API-key) cohort at {{best_p50:tier:keyed}}." -subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Arc RPC endpoint (probed every 60 seconds from 3 regions) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock and QuickNode (every 120 seconds from us-east and eu-west). The two cohorts are ranked separately, never against each other. +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Arc RPC endpoint (probed every 60 seconds from 3 regions) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock, QuickNode and Dwellir (every 120 seconds). The two cohorts are ranked separately, never against each other. category: RPCs status: live @@ -22,28 +22,29 @@ seo_intro: | {{count}} endpoints that answer without a key, every row of the table in the chain's own docs included, from us-east, eu-west and Singapore, and every 120 seconds on the keyed endpoints of Alchemy, Chainstack, GetBlock - and QuickNode, from us-east and eu-west: GetBlock does not offer Arc in + and QuickNode, plus Dwellir, from the same three regions. The keyed + headline averages us-east and eu-west only: GetBlock does not offer Arc in Singapore, and averaging a two-region row against three-region rows would - compare different populations, so the keyed cohort is measured from the two - regions the chain is served from. The Access selector switches between the + compare different populations. Singapore is published per region for the + five vendors that answer the Singapore probe. The Access selector switches between the two cohorts, which are never ranked against each other. abstract: | - Arc: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}; the Circle-operated endpoint measures {{p50:arc-official}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock and QuickNode on their keyed Arc endpoints every 120 seconds from us-east and eu-west: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. - We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Arc endpoint that sustains continuous probing, {{count}} providers, every 60 seconds, from us-east, eu-west and Singapore, and against the keyed endpoints of Alchemy, Chainstack, GetBlock and QuickNode every 120 seconds from us-east and eu-west, the two regions the chain is served from. 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. Arc produces blocks in well under a second; the stale check tolerates 40 blocks (20 s) on the public cohort and 20 on the keyed one. + Arc: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}; the Circle-operated endpoint measures {{p50:arc-official}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock, QuickNode and Dwellir on their keyed Arc endpoints every 120 seconds from us-east and eu-west: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. + We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Arc endpoint that sustains continuous probing, {{count}} providers, every 60 seconds, from us-east, eu-west and Singapore, and against the keyed endpoints of Alchemy, Chainstack, GetBlock, QuickNode and Dwellir every 120 seconds from the same three regions, the keyed headline averaging us-east and eu-west because GetBlock has no Arc endpoint in 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. Arc produces blocks in well under a second; the stale check tolerates 40 blocks (20 s) on the public cohort and 20 on the keyed one. methodology: - - "Cadence: the public cohort every 60 seconds per provider from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore), its headline p50/p90/p99 aggregating all 3 via Prometheus `avg(quantile_over_time(...))`. The keyed cohort runs every 120 seconds from us-east and eu-west and aggregates those two. Per-region breakdowns are first-class via the region tabs." + - "Cadence: the public cohort every 60 seconds per provider from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore), its headline p50/p90/p99 aggregating all 3 via Prometheus `avg(quantile_over_time(...))`. The keyed cohort runs every 120 seconds from the same 3 regions; its headline averages us-east and eu-west only, for the GetBlock coverage reason below. 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. The request id rotates, but that does not guarantee a cache miss: many caches normalise `id` away. Measured 2026-10-08, fixed and rotating ids answer within 2 to 4 percent of each other." - "Latency measurement, call-result classification (`ok`, `http_err`, `jsonrpc_err`, `stale`, `timeout`) and the archive-depth audit follow the cluster method documented on [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities#methodology); this page is the Arc-scoped view of the same harness, with the same exclusion rules." - "Chain scope: every query on this page is pinned to chain=\"arc\" (chain id 5042). Provider coverage: {{count}} no-key public-read endpoints live today, each listed with its URL in the endpoints table on this page. Exclusions follow the cluster-wide rules documented on the parent benchmark." - "Endpoints published by the chain: the RPC endpoints page of the Arc docs lists Circle's primary endpoint plus Alchemy, Blockdaemon, dRPC and QuickNode. The Blockdaemon and QuickNode rows are Arc-branded URLs (rpc.blockdaemon.mainnet.arc.io, rpc.quicknode.mainnet.arc.io) that answer without a key, verified before inclusion, and are measured here as such." - "The dRPC row of that table (rpc.drpc.mainnet.arc.io) serves the same free tier as arc.drpc.org, identical x-drpc-owner-id, and is measured once under dRPC; the Alchemy row needs a key and sits in the private cohort." - - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock and QuickNode probed on their keyed Arc endpoints every 120 seconds from us-east and eu-west, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." + - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock, QuickNode and Dwellir probed on their keyed Arc endpoints every 120 seconds from all 3 probe regions, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." - "Keys live in the probe environment, never in the repo. Plan tiers, disclosed: Chainstack: a Global Node on the Growth plan; QuickNode: an endpoint on a Build plan ($49 per month), shared fleet; Alchemy: a Free plan key (30M CU per month, 300 compute units per second), the tier any reader gets; Alchemy states free and paid keys share one routing; GetBlock: an endpoint on the Enterprise plan, shared by the two probe regions. Chainstack, GetBlock and QuickNode provided their endpoints." - "Dwellir plan tier, disclosed: a Developer plan key ($49 per month, 25 million API responses, 100 responses per second; 1 RPC response = 1 API credit, no compute units). Shared nodes, the same product a reader buys. Dwellir publishes no Solana endpoint, so it is absent from that page rather than shown as unavailable." - "GetBlock regional pairing: us-east uses GetBlock's New York endpoint and eu-west its Frankfurt endpoint. Alchemy, Chainstack and QuickNode publish one routed entry point per chain instead, which in the general case does land a probe on a nearby node. Some of any gap between the two arrangements is deployment topology rather than node speed, and a deployment in one of these regions would see the same benefit GetBlock's endpoint gives the probe." - - "GetBlock coverage: GetBlock does not offer Arc in Singapore, so no GetBlock row exists for that region: the endpoint is not offered there, which is not a failed or pending measurement. The whole keyed cohort is therefore measured from us-east and eu-west, because averaging a two-region row against three-region rows would compare different populations." + - "GetBlock coverage: GetBlock does not offer Arc in Singapore, so no GetBlock figure exists for that region: the endpoint is not offered there, which is not a failed or pending measurement. The keyed headline therefore averages us-east and eu-west for every vendor, because averaging a two-region row against three-region rows would compare different populations. Singapore is published per region for the five vendors that answer the Singapore probe." - "Two cohorts, two rankings: the public endpoints are shared, rate-limited gateways probed every 60 seconds; the keyed endpoints are metered or dedicated and probed every 120 seconds. The selector switches the whole page (ranking, chart, region tabs, badges, citation) between them. No figure on this page compares a public endpoint with a keyed one." - "Arc produces blocks in well under a second (Malachite BFT, sub-second finality). The stale check tolerates 40 blocks behind the cross-provider tip (20 s of wall clock) on the public cohort and 20 blocks on the keyed one." @@ -56,7 +57,7 @@ faq: - q: "Which Arc RPCs work without an API key?" a: "{{declared_count}} endpoints are probed on this chain; each is listed with its URL, 24h median and success rate in the endpoints table on this page, and the list changes as providers come and go. Every (provider, chain) pair was live-verified no-key before inclusion; anything that key-gates, region-blocks or rate-limits below the 60-second cadence is excluded rather than listed with an asterisk." - q: "Which Arc RPC provider is fastest with an API key?" - a: "Per the live data: {{best_name:tier:keyed}} at {{best_p50:tier:keyed}} (eth_getBlockByNumber p50 over 24h, averaged across us-east and eu-west); Alchemy measures {{p50:alchemy}}, Chainstack {{p50:chainstack}} and QuickNode {{p50:quicknode}}; GetBlock measures {{p50:getblock}}. The keyed cohort is measured from those two regions only, because GetBlock does not offer Arc in Singapore and averaging a two-region row against three-region rows would compare different populations; no keyed row exists for the Singapore tab, which is a coverage fact rather than a failed measurement. On the free cohort {{best_name}} leads at {{best_p50}}, measured on the same call from 3 regions; the two cohorts are never ranked in one table (shared gateways at 60 s against metered endpoints at 120 s). Switch the Access selector to Private for the region tabs and the p99 column; plan tiers are disclosed in the methodology." + a: "Per the live data: {{best_name:tier:keyed}} at {{best_p50:tier:keyed}} (eth_getBlockByNumber p50 over 24h, averaged across us-east and eu-west); Alchemy measures {{p50:alchemy}}, Chainstack {{p50:chainstack}} and QuickNode {{p50:quicknode}}; GetBlock measures {{p50:getblock}}. The headline averages those two regions only, because GetBlock does not offer Arc in Singapore and averaging a two-region row against three-region rows would compare different populations; the Singapore figures are published per region for the five vendors that answer that probe, and GetBlock's absence there is a coverage fact rather than a failed measurement. On the free cohort {{best_name}} leads at {{best_p50}}, measured on the same call from 3 regions; the two cohorts are never ranked in one table (shared gateways at 60 s against metered endpoints at 120 s). Switch the Access selector to Private for the region tabs and the p99 column; plan tiers are disclosed in the methodology." - q: "What is Arc?" a: "Arc (chain ID 5042) is Circle's EVM Layer 1 for stablecoin finance: USDC is the gas token, consensus is Malachite BFT with sub-second deterministic finality, and the validator set is permissioned (BlackRock, DTCC, Visa, Mastercard and ICE among the founding validators). Mainnet opened on 2026-09-16; it supports standard EVM tooling and eth_ JSON-RPC methods." @@ -241,6 +242,11 @@ providers: # rebuilt to remove, so the whole cohort is measured from us-east and # eu-west. The public cohort above is untouched and still spans 3 regions. # + # Each vendor that answers from Singapore still carries an explicit + # `- region: sgp` entry below. Those are separate queries from the headline, + # so the hub and the geo view can show the Singapore measurement without it + # entering the two-region average the ranking rests on. + # # Written as a negative matcher, not region=~"us-east|eu-west", and that is # load-bearing: injectLabels() treats any `region=` (including `=~`) as # already filtered and would then inject nothing, so every region tab would @@ -255,6 +261,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="arc",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="alchemy",chain="arc",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="alchemy",chain="arc",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="alchemy",chain="arc",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="alchemy",chain="arc",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="arc",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="arc",tier="keyed",region!="sgp"}) @@ -267,6 +274,9 @@ providers: - region: eu-west p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="arc",tier="keyed",region="eu-west"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="alchemy",chain="arc",tier="keyed",region="eu-west"}[1h])) + - region: sgp + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="arc",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="alchemy",chain="arc",tier="keyed",region="sgp"}[1h])) - slug: chainstack name: Chainstack @@ -276,6 +286,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="arc",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="chainstack",chain="arc",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="chainstack",chain="arc",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="chainstack",chain="arc",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="chainstack",chain="arc",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="arc",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="arc",tier="keyed",region!="sgp"}) @@ -288,6 +299,9 @@ providers: - region: eu-west p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="arc",tier="keyed",region="eu-west"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="chainstack",chain="arc",tier="keyed",region="eu-west"}[1h])) + - region: sgp + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="arc",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="chainstack",chain="arc",tier="keyed",region="sgp"}[1h])) - slug: getblock name: GetBlock @@ -297,6 +311,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="getblock",chain="arc",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="getblock",chain="arc",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="getblock",chain="arc",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="getblock",chain="arc",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="getblock",chain="arc",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="arc",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="arc",tier="keyed",region!="sgp"}) @@ -318,6 +333,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="arc",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="quicknode",chain="arc",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="quicknode",chain="arc",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="quicknode",chain="arc",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="quicknode",chain="arc",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="arc",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="arc",tier="keyed",region!="sgp"}) @@ -330,6 +346,9 @@ providers: - region: eu-west p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="arc",tier="keyed",region="eu-west"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="quicknode",chain="arc",tier="keyed",region="eu-west"}[1h])) + - region: sgp + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="arc",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="quicknode",chain="arc",tier="keyed",region="sgp"}[1h])) - slug: dwellir name: Dwellir @@ -339,6 +358,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir",chain="arc",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="dwellir",chain="arc",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="dwellir",chain="arc",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="dwellir",chain="arc",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="dwellir",chain="arc",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="arc",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="arc",tier="keyed"}) @@ -363,6 +383,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="alchemy",chain="arc",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="alchemy",chain="arc",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="alchemy",chain="arc",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="alchemy",chain="arc",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="alchemy",chain="arc",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="arc",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="arc",tier="keyed",region!="sgp"}) @@ -375,6 +396,9 @@ providers: - region: eu-west p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="alchemy",chain="arc",tier="keyed",region="eu-west"}) series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="alchemy",chain="arc",tier="keyed",region="eu-west"}[1h])) + - region: sgp + p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="alchemy",chain="arc",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="alchemy",chain="arc",tier="keyed",region="sgp"}[1h])) - slug: chainstack-cold name: Chainstack tier: keyed-cold @@ -383,6 +407,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="chainstack",chain="arc",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="chainstack",chain="arc",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="chainstack",chain="arc",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="chainstack",chain="arc",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="chainstack",chain="arc",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="arc",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="arc",tier="keyed",region!="sgp"}) @@ -395,6 +420,9 @@ providers: - region: eu-west p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="chainstack",chain="arc",tier="keyed",region="eu-west"}) series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="chainstack",chain="arc",tier="keyed",region="eu-west"}[1h])) + - region: sgp + p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="chainstack",chain="arc",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="chainstack",chain="arc",tier="keyed",region="sgp"}[1h])) - slug: getblock-cold name: GetBlock tier: keyed-cold @@ -403,6 +431,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="getblock",chain="arc",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="getblock",chain="arc",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="getblock",chain="arc",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="getblock",chain="arc",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="getblock",chain="arc",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="arc",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="arc",tier="keyed",region!="sgp"}) @@ -423,6 +452,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="quicknode",chain="arc",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="quicknode",chain="arc",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="quicknode",chain="arc",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="quicknode",chain="arc",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="quicknode",chain="arc",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="arc",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="arc",tier="keyed",region!="sgp"}) @@ -435,6 +465,9 @@ providers: - region: eu-west p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="quicknode",chain="arc",tier="keyed",region="eu-west"}) series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="quicknode",chain="arc",tier="keyed",region="eu-west"}[1h])) + - region: sgp + p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="quicknode",chain="arc",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="quicknode",chain="arc",tier="keyed",region="sgp"}[1h])) - slug: dwellir-cold name: Dwellir tier: keyed-cold @@ -443,6 +476,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="dwellir",chain="arc",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="dwellir",chain="arc",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="dwellir",chain="arc",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="dwellir",chain="arc",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="dwellir",chain="arc",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="arc",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="arc",tier="keyed"}) @@ -458,3 +492,4 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="dwellir",chain="arc",tier="keyed",region="sgp"}) series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="dwellir",chain="arc",tier="keyed",region="sgp"}[1h])) + diff --git a/benchmarks/astar-rpc.yml b/benchmarks/astar-rpc.yml index e9e3e2b35..0ae486b86 100644 --- a/benchmarks/astar-rpc.yml +++ b/benchmarks/astar-rpc.yml @@ -70,26 +70,51 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="astar", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="astar", region="sgp"}[1h]) - - slug: 1rpc - name: 1RPC - endpoint: https://1rpc.io/astr - tag: Privacy-first RPC relay, Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Astar endpoint." + - slug: astar-official + name: Astar + endpoint: https://evm.astar.network + tag: Astar Foundation official public RPC + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Astar's no-key Astar Network endpoint." queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="astar"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="astar"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="1rpc", chain="astar"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="1rpc", chain="astar"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="1rpc", chain="astar"}) / sum(ocb:rpc_call:rate_24h{provider="1rpc", chain="astar"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="1rpc", chain="astar"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="astar"}[1h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="astar-official", chain="astar"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="astar-official", chain="astar"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="astar-official", chain="astar"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="astar-official", chain="astar"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="astar-official", chain="astar"}) / sum(ocb:rpc_call:rate_24h{provider="astar-official", chain="astar"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="astar-official", chain="astar"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="astar-official", chain="astar"}[1h])) regions: - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="astar", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="astar", region="us-east"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="astar-official", chain="astar", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="astar-official", chain="astar", region="us-east"}[1h]) - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="astar", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="astar", region="eu-west"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="astar-official", chain="astar", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="astar-official", chain="astar", region="eu-west"}[1h]) - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="astar", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="astar", region="sgp"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="astar-official", chain="astar", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="astar-official", chain="astar", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + endpoint: https://astar.drpc.org + 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 60s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Astar Network endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="astar"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="astar"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="astar"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="astar"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="astar"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="astar"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="astar"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="astar"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="astar", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="astar", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="astar", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="astar", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="astar", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="astar", region="sgp"}[1h]) + diff --git a/benchmarks/base-rpc-ws.yml b/benchmarks/base-rpc-ws.yml new file mode 100644 index 000000000..a69818818 --- /dev/null +++ b/benchmarks/base-rpc-ws.yml @@ -0,0 +1,177 @@ +# OpenChainBench. Bench № 297 + +slug: base-rpc-ws +number: "297" +title: Fastest private Base WebSocket push, per-block race across keyed RPC providers +seo_title: "Fastest Base WebSocket RPC push 2026, private endpoints" +seo_description: "{{best_name}} has the lowest median lag behind the first Base block push among Chainstack, GetBlock, QuickNode and Dwellir." +subtitle: Per-block WebSocket push lag in milliseconds versus the first provider to deliver the same Base block, across API-key endpoints probed from us-east, eu-west and Singapore. + +category: RPCs +status: live +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-keyed-latency +metric: Push lag +unit: ms +higher_is_better: false + +seo_intro: | + A bot that subscribes to eth_subscribe(newHeads) does not care what a + provider's median HTTP latency is. It cares who hands it block N first, + and by how much the others trail. This page races the private (Base) + cohort: one WebSocket per provider per region, open ten minutes in every + hour across the whole cohort, every frame + timestamped on arrival, each block scored against whichever provider + delivered it earliest. {{best_name}} currently leads at {{best_p50}} + (p50, 24h) across {{count}} keyed providers. + + Because the measurement is relative, the leader reads near zero by + construction. The numbers to read are the gap to the runner-up and the + shape of the trailing distribution, p50 against p99, not the leader's + absolute figure. + +abstract: | + Private, API-key Base endpoints raced on WebSocket delivery. One harness + holds an eth_subscribe(newHeads) subscription per provider in + each of three regions and timestamps every push on receipt. Per block, the + earliest arrival across providers in that region defines T0; each provider's + sample is its own arrival minus T0, in milliseconds. + + The same process runs the HTTP arm of the keyed cohort, which is deliberate: + comparing arrival times recorded by separate processes would compare clocks + as much as providers, and the differences here are tens of milliseconds. + +methodology: + - "Race, not latency: all providers on a chain wait for the same block, so only the spread between them belongs to them. Time from block production to first arrival is the chain's block time and is not published as a provider result." + - "One socket per provider per region, eth_subscribe(newHeads), opened for the same ten minutes of every hour across the whole cohort and closed with the window. Every frame is timestamped on receipt. The window is aligned to the clock rather than to each connection, because a race only means something if every provider is listening to the same blocks." + - "Sampled rather than continuous since 2026-10-17, and the reason is ours: a permanent subscription on nine chains pushed about 138 million messages a month per provider against 583 thousand HTTP probes, and one vendor reached its monthly limit. The three fastest chains left the arm and the rest listen a sixth of the time. Racing a provider until its account is exhausted would remove it from nine other pages." + - "Histogram buckets floor at 0.5 ms. The winner reads 0 by construction, so a coarse first bucket collapses every leader's p50 into an interpolation artifact inside it." + - "Reconnections are a published result, not an implementation detail: a provider that drops twice in two minutes is unusable to a subscriber whatever its median says." + - "GetBlock issues one WebSocket token per chain per region, so its three cells are distinct endpoints rather than one URL probed from three places." + - "Alchemy is measured on the HTTP cohort but not here, and the reason is ours rather than theirs: its endpoint bills WebSocket per message, these subscriptions pushed about 116 million frames a month against the Free plan key that row discloses, and the key is shared with the HTTP probes. Racing a provider until its account is exhausted would remove it from nine other pages, so it waits for a message budget guard." + - "Validation Cloud is listed on the HTTP cohorts of this chain but not here: its WebSocket endpoint refuses the upgrade in all three regions (`websocket: bad handshake`), so there is nothing to race. Measured over 3h on 2026-10-17: 542 reconnection attempts, zero frames. The row returns when the endpoint opens." + - "Same keyed endpoints as the HTTP cohort on this chain, same keys, same three regions. Keys live in the probe environment, never in this repository. Plan tiers are disclosed on the HTTP cohort's methodology." + +expected_n: 22000 + +prometheus: + window: 24h + freshness_metric: rpc_ws_head_seen_total + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is fixed for +# the whole bench, so cells key on provider and region. +rank_matrix_query: histogram_quantile(0.50, sum by (provider, region, le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{chain="base",tier="keyed"})) + +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +faq: + - q: "Which private RPC provider pushes new Base blocks first over WebSocket?" + a: "{{best_name}} has the lowest median lag behind the first arrival over 24h; a figure inside the first histogram bucket reads as zero. {{worst_name}} trails most, at {{worst_p50}} (p50, 24h). The board re-scores on every Base block. Read the p99 column before wiring a latency-sensitive bot to one provider." + - q: "Why does the fastest provider show a lag near zero?" + a: "By construction. Each block is a race: the earliest arrival defines T0 and every sample is measured against it, so the provider that wins most races accumulates mostly zero samples. A single vantage point cannot measure absolute production-to-client latency without a node co-located with block producers; it can measure fairly which provider delivers the same block earlier over the same path. Read the gap to the runner-up and the trailing distribution, not the leader's absolute number." + - q: "Is this the same as the HTTP latency cohort on this chain?" + a: "Same providers, same keys, same regions, different question. The HTTP cohort measures a request you send; this measures a push you wait for, and the two orderings are not the same. GetBlock issues one WebSocket token per chain per region, so its three cells are distinct endpoints rather than one URL probed from three places." + - q: "Could a provider win by pushing blocks before validating them?" + a: "In principle, at the cost of occasionally pushing something that gets retracted. That is why coverage is published next to the lag: a provider that pushes early but wrong shows gaps, and the coverage column counts blocks seen against the cohort maximum. Hash-level cross-checking against an independent reference is not in this version." + +providers: + - slug: chainstack + name: Chainstack + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Base block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="base",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="base",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="base",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="base",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="chainstack",chain="base",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="chainstack",chain="base",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="chainstack",chain="base",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="base",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="chainstack",chain="base",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="base",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="base",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="base",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="base",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="base",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="base",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="base",tier="keyed",region="sgp"}[1h]))) + + - slug: getblock + name: GetBlock + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Base block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="base",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="base",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="base",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="base",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="getblock",chain="base",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="getblock",chain="base",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="getblock",chain="base",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="base",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="getblock",chain="base",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="base",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="base",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="base",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="base",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="base",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="base",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="base",tier="keyed",region="sgp"}[1h]))) + + - slug: quicknode + name: QuickNode + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Base block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="base",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="base",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="base",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="base",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="quicknode",chain="base",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="quicknode",chain="base",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="quicknode",chain="base",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="base",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="quicknode",chain="base",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="base",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="base",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="base",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="base",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="base",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="base",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="base",tier="keyed",region="sgp"}[1h]))) + + - slug: dwellir + name: Dwellir + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Base block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="base",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="base",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="base",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="base",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="dwellir",chain="base",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="dwellir",chain="base",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="dwellir",chain="base",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="base",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="dwellir",chain="base",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="base",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="base",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="base",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="base",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="base",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="base",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="base",tier="keyed",region="sgp"}[1h]))) + diff --git a/benchmarks/base-rpc.yml b/benchmarks/base-rpc.yml index d4c95863b..63bb66d4f 100644 --- a/benchmarks/base-rpc.yml +++ b/benchmarks/base-rpc.yml @@ -5,7 +5,7 @@ number: "046" title: "Base RPC endpoints: free public URLs and private API-key providers by latency" seo_title: "Base RPC endpoints: public and private, ranked by latency" seo_description: "{{best_name}} leads free Base RPC at {{best_p50}} (p50, 24h); URLs for all {{count}} no-key endpoints. {{best_name:tier:keyed}} leads the private (API-key) cohort at {{best_p50:tier:keyed}}." -subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Base RPC endpoint (probed every 60 seconds) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock and QuickNode (every 120 seconds), from 3 regions. The two cohorts are ranked separately, never against each other. +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Base RPC endpoint (probed every 60 seconds) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock, QuickNode and Dwellir (every 120 seconds), from 3 regions. The two cohorts are ranked separately, never against each other. category: RPCs status: live @@ -17,7 +17,7 @@ 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 and Nodies on a level probe. {{count}} providers, the same `eth_getBlockByNumber` call every 60 seconds, three regions, stale-head detection against the cross-provider tip. abstract: | - Base: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}; the chain-official endpoint measures {{p50:base-official}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock and QuickNode on their keyed Base endpoints every 120 seconds: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. + Base: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}; the chain-official endpoint measures {{p50:base-official}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock, QuickNode and Dwellir on their keyed Base endpoints every 120 seconds: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. 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, {{count}} providers, every 60 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: @@ -25,7 +25,7 @@ methodology: - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}`. Plain HTTP POST, identical for every endpoint, no API key in any request. The request id rotates, but that does not guarantee a cache miss: many caches normalise `id` away. Measured 2026-10-08, fixed and rotating ids answer within 2 to 4 percent of each other." - "Latency measurement, call-result classification (`ok`, `http_err`, `jsonrpc_err`, `stale`, `timeout`) and the archive-depth audit follow the cluster method documented on [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities#methodology); this page is the Base-scoped view of the same harness, with the same exclusion rules." - "Chain scope: every query on this page is pinned to chain=\"base\". Provider coverage: {{count}} no-key public-read endpoints live today, each listed with its URL in the endpoints table on this page. Blink (formerly Merkle) was delisted 2026-07: its gateway does not serve `eth_getBlockByNumber`, the anti-cache probe this cluster standardized on, so it cannot be measured on equal terms. Exclusions follow the cluster-wide rules documented on the parent benchmark." - - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock and QuickNode probed on their keyed Base endpoints every 120 seconds from the same 3 regions, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." + - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock, QuickNode and Dwellir probed on their keyed Base endpoints every 120 seconds from the same 3 regions, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." - "Keys live in the probe environment, never in the repo. Plan tiers, disclosed: Alchemy: a Free plan key (30M CU per month, 300 compute units per second), the tier any reader gets; Alchemy states free and paid keys share one routing; Chainstack: a Global Node on the Growth plan; QuickNode: an endpoint on a Build plan ($49 per month), shared fleet; GetBlock: an endpoint on the Enterprise plan, shared by the three probe regions. Chainstack, GetBlock and QuickNode provided their endpoints." - "Dwellir plan tier, disclosed: a Developer plan key ($49 per month, 25 million API responses, 100 responses per second; 1 RPC response = 1 API credit, no compute units). Shared nodes, the same product a reader buys. Dwellir publishes no Solana endpoint, so it is absent from that page rather than shown as unavailable." - "GetBlock regional pairing: each probe region uses GetBlock's nearest endpoint, us-east to New York, eu-west to Frankfurt, sgp to Singapore. Alchemy, Chainstack and QuickNode publish one routed entry point per chain instead, which in the general case does land a probe on a nearby node. Some of any gap between the two arrangements is deployment topology rather than node speed, and a deployment in one of these regions would see the same benefit GetBlock's endpoint gives the probe." @@ -204,6 +204,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="base",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="alchemy",chain="base",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="alchemy",chain="base",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="alchemy",chain="base",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="alchemy",chain="base",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="base",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="base",tier="keyed"}) @@ -228,6 +229,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="base",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="chainstack",chain="base",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="chainstack",chain="base",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="chainstack",chain="base",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="chainstack",chain="base",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="base",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="base",tier="keyed"}) @@ -252,6 +254,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="getblock",chain="base",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="getblock",chain="base",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="getblock",chain="base",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="getblock",chain="base",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="getblock",chain="base",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="base",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="base",tier="keyed"}) @@ -276,6 +279,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="base",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="quicknode",chain="base",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="quicknode",chain="base",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="quicknode",chain="base",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="quicknode",chain="base",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="base",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="base",tier="keyed"}) @@ -300,6 +304,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir",chain="base",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="dwellir",chain="base",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="dwellir",chain="base",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="dwellir",chain="base",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="dwellir",chain="base",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="base",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="base",tier="keyed"}) @@ -324,6 +329,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="alchemy",chain="base",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="alchemy",chain="base",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="alchemy",chain="base",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="alchemy",chain="base",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="alchemy",chain="base",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="base",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="base",tier="keyed"}) @@ -347,6 +353,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="chainstack",chain="base",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="chainstack",chain="base",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="chainstack",chain="base",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="chainstack",chain="base",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="chainstack",chain="base",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="base",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="base",tier="keyed"}) @@ -370,6 +377,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="getblock",chain="base",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="getblock",chain="base",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="getblock",chain="base",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="getblock",chain="base",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="getblock",chain="base",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="base",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="base",tier="keyed"}) @@ -393,6 +401,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="quicknode",chain="base",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="quicknode",chain="base",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="quicknode",chain="base",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="quicknode",chain="base",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="quicknode",chain="base",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="base",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="base",tier="keyed"}) @@ -416,6 +425,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="dwellir",chain="base",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="dwellir",chain="base",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="dwellir",chain="base",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="dwellir",chain="base",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="dwellir",chain="base",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="base",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="base",tier="keyed"}) @@ -431,3 +441,4 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="dwellir",chain="base",tier="keyed",region="sgp"}) series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="dwellir",chain="base",tier="keyed",region="sgp"}[1h])) + diff --git a/benchmarks/bnb-rpc-ws.yml b/benchmarks/bnb-rpc-ws.yml new file mode 100644 index 000000000..50c92aa6c --- /dev/null +++ b/benchmarks/bnb-rpc-ws.yml @@ -0,0 +1,177 @@ +# OpenChainBench. Bench № 299 + +slug: bnb-rpc-ws +number: "299" +title: Fastest private BNB Chain WebSocket push, per-block race across keyed RPC providers +seo_title: "Fastest BNB Chain WebSocket RPC push 2026, private endpoints" +seo_description: "{{best_name}} has the lowest median lag behind the first BNB Chain block push among Chainstack, GetBlock, QuickNode and Dwellir." +subtitle: Per-block WebSocket push lag in milliseconds versus the first provider to deliver the same BNB Chain block, across API-key endpoints probed from us-east, eu-west and Singapore. + +category: RPCs +status: live +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-keyed-latency +metric: Push lag +unit: ms +higher_is_better: false + +seo_intro: | + A bot that subscribes to eth_subscribe(newHeads) does not care what a + provider's median HTTP latency is. It cares who hands it block N first, + and by how much the others trail. This page races the private (BNB Chain) + cohort: one WebSocket per provider per region, open ten minutes in every + hour across the whole cohort, every frame + timestamped on arrival, each block scored against whichever provider + delivered it earliest. {{best_name}} currently leads at {{best_p50}} + (p50, 24h) across {{count}} keyed providers. + + Because the measurement is relative, the leader reads near zero by + construction. The numbers to read are the gap to the runner-up and the + shape of the trailing distribution, p50 against p99, not the leader's + absolute figure. + +abstract: | + Private, API-key BNB Chain endpoints raced on WebSocket delivery. One harness + holds an eth_subscribe(newHeads) subscription per provider in + each of three regions and timestamps every push on receipt. Per block, the + earliest arrival across providers in that region defines T0; each provider's + sample is its own arrival minus T0, in milliseconds. + + The same process runs the HTTP arm of the keyed cohort, which is deliberate: + comparing arrival times recorded by separate processes would compare clocks + as much as providers, and the differences here are tens of milliseconds. + +methodology: + - "Race, not latency: all providers on a chain wait for the same block, so only the spread between them belongs to them. Time from block production to first arrival is the chain's block time and is not published as a provider result." + - "One socket per provider per region, eth_subscribe(newHeads), opened for the same ten minutes of every hour across the whole cohort and closed with the window. Every frame is timestamped on receipt. The window is aligned to the clock rather than to each connection, because a race only means something if every provider is listening to the same blocks." + - "Sampled rather than continuous since 2026-10-17, and the reason is ours: a permanent subscription on nine chains pushed about 138 million messages a month per provider against 583 thousand HTTP probes, and one vendor reached its monthly limit. The three fastest chains left the arm and the rest listen a sixth of the time. Racing a provider until its account is exhausted would remove it from nine other pages." + - "Histogram buckets floor at 0.5 ms. The winner reads 0 by construction, so a coarse first bucket collapses every leader's p50 into an interpolation artifact inside it." + - "Reconnections are a published result, not an implementation detail: a provider that drops twice in two minutes is unusable to a subscriber whatever its median says." + - "GetBlock issues one WebSocket token per chain per region, so its three cells are distinct endpoints rather than one URL probed from three places." + - "Alchemy is measured on the HTTP cohort but not here, and the reason is ours rather than theirs: its endpoint bills WebSocket per message, these subscriptions pushed about 116 million frames a month against the Free plan key that row discloses, and the key is shared with the HTTP probes. Racing a provider until its account is exhausted would remove it from nine other pages, so it waits for a message budget guard." + - "Validation Cloud is listed on the HTTP cohorts of this chain but not here: its WebSocket endpoint refuses the upgrade in all three regions (`websocket: bad handshake`), so there is nothing to race. Measured over 3h on 2026-10-17: 542 reconnection attempts, zero frames. The row returns when the endpoint opens." + - "Same keyed endpoints as the HTTP cohort on this chain, same keys, same three regions. Keys live in the probe environment, never in this repository. Plan tiers are disclosed on the HTTP cohort's methodology." + +expected_n: 96000 + +prometheus: + window: 24h + freshness_metric: rpc_ws_head_seen_total + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is fixed for +# the whole bench, so cells key on provider and region. +rank_matrix_query: histogram_quantile(0.50, sum by (provider, region, le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{chain="bnb",tier="keyed"})) + +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +faq: + - q: "Which private RPC provider pushes new BNB Chain blocks first over WebSocket?" + a: "{{best_name}} has the lowest median lag behind the first arrival over 24h; a figure inside the first histogram bucket reads as zero. {{worst_name}} trails most, at {{worst_p50}} (p50, 24h). The board re-scores on every BNB Chain block. Read the p99 column before wiring a latency-sensitive bot to one provider." + - q: "Why does the fastest provider show a lag near zero?" + a: "By construction. Each block is a race: the earliest arrival defines T0 and every sample is measured against it, so the provider that wins most races accumulates mostly zero samples. A single vantage point cannot measure absolute production-to-client latency without a node co-located with block producers; it can measure fairly which provider delivers the same block earlier over the same path. Read the gap to the runner-up and the trailing distribution, not the leader's absolute number." + - q: "Is this the same as the HTTP latency cohort on this chain?" + a: "Same providers, same keys, same regions, different question. The HTTP cohort measures a request you send; this measures a push you wait for, and the two orderings are not the same. GetBlock issues one WebSocket token per chain per region, so its three cells are distinct endpoints rather than one URL probed from three places." + - q: "Could a provider win by pushing blocks before validating them?" + a: "In principle, at the cost of occasionally pushing something that gets retracted. That is why coverage is published next to the lag: a provider that pushes early but wrong shows gaps, and the coverage column counts blocks seen against the cohort maximum. Hash-level cross-checking against an independent reference is not in this version." + +providers: + - slug: chainstack + name: Chainstack + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each BNB Chain block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="bnb",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="bnb",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="bnb",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="bnb",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="chainstack",chain="bnb",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="chainstack",chain="bnb",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="chainstack",chain="bnb",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="bnb",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="chainstack",chain="bnb",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="bnb",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="bnb",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="bnb",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="bnb",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="bnb",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="bnb",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="bnb",tier="keyed",region="sgp"}[1h]))) + + - slug: getblock + name: GetBlock + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each BNB Chain block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="bnb",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="bnb",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="bnb",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="bnb",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="getblock",chain="bnb",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="getblock",chain="bnb",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="getblock",chain="bnb",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="bnb",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="getblock",chain="bnb",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="bnb",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="bnb",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="bnb",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="bnb",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="bnb",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="bnb",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="bnb",tier="keyed",region="sgp"}[1h]))) + + - slug: quicknode + name: QuickNode + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each BNB Chain block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="bnb",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="bnb",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="bnb",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="bnb",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="quicknode",chain="bnb",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="quicknode",chain="bnb",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="quicknode",chain="bnb",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="bnb",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="quicknode",chain="bnb",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="bnb",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="bnb",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="bnb",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="bnb",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="bnb",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="bnb",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="bnb",tier="keyed",region="sgp"}[1h]))) + + - slug: dwellir + name: Dwellir + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each BNB Chain block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="bnb",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="bnb",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="bnb",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="bnb",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="dwellir",chain="bnb",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="dwellir",chain="bnb",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="dwellir",chain="bnb",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="bnb",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="dwellir",chain="bnb",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="bnb",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="bnb",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="bnb",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="bnb",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="bnb",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="bnb",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="bnb",tier="keyed",region="sgp"}[1h]))) + diff --git a/benchmarks/bnb-rpc.yml b/benchmarks/bnb-rpc.yml index 4d4f697d6..081976691 100644 --- a/benchmarks/bnb-rpc.yml +++ b/benchmarks/bnb-rpc.yml @@ -3,9 +3,9 @@ slug: bnb-rpc number: "049" title: "BNB Chain RPC endpoints: free public URLs and private API-key providers by latency" -seo_title: "BNB Chain RPC endpoints: public and private, ranked by latency" +seo_title: "BNB Chain RPC endpoints: public and private, by latency" seo_description: "{{best_name}} leads free BNB Chain RPC at {{best_p50}} (p50, 24h); URLs for all {{count}} no-key endpoints. {{best_name:tier:keyed}} leads the private (API-key) cohort at {{best_p50:tier:keyed}}." -subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public BNB Chain RPC endpoint (probed every 60 seconds) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock and QuickNode (every 120 seconds), from 3 regions. The two cohorts are ranked separately, never against each other. +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public BNB Chain RPC endpoint (probed every 60 seconds) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock, QuickNode and Dwellir (every 120 seconds), from 3 regions. The two cohorts are ranked separately, never against each other. category: RPCs status: live @@ -14,10 +14,10 @@ unit: ms higher_is_better: false seo_intro: | - BNB Chain is the incumbent's chain: Binance's `bsc-dataseed1.binance.org` has been the copy-paste default since 2020, and it measures {{p50:binance}} on the cross-region median today (see the region tabs for the origin split). The catch is that it serves exactly one chain, while PublicNode, dRPC and Nodies bring multi-chain coverage with increasingly competitive latency from EU origins. {{count}} providers, identical probes, three regions. + BNB Chain is the incumbent's chain: Binance's `bsc-dataseed1.binance.org` has been the copy-paste default since 2020, and it measures {{p50:binance}} on the cross-region median today (see the region tabs for the origin split). The catch is that it serves exactly one chain, while PublicNode, NOWNodes and bloXroute bring multi-chain coverage at competitive latency. {{count}} providers, identical probes, three regions. abstract: | - BNB Chain: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock and QuickNode on their keyed BNB Chain endpoints every 120 seconds: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. + BNB Chain: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock, QuickNode and Dwellir on their keyed BNB Chain endpoints every 120 seconds: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. 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, {{count}} providers, every 60 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: @@ -25,7 +25,7 @@ methodology: - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}`. Plain HTTP POST, identical for every endpoint, no API key in any request. The request id rotates, but that does not guarantee a cache miss: many caches normalise `id` away. Measured 2026-10-08, fixed and rotating ids answer within 2 to 4 percent of each other." - "Latency measurement, call-result classification (`ok`, `http_err`, `jsonrpc_err`, `stale`, `timeout`) and the archive-depth audit follow the cluster method documented on [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities#methodology); this page is the BNB Chain-scoped view of the same harness, with the same exclusion rules." - "Chain scope: every query on this page is pinned to chain=\"bnb\". Provider coverage: {{count}} no-key public-read endpoints live today, each listed with its URL in the endpoints table on this page. Blink (formerly Merkle) was delisted 2026-07: its gateway does not serve `eth_getBlockByNumber`, the anti-cache probe this cluster standardized on, so it cannot be measured on equal terms. Exclusions follow the cluster-wide rules documented on the parent benchmark." - - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock and QuickNode probed on their keyed BNB Chain endpoints every 120 seconds from the same 3 regions, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." + - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock, QuickNode and Dwellir probed on their keyed BNB Chain endpoints every 120 seconds from the same 3 regions, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." - "Keys live in the probe environment, never in the repo. Plan tiers, disclosed: Alchemy: a Free plan key (30M CU per month, 300 compute units per second), the tier any reader gets; Alchemy states free and paid keys share one routing; Chainstack: a Global Node on the Growth plan; QuickNode: an endpoint on a Build plan ($49 per month), shared fleet; GetBlock: an endpoint on the Enterprise plan, shared by the three probe regions. Chainstack, GetBlock and QuickNode provided their endpoints." - "Dwellir plan tier, disclosed: a Developer plan key ($49 per month, 25 million API responses, 100 responses per second; 1 RPC response = 1 API credit, no compute units). Shared nodes, the same product a reader buys. Dwellir publishes no Solana endpoint, so it is absent from that page rather than shown as unavailable." - "GetBlock regional pairing: each probe region uses GetBlock's nearest endpoint, us-east to New York, eu-west to Frankfurt, sgp to Singapore. Alchemy, Chainstack and QuickNode publish one routed entry point per chain instead, which in the general case does land a probe on a nearby node. Some of any gap between the two arrangements is deployment topology rather than node speed, and a deployment in one of these regions would see the same benefit GetBlock's endpoint gives the probe." @@ -122,54 +122,6 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="bnb", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="bnb", region="sgp"}[1h]) - - slug: drpc - name: dRPC - endpoint: https://bsc.drpc.org - 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 60s 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"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="bnb"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="bnb"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="bnb"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="bnb"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="bnb"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="bnb"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="bnb", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="bnb", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="bnb", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="bnb", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="bnb", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="bnb", region="sgp"}[1h]) - - - slug: nodies - name: Nodies - endpoint: https://bsc-pokt.nodies.app - 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_getBlockByNumber` POST sent every 60s 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"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="nodies", chain="bnb"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="nodies", chain="bnb"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="nodies", chain="bnb"}) / sum(ocb:rpc_call:rate_24h{provider="nodies", chain="bnb"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="nodies", chain="bnb"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="nodies", chain="bnb"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="bnb", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="nodies", chain="bnb", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="bnb", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="nodies", chain="bnb", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="bnb", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="nodies", chain="bnb", region="sgp"}[1h]) - - slug: binance name: Binance endpoint: https://bsc-dataseed1.binance.org @@ -196,6 +148,30 @@ providers: # API-key cohort (tier: keyed). Queries pin tier="keyed", the label the # rpc-keyed-latency harness emits; the public rows above carry no tier label. + - slug: bloxroute + name: bloXroute + endpoint: https://bsc.rpc.blxrbdn.com + tag: bloXroute public BSC gateway, no key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to bloXroute public no-key BNB Chain endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="bloxroute", chain="bnb"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="bloxroute", chain="bnb"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="bloxroute", chain="bnb"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="bloxroute", chain="bnb"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="bloxroute", chain="bnb"}) / sum(ocb:rpc_call:rate_24h{provider="bloxroute", chain="bnb"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="bloxroute", chain="bnb"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="bloxroute", chain="bnb"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="bloxroute", chain="bnb", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="bloxroute", chain="bnb", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="bloxroute", chain="bnb", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="bloxroute", chain="bnb", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="bloxroute", chain="bnb", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="bloxroute", chain="bnb", region="sgp"}[1h]) + - slug: alchemy name: Alchemy tier: keyed @@ -204,6 +180,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="bnb",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="alchemy",chain="bnb",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="alchemy",chain="bnb",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="alchemy",chain="bnb",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="alchemy",chain="bnb",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="bnb",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="bnb",tier="keyed"}) @@ -228,6 +205,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="bnb",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="chainstack",chain="bnb",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="chainstack",chain="bnb",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="chainstack",chain="bnb",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="chainstack",chain="bnb",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="bnb",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="bnb",tier="keyed"}) @@ -252,6 +230,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="getblock",chain="bnb",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="getblock",chain="bnb",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="getblock",chain="bnb",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="getblock",chain="bnb",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="getblock",chain="bnb",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="bnb",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="bnb",tier="keyed"}) @@ -276,6 +255,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="bnb",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="quicknode",chain="bnb",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="quicknode",chain="bnb",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="quicknode",chain="bnb",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="quicknode",chain="bnb",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="bnb",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="bnb",tier="keyed"}) @@ -300,6 +280,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir",chain="bnb",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="dwellir",chain="bnb",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="dwellir",chain="bnb",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="dwellir",chain="bnb",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="dwellir",chain="bnb",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="bnb",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="bnb",tier="keyed"}) @@ -324,6 +305,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="alchemy",chain="bnb",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="alchemy",chain="bnb",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="alchemy",chain="bnb",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="alchemy",chain="bnb",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="alchemy",chain="bnb",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="bnb",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="bnb",tier="keyed"}) @@ -347,6 +329,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="chainstack",chain="bnb",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="chainstack",chain="bnb",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="chainstack",chain="bnb",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="chainstack",chain="bnb",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="chainstack",chain="bnb",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="bnb",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="bnb",tier="keyed"}) @@ -370,6 +353,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="getblock",chain="bnb",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="getblock",chain="bnb",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="getblock",chain="bnb",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="getblock",chain="bnb",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="getblock",chain="bnb",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="bnb",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="bnb",tier="keyed"}) @@ -393,6 +377,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="quicknode",chain="bnb",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="quicknode",chain="bnb",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="quicknode",chain="bnb",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="quicknode",chain="bnb",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="quicknode",chain="bnb",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="bnb",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="bnb",tier="keyed"}) @@ -416,6 +401,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="dwellir",chain="bnb",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="dwellir",chain="bnb",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="dwellir",chain="bnb",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="dwellir",chain="bnb",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="dwellir",chain="bnb",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="bnb",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="bnb",tier="keyed"}) diff --git a/benchmarks/cosmos-hub-rpc.yml b/benchmarks/cosmos-hub-rpc.yml index f684400c7..89a120e6f 100644 --- a/benchmarks/cosmos-hub-rpc.yml +++ b/benchmarks/cosmos-hub-rpc.yml @@ -115,26 +115,27 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lavenderfive", chain="cosmos-hub", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="lavenderfive", chain="cosmos-hub", region="sgp"}[1h]) - - slug: lava - name: Lava Network - endpoint: https://cosmoshub.tendermintrpc.lava.build - tag: Multi-chain RPC gateway with a no-key Cosmos Hub tier - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single Tendermint `status` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Lava Network's no-key cosmos-hub RPC endpoint." + - slug: ecostake + name: ecostake + endpoint: https://rpc-cosmoshub.ecostake.com + tag: ecostake community validator public RPC + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single Tendermint `status` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to ecostake's no-key cosmos-hub RPC endpoint." queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="cosmos-hub"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="lava", chain="cosmos-hub"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="lava", chain="cosmos-hub"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="lava", chain="cosmos-hub"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="lava", chain="cosmos-hub"}) / sum(ocb:rpc_call:rate_24h{provider="lava", chain="cosmos-hub"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="lava", chain="cosmos-hub"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="lava", chain="cosmos-hub"}[1h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ecostake", chain="cosmos-hub"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="ecostake", chain="cosmos-hub"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="ecostake", chain="cosmos-hub"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="ecostake", chain="cosmos-hub"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="ecostake", chain="cosmos-hub"}) / sum(ocb:rpc_call:rate_24h{provider="ecostake", chain="cosmos-hub"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="ecostake", chain="cosmos-hub"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="ecostake", chain="cosmos-hub"}[1h])) regions: - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="cosmos-hub", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="lava", chain="cosmos-hub", region="us-east"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ecostake", chain="cosmos-hub", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="ecostake", chain="cosmos-hub", region="us-east"}[1h]) - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="cosmos-hub", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="lava", chain="cosmos-hub", region="eu-west"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ecostake", chain="cosmos-hub", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="ecostake", chain="cosmos-hub", region="eu-west"}[1h]) - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="cosmos-hub", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="lava", chain="cosmos-hub", region="sgp"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ecostake", chain="cosmos-hub", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="ecostake", chain="cosmos-hub", region="sgp"}[1h]) + diff --git a/benchmarks/ethereum-rpc-ws.yml b/benchmarks/ethereum-rpc-ws.yml new file mode 100644 index 000000000..005111d38 --- /dev/null +++ b/benchmarks/ethereum-rpc-ws.yml @@ -0,0 +1,177 @@ +# OpenChainBench. Bench № 296 + +slug: ethereum-rpc-ws +number: "296" +title: Fastest private Ethereum WebSocket push, per-block race across keyed RPC providers +seo_title: "Fastest Ethereum WebSocket RPC push 2026, private endpoints" +seo_description: "{{best_name}} has the lowest median lag behind the first Ethereum block push among Chainstack, GetBlock, QuickNode and Dwellir." +subtitle: Per-block WebSocket push lag in milliseconds versus the first provider to deliver the same Ethereum block, across API-key endpoints probed from us-east, eu-west and Singapore. + +category: RPCs +status: live +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-keyed-latency +metric: Push lag +unit: ms +higher_is_better: false + +seo_intro: | + A bot that subscribes to eth_subscribe(newHeads) does not care what a + provider's median HTTP latency is. It cares who hands it block N first, + and by how much the others trail. This page races the private (Ethereum) + cohort: one WebSocket per provider per region, open ten minutes in every + hour across the whole cohort, every frame + timestamped on arrival, each block scored against whichever provider + delivered it earliest. {{best_name}} currently leads at {{best_p50}} + (p50, 24h) across {{count}} keyed providers. + + Because the measurement is relative, the leader reads near zero by + construction. The numbers to read are the gap to the runner-up and the + shape of the trailing distribution, p50 against p99, not the leader's + absolute figure. + +abstract: | + Private, API-key Ethereum endpoints raced on WebSocket delivery. One harness + holds an eth_subscribe(newHeads) subscription per provider in + each of three regions and timestamps every push on receipt. Per block, the + earliest arrival across providers in that region defines T0; each provider's + sample is its own arrival minus T0, in milliseconds. + + The same process runs the HTTP arm of the keyed cohort, which is deliberate: + comparing arrival times recorded by separate processes would compare clocks + as much as providers, and the differences here are tens of milliseconds. + +methodology: + - "Race, not latency: all providers on a chain wait for the same block, so only the spread between them belongs to them. Time from block production to first arrival is the chain's block time and is not published as a provider result." + - "One socket per provider per region, eth_subscribe(newHeads), opened for the same ten minutes of every hour across the whole cohort and closed with the window. Every frame is timestamped on receipt. The window is aligned to the clock rather than to each connection, because a race only means something if every provider is listening to the same blocks." + - "Sampled rather than continuous since 2026-10-17, and the reason is ours: a permanent subscription on nine chains pushed about 138 million messages a month per provider against 583 thousand HTTP probes, and one vendor reached its monthly limit. The three fastest chains left the arm and the rest listen a sixth of the time. Racing a provider until its account is exhausted would remove it from nine other pages." + - "Histogram buckets floor at 0.5 ms. The winner reads 0 by construction, so a coarse first bucket collapses every leader's p50 into an interpolation artifact inside it." + - "Reconnections are a published result, not an implementation detail: a provider that drops twice in two minutes is unusable to a subscriber whatever its median says." + - "GetBlock issues one WebSocket token per chain per region, so its three cells are distinct endpoints rather than one URL probed from three places." + - "Alchemy is measured on the HTTP cohort but not here, and the reason is ours rather than theirs: its endpoint bills WebSocket per message, these subscriptions pushed about 116 million frames a month against the Free plan key that row discloses, and the key is shared with the HTTP probes. Racing a provider until its account is exhausted would remove it from nine other pages, so it waits for a message budget guard." + - "Validation Cloud is listed on the HTTP cohorts of this chain but not here: its WebSocket endpoint refuses the upgrade in all three regions (`websocket: bad handshake`), so there is nothing to race. Measured over 3h on 2026-10-17: 542 reconnection attempts, zero frames. The row returns when the endpoint opens." + - "Same keyed endpoints as the HTTP cohort on this chain, same keys, same three regions. Keys live in the probe environment, never in this repository. Plan tiers are disclosed on the HTTP cohort's methodology." + +expected_n: 3600 + +prometheus: + window: 24h + freshness_metric: rpc_ws_head_seen_total + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is fixed for +# the whole bench, so cells key on provider and region. +rank_matrix_query: histogram_quantile(0.50, sum by (provider, region, le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{chain="ethereum",tier="keyed"})) + +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +faq: + - q: "Which private RPC provider pushes new Ethereum blocks first over WebSocket?" + a: "{{best_name}} has the lowest median lag behind the first arrival over 24h; a figure inside the first histogram bucket reads as zero. {{worst_name}} trails most, at {{worst_p50}} (p50, 24h). The board re-scores on every Ethereum block. Read the p99 column before wiring a latency-sensitive bot to one provider." + - q: "Why does the fastest provider show a lag near zero?" + a: "By construction. Each block is a race: the earliest arrival defines T0 and every sample is measured against it, so the provider that wins most races accumulates mostly zero samples. A single vantage point cannot measure absolute production-to-client latency without a node co-located with block producers; it can measure fairly which provider delivers the same block earlier over the same path. Read the gap to the runner-up and the trailing distribution, not the leader's absolute number." + - q: "Is this the same as the HTTP latency cohort on this chain?" + a: "Same providers, same keys, same regions, different question. The HTTP cohort measures a request you send; this measures a push you wait for, and the two orderings are not the same. GetBlock issues one WebSocket token per chain per region, so its three cells are distinct endpoints rather than one URL probed from three places." + - q: "Could a provider win by pushing blocks before validating them?" + a: "In principle, at the cost of occasionally pushing something that gets retracted. That is why coverage is published next to the lag: a provider that pushes early but wrong shows gaps, and the coverage column counts blocks seen against the cohort maximum. Hash-level cross-checking against an independent reference is not in this version." + +providers: + - slug: chainstack + name: Chainstack + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Ethereum block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="ethereum",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="ethereum",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="ethereum",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="ethereum",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="chainstack",chain="ethereum",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="chainstack",chain="ethereum",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="chainstack",chain="ethereum",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="ethereum",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="chainstack",chain="ethereum",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="ethereum",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="ethereum",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="ethereum",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="ethereum",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="ethereum",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="ethereum",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="ethereum",tier="keyed",region="sgp"}[1h]))) + + - slug: getblock + name: GetBlock + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Ethereum block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="ethereum",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="ethereum",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="ethereum",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="ethereum",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="getblock",chain="ethereum",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="getblock",chain="ethereum",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="getblock",chain="ethereum",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="ethereum",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="getblock",chain="ethereum",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="ethereum",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="ethereum",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="ethereum",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="ethereum",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="ethereum",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="ethereum",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="ethereum",tier="keyed",region="sgp"}[1h]))) + + - slug: quicknode + name: QuickNode + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Ethereum block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="ethereum",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="ethereum",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="ethereum",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="ethereum",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="quicknode",chain="ethereum",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="quicknode",chain="ethereum",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="quicknode",chain="ethereum",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="ethereum",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="quicknode",chain="ethereum",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="ethereum",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="ethereum",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="ethereum",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="ethereum",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="ethereum",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="ethereum",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="ethereum",tier="keyed",region="sgp"}[1h]))) + + - slug: dwellir + name: Dwellir + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Ethereum block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="ethereum",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="ethereum",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="ethereum",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="ethereum",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="dwellir",chain="ethereum",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="dwellir",chain="ethereum",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="dwellir",chain="ethereum",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="ethereum",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="dwellir",chain="ethereum",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="ethereum",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="ethereum",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="ethereum",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="ethereum",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="ethereum",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="ethereum",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="ethereum",tier="keyed",region="sgp"}[1h]))) + diff --git a/benchmarks/ethereum-rpc.yml b/benchmarks/ethereum-rpc.yml index a2c6c871c..6828e725c 100644 --- a/benchmarks/ethereum-rpc.yml +++ b/benchmarks/ethereum-rpc.yml @@ -3,9 +3,9 @@ slug: ethereum-rpc number: "044" title: "Ethereum RPC endpoints: free public URLs and private API-key providers by latency" -seo_title: "Ethereum RPC endpoints: public and private, ranked by latency" +seo_title: "Ethereum RPC endpoints: public and private, by latency" seo_description: "{{best_name}} leads free Ethereum RPC at {{best_p50}} (p50, 24h); URLs for all {{count}} no-key endpoints. {{best_name:tier:keyed}} leads the private (API-key) cohort at {{best_p50:tier:keyed}}." -subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Ethereum RPC endpoint (probed every 60 seconds) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock and QuickNode (every 120 seconds), from 3 regions. The two cohorts are ranked separately, never against each other. +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Ethereum RPC endpoint (probed every 60 seconds) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock, QuickNode and Dwellir (every 120 seconds), from 3 regions. The two cohorts are ranked separately, never against each other. category: RPCs status: live @@ -17,7 +17,7 @@ seo_intro: | Ethereum carries one of the largest free-RPC cohorts we measure: {{count}} no-key public-read providers answering the same `eth_getBlockByNumber` probe every 60 seconds from three regions. This bench is scoped to public, read-optimised gateways only; MEV-protection RPCs (Flashbots, MEV Blocker, Blink, bloXroute) share the JSON-RPC surface but serve a fundamentally different SLA (send-tx privacy, not read speed) and live in their own cluster on [mev-protect-rpc](/benchmarks/mev-protect-rpc). Cloudflare-eth has been removed from this bench after persistent JSON-RPC error -32603, 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's read path, this page is the live answer to which one deserves it. abstract: | - Ethereum: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock and QuickNode on their keyed Ethereum endpoints every 120 seconds: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. + Ethereum: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock, QuickNode and Dwellir on their keyed Ethereum endpoints every 120 seconds: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public-read Ethereum endpoint that sustains continuous probing, {{count}} providers, every 60 seconds, from us-east, eu-west and Singapore. MEV-protection endpoints (private-mempool, anti-sandwich, PBS relays) are measured on the same probe but scored separately on [mev-protect-rpc](/benchmarks/mev-protect-rpc) because their SLA optimises for send-tx privacy rather than read latency, and mixing them here would flatter or penalise them on the wrong axis. 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: @@ -25,7 +25,7 @@ methodology: - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}`. Plain HTTP POST, identical for every endpoint, no API key in any request. The request id rotates, but that does not guarantee a cache miss: many caches normalise `id` away. Measured 2026-10-08, fixed and rotating ids answer within 2 to 4 percent of each other." - "Latency measurement, call-result classification (`ok`, `http_err`, `jsonrpc_err`, `stale`, `timeout`) and the archive-depth audit follow the cluster method documented on [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities#methodology); this page is the Ethereum-scoped view of the same harness, with the same exclusion rules." - "Chain scope: every query on this page is pinned to chain=\"ethereum\". Provider coverage: {{count}} no-key public-read endpoints live today, each listed with its URL in the endpoints table on this page. Cloudflare removed (persistent JSON-RPC -32603). MEV-protection RPCs (Flashbots, MEV Blocker, Blink, bloXroute) share this probe methodology but score on [mev-protect-rpc](/benchmarks/mev-protect-rpc), because their SLA optimises for send-tx privacy, not read speed." - - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock and QuickNode probed on their keyed Ethereum endpoints every 120 seconds from the same 3 regions, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." + - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock, QuickNode and Dwellir probed on their keyed Ethereum endpoints every 120 seconds from the same 3 regions, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." - "Keys live in the probe environment, never in the repo. Plan tiers, disclosed: Alchemy: a Free plan key (30M CU per month, 300 compute units per second), the tier any reader gets; Alchemy states free and paid keys share one routing; Chainstack: a Global Node on the Growth plan; QuickNode: an endpoint on a Build plan ($49 per month), shared fleet; GetBlock: an endpoint on the Enterprise plan, shared by the three probe regions. Chainstack, GetBlock and QuickNode provided their endpoints." - "Dwellir plan tier, disclosed: a Developer plan key ($49 per month, 25 million API responses, 100 responses per second; 1 RPC response = 1 API credit, no compute units). Shared nodes, the same product a reader buys. Dwellir publishes no Solana endpoint, so it is absent from that page rather than shown as unavailable." - "GetBlock regional pairing: each probe region uses GetBlock's nearest endpoint, us-east to New York, eu-west to Frankfurt, sgp to Singapore. Alchemy, Chainstack and QuickNode publish one routed entry point per chain instead, which in the general case does land a probe on a nearby node. Some of any gap between the two arrangements is deployment topology rather than node speed, and a deployment in one of these regions would see the same benefit GetBlock's endpoint gives the probe." @@ -252,6 +252,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="ethereum",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="alchemy",chain="ethereum",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="alchemy",chain="ethereum",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="alchemy",chain="ethereum",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="alchemy",chain="ethereum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="ethereum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="ethereum",tier="keyed"}) @@ -276,6 +277,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="ethereum",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="chainstack",chain="ethereum",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="chainstack",chain="ethereum",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="chainstack",chain="ethereum",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="chainstack",chain="ethereum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="ethereum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="ethereum",tier="keyed"}) @@ -300,6 +302,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="getblock",chain="ethereum",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="getblock",chain="ethereum",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="getblock",chain="ethereum",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="getblock",chain="ethereum",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="getblock",chain="ethereum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="ethereum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="ethereum",tier="keyed"}) @@ -324,6 +327,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="ethereum",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="quicknode",chain="ethereum",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="quicknode",chain="ethereum",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="quicknode",chain="ethereum",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="quicknode",chain="ethereum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="ethereum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="ethereum",tier="keyed"}) @@ -348,6 +352,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir",chain="ethereum",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="dwellir",chain="ethereum",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="dwellir",chain="ethereum",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="dwellir",chain="ethereum",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="dwellir",chain="ethereum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="ethereum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="ethereum",tier="keyed"}) @@ -372,6 +377,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="alchemy",chain="ethereum",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="alchemy",chain="ethereum",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="alchemy",chain="ethereum",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="alchemy",chain="ethereum",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="alchemy",chain="ethereum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="ethereum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="ethereum",tier="keyed"}) @@ -395,6 +401,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="chainstack",chain="ethereum",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="chainstack",chain="ethereum",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="chainstack",chain="ethereum",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="chainstack",chain="ethereum",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="chainstack",chain="ethereum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="ethereum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="ethereum",tier="keyed"}) @@ -418,6 +425,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="getblock",chain="ethereum",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="getblock",chain="ethereum",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="getblock",chain="ethereum",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="getblock",chain="ethereum",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="getblock",chain="ethereum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="ethereum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="ethereum",tier="keyed"}) @@ -441,6 +449,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="quicknode",chain="ethereum",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="quicknode",chain="ethereum",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="quicknode",chain="ethereum",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="quicknode",chain="ethereum",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="quicknode",chain="ethereum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="ethereum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="ethereum",tier="keyed"}) @@ -464,6 +473,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="dwellir",chain="ethereum",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="dwellir",chain="ethereum",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="dwellir",chain="ethereum",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="dwellir",chain="ethereum",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="dwellir",chain="ethereum",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="ethereum",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="ethereum",tier="keyed"}) @@ -479,3 +489,4 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="dwellir",chain="ethereum",tier="keyed",region="sgp"}) series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="dwellir",chain="ethereum",tier="keyed",region="sgp"}[1h])) + diff --git a/benchmarks/evm-quote-latency.yml b/benchmarks/evm-quote-latency.yml index 945013a5d..acabf2ab5 100644 --- a/benchmarks/evm-quote-latency.yml +++ b/benchmarks/evm-quote-latency.yml @@ -119,6 +119,16 @@ providers: 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)) + regions: + - region: us-east + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="mobula", region="us-east"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="mobula", region="us-east"}[1h])) by (le)) + - region: eu-west + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="mobula", region="eu-west"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="mobula", region="eu-west"}[1h])) by (le)) + - region: ap-southeast + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="mobula", region="sgp"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="mobula", region="sgp"}[1h])) by (le)) - slug: kyberswap name: KyberSwap @@ -132,6 +142,16 @@ providers: 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)) + regions: + - region: us-east + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="kyberswap", region="us-east"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="kyberswap", region="us-east"}[1h])) by (le)) + - region: eu-west + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="kyberswap", region="eu-west"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="kyberswap", region="eu-west"}[1h])) by (le)) + - region: ap-southeast + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="kyberswap", region="sgp"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="kyberswap", region="sgp"}[1h])) by (le)) - slug: bebop name: Bebop @@ -145,6 +165,16 @@ providers: 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)) + regions: + - region: us-east + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="bebop", region="us-east"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="bebop", region="us-east"}[1h])) by (le)) + - region: eu-west + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="bebop", region="eu-west"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="bebop", region="eu-west"}[1h])) by (le)) + - region: ap-southeast + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="bebop", region="sgp"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="bebop", region="sgp"}[1h])) by (le)) - slug: lifi name: LI.FI @@ -158,6 +188,16 @@ providers: 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)) + regions: + - region: us-east + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="lifi", region="us-east"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="lifi", region="us-east"}[1h])) by (le)) + - region: eu-west + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="lifi", region="eu-west"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="lifi", region="eu-west"}[1h])) by (le)) + - region: ap-southeast + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="lifi", region="sgp"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="lifi", region="sgp"}[1h])) by (le)) - slug: openocean name: OpenOcean @@ -171,6 +211,16 @@ providers: 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)) + regions: + - region: us-east + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="openocean", region="us-east"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="openocean", region="us-east"}[1h])) by (le)) + - region: eu-west + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="openocean", region="eu-west"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="openocean", region="eu-west"}[1h])) by (le)) + - region: ap-southeast + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="openocean", region="sgp"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="openocean", region="sgp"}[1h])) by (le)) - slug: cow name: CoW Protocol @@ -184,6 +234,16 @@ providers: 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)) + regions: + - region: us-east + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="cow", region="us-east"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="cow", region="us-east"}[1h])) by (le)) + - region: eu-west + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="cow", region="eu-west"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="cow", region="eu-west"}[1h])) by (le)) + - region: ap-southeast + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="cow", region="sgp"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="cow", region="sgp"}[1h])) by (le)) - slug: enso name: Enso @@ -197,6 +257,16 @@ providers: 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)) + regions: + - region: us-east + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="enso", region="us-east"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="enso", region="us-east"}[1h])) by (le)) + - region: eu-west + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="enso", region="eu-west"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="enso", region="eu-west"}[1h])) by (le)) + - region: ap-southeast + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="enso", region="sgp"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="enso", region="sgp"}[1h])) by (le)) - slug: route name: Route @@ -210,3 +280,13 @@ providers: success: avg(ocb:evm_swap_quote_success:avg_24h{provider="route"}) sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="route"}) series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="route"}[1h])) by (le)) + regions: + - region: us-east + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="route", region="us-east"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="route", region="us-east"}[1h])) by (le)) + - region: eu-west + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="route", region="eu-west"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="route", region="eu-west"}[1h])) by (le)) + - region: ap-southeast + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="route", region="sgp"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="route", region="sgp"}[1h])) by (le)) diff --git a/benchmarks/gravity-rpc.yml b/benchmarks/gravity-rpc.yml index 3a7ea9811..7d23a78ba 100644 --- a/benchmarks/gravity-rpc.yml +++ b/benchmarks/gravity-rpc.yml @@ -45,6 +45,7 @@ dimensions: providers: - slug: gravity-official name: Gravity Foundation + endpoint: https://rpc.gravity.xyz tag: Gravity Foundation's official public RPC formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Gravity Foundation's no-key Gravity Alpha endpoint." queries: @@ -66,26 +67,26 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="gravity", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="gravity", region="sgp"}[1h]) - - slug: ankr - name: Ankr - endpoint: https://rpc.ankr.com/gravity - tag: Multi-cloud RPC network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Ankr's no-key Gravity Alpha endpoint." + - slug: thirdweb + name: Thirdweb + endpoint: https://1625.rpc.thirdweb.com + tag: Thirdweb public RPC gateway, no key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Thirdweb's no-key Gravity Alpha endpoint." queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="gravity"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="ankr", chain="gravity"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="ankr", chain="gravity"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="ankr", chain="gravity"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="ankr", chain="gravity"}) / sum(ocb:rpc_call:rate_24h{provider="ankr", chain="gravity"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="ankr", chain="gravity"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="gravity"}[1h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="thirdweb", chain="gravity"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="thirdweb", chain="gravity"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="thirdweb", chain="gravity"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="thirdweb", chain="gravity"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="thirdweb", chain="gravity"}) / sum(ocb:rpc_call:rate_24h{provider="thirdweb", chain="gravity"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="thirdweb", chain="gravity"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="thirdweb", chain="gravity"}[1h])) regions: - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="gravity", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="gravity", region="us-east"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="thirdweb", chain="gravity", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="thirdweb", chain="gravity", region="us-east"}[1h]) - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="gravity", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="gravity", region="eu-west"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="thirdweb", chain="gravity", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="thirdweb", chain="gravity", region="eu-west"}[1h]) - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="gravity", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="gravity", region="sgp"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="thirdweb", chain="gravity", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="thirdweb", chain="gravity", region="sgp"}[1h]) diff --git a/benchmarks/hyperliquid-rpc-ws.yml b/benchmarks/hyperliquid-rpc-ws.yml new file mode 100644 index 000000000..4d6a7212b --- /dev/null +++ b/benchmarks/hyperliquid-rpc-ws.yml @@ -0,0 +1,171 @@ +# OpenChainBench. Bench № 302 + +slug: hyperliquid-rpc-ws +number: "302" +title: Fastest private HyperEVM WebSocket push, per-block race across keyed RPC providers +seo_title: "Fastest HyperEVM WebSocket RPC push 2026, private endpoints" +seo_description: "{{best_name}} has the lowest median lag behind the first HyperEVM block push among Chainstack, GetBlock, QuickNode and Dwellir." +subtitle: Per-block WebSocket push lag in milliseconds versus the first provider to deliver the same HyperEVM block, across API-key endpoints probed from us-east, eu-west and Singapore. + +category: RPCs +status: live +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-keyed-latency +metric: Push lag +unit: ms +higher_is_better: false + +seo_intro: | + A bot that subscribes to eth_subscribe(newHeads) does not care what a + provider's median HTTP latency is. It cares who hands it block N first, + and by how much the others trail. This page races the private (HyperEVM) + cohort: one WebSocket per provider per region, open ten minutes in every + hour across the whole cohort, every frame + timestamped on arrival, each block scored against whichever provider + delivered it earliest. {{best_name}} currently leads at {{best_p50}} + (p50, 24h) across {{count}} keyed providers. + + Because the measurement is relative, the leader reads near zero by + construction. The numbers to read are the gap to the runner-up and the + shape of the trailing distribution, p50 against p99, not the leader's + absolute figure. + +abstract: | + Private, API-key HyperEVM endpoints raced on WebSocket delivery. One harness + holds an eth_subscribe(newHeads) subscription per provider in + each of three regions and timestamps every push on receipt. Per block, the + earliest arrival across providers in that region defines T0; each provider's + sample is its own arrival minus T0, in milliseconds. + + The same process runs the HTTP arm of the keyed cohort, which is deliberate: + comparing arrival times recorded by separate processes would compare clocks + as much as providers, and the differences here are tens of milliseconds. + +methodology: + - "Race, not latency: all providers on a chain wait for the same block, so only the spread between them belongs to them. Time from block production to first arrival is the chain's block time and is not published as a provider result." + - "One socket per provider per region, eth_subscribe(newHeads), opened for the same ten minutes of every hour across the whole cohort and closed with the window. Every frame is timestamped on receipt. The window is aligned to the clock rather than to each connection, because a race only means something if every provider is listening to the same blocks." + - "Sampled rather than continuous since 2026-10-17, and the reason is ours: a permanent subscription on nine chains pushed about 138 million messages a month per provider against 583 thousand HTTP probes, and one vendor reached its monthly limit. The three fastest chains left the arm and the rest listen a sixth of the time. Racing a provider until its account is exhausted would remove it from nine other pages." + - "Histogram buckets floor at 0.5 ms. The winner reads 0 by construction, so a coarse first bucket collapses every leader's p50 into an interpolation artifact inside it." + - "Reconnections are a published result, not an implementation detail: a provider that drops twice in two minutes is unusable to a subscriber whatever its median says." + - "QuickNode serves this chain's WebSocket on a separate /nanoreth path rather than the /evm path its HTTP endpoint uses. GetBlock completed a handshake only from Frankfurt, so it is raced from eu-west alone." + - "Alchemy is measured on the HTTP cohort but not here, and the reason is ours rather than theirs: its endpoint bills WebSocket per message, these subscriptions pushed about 116 million frames a month against the Free plan key that row discloses, and the key is shared with the HTTP probes. Racing a provider until its account is exhausted would remove it from nine other pages, so it waits for a message budget guard." + - "Validation Cloud is listed on the HTTP cohorts of this chain but not here: its WebSocket endpoint refuses the upgrade in all three regions (`websocket: bad handshake`), so there is nothing to race. Measured over 3h on 2026-10-17: 542 reconnection attempts, zero frames. The row returns when the endpoint opens." + - "Same keyed endpoints as the HTTP cohort on this chain, same keys, same three regions. Keys live in the probe environment, never in this repository. Plan tiers are disclosed on the HTTP cohort's methodology." + +expected_n: 37000 + +prometheus: + window: 24h + freshness_metric: rpc_ws_head_seen_total + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is fixed for +# the whole bench, so cells key on provider and region. +rank_matrix_query: histogram_quantile(0.50, sum by (provider, region, le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{chain="hyperliquid",tier="keyed"})) + +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +faq: + - q: "Which private RPC provider pushes new HyperEVM blocks first over WebSocket?" + a: "{{best_name}} has the lowest median lag behind the first arrival over 24h; a figure inside the first histogram bucket reads as zero. {{worst_name}} trails most, at {{worst_p50}} (p50, 24h). The board re-scores on every HyperEVM block. Read the p99 column before wiring a latency-sensitive bot to one provider." + - q: "Why does the fastest provider show a lag near zero?" + a: "By construction. Each block is a race: the earliest arrival defines T0 and every sample is measured against it, so the provider that wins most races accumulates mostly zero samples. A single vantage point cannot measure absolute production-to-client latency without a node co-located with block producers; it can measure fairly which provider delivers the same block earlier over the same path. Read the gap to the runner-up and the trailing distribution, not the leader's absolute number." + - q: "Is this the same as the HTTP latency cohort on this chain?" + a: "Same providers, same keys, same regions, different question. The HTTP cohort measures a request you send; this measures a push you wait for, and the two orderings are not the same. QuickNode serves this chain's WebSocket on a separate /nanoreth path rather than the /evm path its HTTP endpoint uses. GetBlock completed a handshake only from Frankfurt, so it is raced from eu-west alone." + - q: "Could a provider win by pushing blocks before validating them?" + a: "In principle, at the cost of occasionally pushing something that gets retracted. That is why coverage is published next to the lag: a provider that pushes early but wrong shows gaps, and the coverage column counts blocks seen against the cohort maximum. Hash-level cross-checking against an independent reference is not in this version." + +providers: + - slug: chainstack + name: Chainstack + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each HyperEVM block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="hyperliquid",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="hyperliquid",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="hyperliquid",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="hyperliquid",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="chainstack",chain="hyperliquid",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="hyperliquid",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="chainstack",chain="hyperliquid",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="hyperliquid",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="hyperliquid",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="hyperliquid",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="hyperliquid",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="hyperliquid",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="hyperliquid",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="hyperliquid",tier="keyed",region="sgp"}[1h]))) + + - slug: getblock + name: GetBlock + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each HyperEVM block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="hyperliquid",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="hyperliquid",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="hyperliquid",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="hyperliquid",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="getblock",chain="hyperliquid",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="getblock",chain="hyperliquid",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="getblock",chain="hyperliquid",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="hyperliquid",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="getblock",chain="hyperliquid",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="hyperliquid",tier="keyed"}[1h]))) + regions: + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="hyperliquid",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="hyperliquid",tier="keyed",region="eu-west"}[1h]))) + + - slug: quicknode + name: QuickNode + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each HyperEVM block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="hyperliquid",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="hyperliquid",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="hyperliquid",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="hyperliquid",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="quicknode",chain="hyperliquid",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="hyperliquid",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="quicknode",chain="hyperliquid",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="hyperliquid",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="hyperliquid",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="hyperliquid",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="hyperliquid",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="hyperliquid",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="hyperliquid",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="hyperliquid",tier="keyed",region="sgp"}[1h]))) + + - slug: dwellir + name: Dwellir + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each HyperEVM block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="hyperliquid",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="hyperliquid",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="hyperliquid",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="hyperliquid",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="dwellir",chain="hyperliquid",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="hyperliquid",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="dwellir",chain="hyperliquid",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="hyperliquid",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="hyperliquid",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="hyperliquid",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="hyperliquid",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="hyperliquid",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="hyperliquid",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="hyperliquid",tier="keyed",region="sgp"}[1h]))) + diff --git a/benchmarks/hyperliquid-rpc.yml b/benchmarks/hyperliquid-rpc.yml index 0659afd00..fc322bbb0 100644 --- a/benchmarks/hyperliquid-rpc.yml +++ b/benchmarks/hyperliquid-rpc.yml @@ -3,9 +3,9 @@ slug: hyperliquid-rpc number: "092" title: "Hyperliquid RPC endpoints: free public URLs and private API-key providers by latency" -seo_title: "Hyperliquid RPC endpoints: public and private, ranked by latency" +seo_title: "Hyperliquid RPC endpoints: public and private, by latency" seo_description: "{{best_name}} leads free Hyperliquid RPC at {{best_p50}} (p50, 24h); URLs for all {{count}} no-key endpoints. {{best_name:tier:keyed}} leads the private (API-key) cohort at {{best_p50:tier:keyed}}." -subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Hyperliquid RPC endpoint (probed every 60 seconds) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock and QuickNode (every 120 seconds), from 3 regions. The two cohorts are ranked separately, never against each other. +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Hyperliquid RPC endpoint (probed every 60 seconds) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock, QuickNode and Dwellir (every 120 seconds), from 3 regions. The two cohorts are ranked separately, never against each other. category: RPCs status: live @@ -27,7 +27,7 @@ seo_intro: | revisited when a stable no-key path reappears. abstract: | - Hyperliquid: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}; the chain-official endpoint measures {{p50:hyperliquid-official}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock and QuickNode on their keyed Hyperliquid endpoints every 120 seconds: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. + Hyperliquid: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}; the chain-official endpoint measures {{p50:hyperliquid-official}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock, QuickNode and Dwellir on their keyed Hyperliquid endpoints every 120 seconds: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. We measure the round-trip latency of a single, identical JSON-RPC call (`eth_getBlockByNumber("latest", false)`) against every no-key public HyperEVM endpoint that sustains continuous probing, {{count}} providers live today, every 60 seconds, from us-east, eu-west and Singapore. The harness classifies every response (ok / http_err / jsonrpc_err / stale / timeout) with the standard EVM staleness gap (block gap = 25 blocks ≈ 50 seconds at HyperEVM's ~2 s block time). The cross-chain view lives on the parent `rpc-capabilities` benchmark; this page is the Hyperliquid-scoped answer with per-region breakdowns as a first-class dimension. methodology: @@ -36,7 +36,7 @@ methodology: - "Latency measurement, call-result classification (`ok`, `http_err`, `jsonrpc_err`, `stale`, `timeout`) and the archive-depth audit follow the cluster method documented on [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities#methodology); this page is the Hyperliquid-scoped view of the same harness, with the same exclusion rules." - "Chain scope: every query on this page is pinned to `chain=\"hyperliquid\"`. Provider coverage today: {{count}} no-key endpoints (Hyperliquid Labs official, dRPC, Stakely, Purroof Group, Hypurrscan)." - "Excluded by the launch audit: Alchemy demo path (rate-limited dead), thirdweb (`Invalid chain` on HyperEVM), Grove/Pocket public LB (requires an app id at the LB path), AllThatNode + Blast API + Chainstack + Gelato + Imperator HyperEVM paths (DNS-fail or 401 without a key), PublicNode (no HyperEVM subdomain yet). HyperEVM is a young chain (mainnet Feb 2025) so this list will shift quickly." - - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock and QuickNode probed on their keyed Hyperliquid endpoints every 120 seconds from the same 3 regions, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." + - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock, QuickNode and Dwellir probed on their keyed Hyperliquid endpoints every 120 seconds from the same 3 regions, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." - "Keys live in the probe environment, never in the repo. Plan tiers, disclosed: Alchemy: a Free plan key (30M CU per month, 300 compute units per second), the tier any reader gets; Alchemy states free and paid keys share one routing; Chainstack: a Global Node on the Growth plan; QuickNode: an endpoint on a Build plan ($49 per month), shared fleet; GetBlock: an endpoint on the Enterprise plan, shared by the three probe regions. Chainstack, GetBlock and QuickNode provided their endpoints." - "Dwellir plan tier, disclosed: a Developer plan key ($49 per month, 25 million API responses, 100 responses per second; 1 RPC response = 1 API credit, no compute units). Shared nodes, the same product a reader buys. Dwellir publishes no Solana endpoint, so it is absent from that page rather than shown as unavailable." - "GetBlock regional pairing: each probe region uses GetBlock's nearest endpoint, us-east to New York, eu-west to Frankfurt, sgp to Singapore. Alchemy, Chainstack and QuickNode publish one routed entry point per chain instead, which in the general case does land a probe on a nearby node. Some of any gap between the two arrangements is deployment topology rather than node speed, and a deployment in one of these regions would see the same benefit GetBlock's endpoint gives the probe." @@ -45,7 +45,7 @@ methodology: excluded_providers: [] faq: - - q: "Which HyperEVM RPCs work without an API key?" + - q: "Which Hyperliquid RPCs work without an API key?" a: "{{declared_count}} endpoints are probed on this chain; each is listed with its URL, 24h median and success rate in the endpoints table on this page. Every listed endpoint was live-verified with an `eth_getBlockByNumber` POST returning a parsable hex head before inclusion. Excluded by the audit: Alchemy's demo key path is rate-limited dead, thirdweb returns `Invalid chain` on HyperEVM, Grove/Pocket public LB requires an app id at the LB path, and AllThatNode / Blast API / Chainstack / Gelato / Imperator HyperEVM paths either DNS-fail or 401 without a key. HyperEVM is a young chain so this list will shift as providers stand up dedicated endpoints." - q: "How is Hyperliquid RPC latency measured here, technically?" a: "One identical JSON-RPC POST (`eth_getBlockByNumber(\"latest\", false)`) every 60 seconds against each provider from each of 3 regions, using the same plain HTTP client the rest of the EVM cluster uses. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest." @@ -209,6 +209,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="hyperliquid",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="alchemy",chain="hyperliquid",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="alchemy",chain="hyperliquid",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="alchemy",chain="hyperliquid",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="alchemy",chain="hyperliquid",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="hyperliquid",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="hyperliquid",tier="keyed"}) @@ -233,6 +234,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) @@ -257,6 +259,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="getblock",chain="hyperliquid",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="getblock",chain="hyperliquid",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="getblock",chain="hyperliquid",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="getblock",chain="hyperliquid",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="getblock",chain="hyperliquid",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="hyperliquid",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="hyperliquid",tier="keyed"}) @@ -281,6 +284,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) @@ -305,6 +309,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) @@ -329,6 +334,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="alchemy",chain="hyperliquid",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="alchemy",chain="hyperliquid",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="alchemy",chain="hyperliquid",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="alchemy",chain="hyperliquid",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="alchemy",chain="hyperliquid",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="hyperliquid",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="hyperliquid",tier="keyed"}) @@ -352,6 +358,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="hyperliquid",tier="keyed"}) @@ -375,6 +382,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="getblock",chain="hyperliquid",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="getblock",chain="hyperliquid",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="getblock",chain="hyperliquid",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="getblock",chain="hyperliquid",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="getblock",chain="hyperliquid",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="hyperliquid",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="hyperliquid",tier="keyed"}) @@ -398,6 +406,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="hyperliquid",tier="keyed"}) @@ -421,6 +430,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="hyperliquid",tier="keyed"}) diff --git a/benchmarks/katana-rpc.yml b/benchmarks/katana-rpc.yml index f992d057b..0c81f469c 100644 --- a/benchmarks/katana-rpc.yml +++ b/benchmarks/katana-rpc.yml @@ -146,3 +146,27 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="thirdweb", chain="katana", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="thirdweb", chain="katana", region="sgp"}[1h]) + + - slug: conduit + name: Conduit + endpoint: https://rpc.katanarpc.com + tag: Conduit public RPC for Katana, listed in Katana's own network documentation + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Conduit's no-key katana RPC endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="conduit", chain="katana"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="conduit", chain="katana"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="conduit", chain="katana"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="conduit", chain="katana"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="conduit", chain="katana"}) / sum(ocb:rpc_call:rate_24h{provider="conduit", chain="katana"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="conduit", chain="katana"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="conduit", chain="katana"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="conduit", chain="katana", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="conduit", chain="katana", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="conduit", chain="katana", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="conduit", chain="katana", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="conduit", chain="katana", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="conduit", chain="katana", region="sgp"}[1h]) diff --git a/benchmarks/memecoin-platforms.yml b/benchmarks/memecoin-platforms.yml index d80a70504..5faaf2b8d 100644 --- a/benchmarks/memecoin-platforms.yml +++ b/benchmarks/memecoin-platforms.yml @@ -58,10 +58,11 @@ methodology: - "Two freshness layers, because they fail differently: terminal_activity_last_success_unix says the harness is still running, and terminal_activity_health says this terminal and chain published a day inside the window. A harness that runs while its source freezes trips the second, not the first." - "Chains: five are offered as tabs, the ones with at least five terminals and meaningful volume. Arc, HyperEVM, Monad, X Layer and Avalanche are measured but not offered, because one or two terminals on a chain is a list rather than a comparison." - "Not the cost of a swap: this is the terminal's cut only. Slippage, priority fees and pool fees are not in it, and a low take rate can sit on top of a worse total execution. Benches 268 and 279 measure the all-in figure." + - "Plausibility ceiling: a cell whose fees exceed 10% of the volume they sit on is withheld, not published. Across this cohort every measured take rate falls between 0.39% and 2.2%, so a cell many times above that is the source attributing a fee to the wrong denominator rather than a terminal keeping a tenth of the ticket. The volume and transaction counts for that cell are still published; only the ratio is dropped." findings: - "{{best_name}} keeps the least per dollar routed at {{best_p50}} on this chain." - - "Two cells are withheld rather than ranked, and the reason is the same for both: Axiom reports no fee on Robinhood and BNB while charging 0.92% on Solana, and pump.fun reports no fee on Solana while charging on four other chains. A zero from a platform that charges elsewhere is a gap, not the cheapest price in the market." + - "A cell is withheld rather than ranked in two cases, and both are gaps in the source rather than prices in the market: a platform reporting no fee on one chain while charging on another, and a platform whose reported fee is too large a share of the volume it sits on to be a trading fee at all. The second case is a misattribution in the feed, not a terminal that keeps most of the ticket." - "Observed take rate is a lower bound on the published fee. Waivers, referral rebates and promotional periods all reduce what a terminal keeps without changing what its schedule says." - "Take rates differ by chain on the same terminal, which is why this page has a chain axis rather than one pooled number." @@ -215,7 +216,7 @@ providers: - slug: pump-fun name: pump.fun tag: "Launchpad's own app, no terminal fee" - formula: "A day's fee revenue divided by that day's routed volume for the pump.fun app. It reports fees on four chains and exactly zero on Solana, where it routes $252M a week, so the Solana cell is withheld rather than ranked as the market floor." + formula: "A day's fee revenue divided by that day's routed volume for the pump.fun app. It reports fees on Base alone, 0.003% of the volume it routes, so every cell is withheld: a rate measured on a sliver is not this app's take rate." queries: p50: max(terminal_fee_rate_pct{platform="pump-fun",chain="all"}) p90: max(terminal_fee_rate_pct{platform="pump-fun",chain="all"}) diff --git a/benchmarks/moonbeam-rpc.yml b/benchmarks/moonbeam-rpc.yml index cd965776f..0d5a2ee0b 100644 --- a/benchmarks/moonbeam-rpc.yml +++ b/benchmarks/moonbeam-rpc.yml @@ -14,21 +14,25 @@ unit: ms higher_is_better: false seo_intro: | - Moonbeam, the EVM chain of the Polkadot ecosystem, fields 4 no-key - descoped cluster-wide to Ethereum and Arbitrum after its keyless - per-IP daily budget proved too tight for continuous multi-chain - probing; the remaining cohort covers the multi-chain gateways plus - the chain-official endpoint. Probes every 60 seconds from three - regions with the cluster's anti-cache payload. + Moonbeam, the EVM chain of the Polkadot ecosystem, fields 3 no-key + public endpoints that sustain continuous probing. The cohort was + rebuilt on 2026-10-09: PublicNode stopped serving the chain and + answers 404 on every hostname it published, Tenderly's public gateway + answers 404, and the Foundation endpoint refuses the TLS handshake + from two independent client stacks. OnFinality and UnitedBloc take + their place, each verified on the same head as dRPC before inclusion. + Probes every 60 seconds from three regions with the cluster's + anti-cache payload. abstract: | - Moonbeam: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}; the chain-official endpoint measures {{p50:moonbeam-official}}. + Moonbeam: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}. 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, {{count}} providers, every 60 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 60 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}`. Plain HTTP POST, identical for every endpoint, no API key in any request. The request id rotates, but that does not guarantee a cache miss: many caches normalise `id` away. Measured 2026-10-08, fixed and rotating ids answer within 2 to 4 percent of each other." - "Latency measurement, call-result classification (`ok`, `http_err`, `jsonrpc_err`, `stale`, `timeout`) and the archive-depth audit follow the cluster method documented on [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities#methodology); this page is the Moonbeam-scoped view of the same harness, with the same exclusion rules." + - "Cohort rebuilt 2026-10-09. Dropped: PublicNode and Tenderly, both 404, and the Foundation endpoint, which refuses the TLS handshake from two independent client stacks (LibreSSL 3.3.6 and OpenSSL 3.5.5), so it cannot be measured from here whatever the cause. Added: OnFinality and UnitedBloc, each probed with `eth_getBlockByNumber` and kept only after returning dRPC's head on 10 of 10 tries." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities @@ -50,30 +54,6 @@ dimensions: - { value: sgp, label: Singapore } providers: - - slug: publicnode - name: PublicNode - endpoint: https://moonbeam-rpc.publicnode.com - tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s 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"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="moonbeam"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="moonbeam"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="moonbeam"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="moonbeam"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="moonbeam"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="moonbeam"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="moonbeam", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="moonbeam", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="moonbeam", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="moonbeam", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="moonbeam", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="moonbeam", region="sgp"}[1h]) - - slug: drpc name: dRPC endpoint: https://moonbeam.drpc.org @@ -98,51 +78,51 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="moonbeam", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="moonbeam", region="sgp"}[1h]) - - slug: tenderly - name: Tenderly - endpoint: https://gateway.tenderly.co/public/moonbeam - tag: Multi-chain public gateway, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Moonbeam endpoint." + - slug: onfinality + name: OnFinality + endpoint: https://moonbeam.api.onfinality.io/public + tag: OnFinality shared node service public endpoint + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to OnFinality'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"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly", chain="moonbeam"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly", chain="moonbeam"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly", chain="moonbeam"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly", chain="moonbeam"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly", chain="moonbeam"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="moonbeam"}[1h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="moonbeam"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="onfinality", chain="moonbeam"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="onfinality", chain="moonbeam"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="onfinality", chain="moonbeam"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="onfinality", chain="moonbeam"}) / sum(ocb:rpc_call:rate_24h{provider="onfinality", chain="moonbeam"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="onfinality", chain="moonbeam"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="moonbeam"}[1h])) regions: - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="moonbeam", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="moonbeam", region="us-east"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="moonbeam", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="moonbeam", region="us-east"}[1h]) - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="moonbeam", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="moonbeam", region="eu-west"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="moonbeam", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="moonbeam", region="eu-west"}[1h]) - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="moonbeam", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="moonbeam", region="sgp"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="moonbeam", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="moonbeam", region="sgp"}[1h]) - - slug: moonbeam-official - name: Moonbeam - endpoint: https://rpc.api.moonbeam.network - tag: Moonbeam Foundation public RPC, Moonbeam only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Moonbeam's no-key Moonbeam endpoint." + - slug: unitedbloc + name: UnitedBloc + endpoint: https://moonbeam.unitedbloc.com + tag: UnitedBloc community-operated public RPC + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to UnitedBloc'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"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="moonbeam-official", chain="moonbeam"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="moonbeam-official", chain="moonbeam"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="moonbeam-official", chain="moonbeam"}) / sum(ocb:rpc_call:rate_24h{provider="moonbeam-official", chain="moonbeam"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="moonbeam-official", chain="moonbeam"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="moonbeam-official", chain="moonbeam"}[1h])) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unitedbloc", chain="moonbeam"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="unitedbloc", chain="moonbeam"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="unitedbloc", chain="moonbeam"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="unitedbloc", chain="moonbeam"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="unitedbloc", chain="moonbeam"}) / sum(ocb:rpc_call:rate_24h{provider="unitedbloc", chain="moonbeam"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="unitedbloc", chain="moonbeam"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="unitedbloc", chain="moonbeam"}[1h])) regions: - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="moonbeam-official", chain="moonbeam", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="moonbeam-official", chain="moonbeam", region="us-east"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unitedbloc", chain="moonbeam", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="unitedbloc", chain="moonbeam", region="us-east"}[1h]) - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="moonbeam-official", chain="moonbeam", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="moonbeam-official", chain="moonbeam", region="eu-west"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unitedbloc", chain="moonbeam", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="unitedbloc", chain="moonbeam", region="eu-west"}[1h]) - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="moonbeam-official", chain="moonbeam", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="moonbeam-official", chain="moonbeam", region="sgp"}[1h]) + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unitedbloc", chain="moonbeam", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="unitedbloc", chain="moonbeam", region="sgp"}[1h]) diff --git a/benchmarks/moonriver-rpc.yml b/benchmarks/moonriver-rpc.yml index 76e870527..c12ec2da2 100644 --- a/benchmarks/moonriver-rpc.yml +++ b/benchmarks/moonriver-rpc.yml @@ -14,17 +14,17 @@ unit: ms higher_is_better: false seo_intro: | - Moonriver is the Kusama canary network for Moonbeam (chain 1285), a full EVM parachain with Ethereum-compatible tooling, MOVR gas token, ~12 s block time under Nominated Proof of Stake, serving as the staging ground for Moonbeam features before they reach Polkadot. The 2026-08-03 audit confirms {{count}} clean keyless providers: the Moonbeam Foundation official endpoint (rpc.api.moonriver.moonbeam.network), PublicNode (moonriver-rpc.publicnode.com), and OnFinality (moonriver.api.onfinality.io/public). Excluded: UnitedBloc (moonriver.unitedbloc.com DNS dead), Ankr (requires key for Moonriver), dRPC (no moonriver route). + Moonriver is the Kusama canary network for Moonbeam (chain 1285), a full EVM parachain with Ethereum-compatible tooling, MOVR gas token, ~12 s block time under Nominated Proof of Stake, serving as the staging ground for Moonbeam features before they reach Polkadot. The 2026-10-09 audit confirms {{count}} clean keyless providers: OnFinality (moonriver.api.onfinality.io/public), dRPC (moonriver.drpc.org) and UnitedBloc (moonriver.unitedbloc.com). The last two were excluded in August, dRPC for having no Moonriver route and UnitedBloc for dead DNS, and both answer again on the current head. Excluded now: the Moonbeam Foundation endpoint (rpc.api.moonriver.moonbeam.network refuses the TLS handshake from two independent client stacks), PublicNode (moonriver-rpc.publicnode.com answers 404, the chain was dropped), Ankr (requires a key for Moonriver). abstract: | - Moonriver: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}; the chain-official endpoint measures {{p50:moonriver-official}}. + Moonriver: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}. We measure the round-trip latency of a single, identical JSON-RPC call (`eth_getBlockByNumber(\"latest\", false)`) against every no-key public Moonriver endpoint that sustains continuous probing, {{count}} providers, every 60 seconds, from us-east, eu-west and Singapore. The harness classifies every response (ok / http_err / jsonrpc_err / stale / timeout) with a Moonriver-scaled staleness gap (20 blocks at Moonriver's ~12 s block time). The cross-chain view lives on the parent `rpc-capabilities` benchmark; this page is the Moonriver-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 60 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\", false]}`. Plain HTTP POST, identical for every endpoint, no API key in any request. The request id rotates, but that does not guarantee a cache miss: many caches normalise `id` away. Measured 2026-10-08, fixed and rotating ids answer within 2 to 4 percent of each other." - "Latency measurement, call-result classification (`ok`, `http_err`, `jsonrpc_err`, `stale`, `timeout`) and the archive-depth audit follow the cluster method documented on [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities#methodology); this page is the Moonriver-scoped view of the same harness, with the same exclusion rules." - - "Chain scope: every query on this page is pinned to `chain=\"moonriver\"`. Provider coverage: {{count}} no-key endpoints. UnitedBloc removed (DNS dead)." + - "Chain scope: every query on this page is pinned to `chain=\"moonriver\"`. Provider coverage: {{count}} no-key endpoints. Cohort rebuilt 2026-10-09: the Foundation endpoint and PublicNode dropped, dRPC and UnitedBloc restored after both answered on the current head on 10 of 10 probes." faq: - q: "Which Moonriver RPCs work without an API key?" @@ -48,54 +48,6 @@ dimensions: - { value: sgp, label: Singapore } providers: - - slug: moonriver-official - name: Moonbeam Foundation - endpoint: https://rpc.api.moonriver.moonbeam.network - tag: Moonbeam Foundation official public RPC for Moonriver - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Moonbeam Foundation's no-key moonriver RPC endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="moonriver-official", chain="moonriver"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="moonriver-official", chain="moonriver"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="moonriver-official", chain="moonriver"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="moonriver-official", chain="moonriver"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="moonriver-official", chain="moonriver"}) / sum(ocb:rpc_call:rate_24h{provider="moonriver-official", chain="moonriver"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="moonriver-official", chain="moonriver"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="moonriver-official", chain="moonriver"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="moonriver-official", chain="moonriver", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="moonriver-official", chain="moonriver", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="moonriver-official", chain="moonriver", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="moonriver-official", chain="moonriver", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="moonriver-official", chain="moonriver", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="moonriver-official", chain="moonriver", region="sgp"}[1h]) - - - slug: publicnode - name: PublicNode - endpoint: https://moonriver-rpc.publicnode.com - tag: PublicNode community-funded public Moonriver RPC - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key moonriver RPC endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="moonriver"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="moonriver"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="moonriver"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="moonriver"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="moonriver"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="moonriver"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="moonriver"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="moonriver"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="moonriver", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="moonriver", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="moonriver", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="moonriver", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="moonriver", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="moonriver", region="sgp"}[1h]) - - slug: onfinality name: OnFinality endpoint: https://moonriver.api.onfinality.io/public @@ -120,3 +72,51 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="moonriver", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="moonriver", region="sgp"}[1h]) + - slug: drpc + name: dRPC + endpoint: https://moonriver.drpc.org + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key moonriver RPC endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="moonriver"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="moonriver"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="moonriver"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="moonriver"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="moonriver"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="moonriver"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="moonriver"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="moonriver"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="moonriver", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="moonriver", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="moonriver", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="moonriver", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="moonriver", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="moonriver", region="sgp"}[1h]) + + - slug: unitedbloc + name: UnitedBloc + endpoint: https://moonriver.unitedbloc.com + tag: UnitedBloc community-operated public RPC + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to UnitedBloc's no-key moonriver RPC endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unitedbloc", chain="moonriver"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="unitedbloc", chain="moonriver"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="unitedbloc", chain="moonriver"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="unitedbloc", chain="moonriver"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="unitedbloc", chain="moonriver"}) / sum(ocb:rpc_call:rate_24h{provider="unitedbloc", chain="moonriver"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="unitedbloc", chain="moonriver"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="unitedbloc", chain="moonriver"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unitedbloc", chain="moonriver", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="unitedbloc", chain="moonriver", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unitedbloc", chain="moonriver", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="unitedbloc", chain="moonriver", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unitedbloc", chain="moonriver", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="unitedbloc", chain="moonriver", region="sgp"}[1h]) + diff --git a/benchmarks/perp-active-markets.yml b/benchmarks/perp-active-markets.yml index 56576f0a8..e08063973 100644 --- a/benchmarks/perp-active-markets.yml +++ b/benchmarks/perp-active-markets.yml @@ -15,12 +15,13 @@ row_noun: { one: venue, many: venues } seo_intro: | This benchmark ranks the major onchain perp venues by total active markets. - Market count is the fastest proxy for breadth: a venue with hundreds of pairs gives + Market count is the simplest proxy for breadth: a venue with hundreds of pairs gives traders access to crypto, forex (EUR/USD, GBP/USD), tokenized stocks, commodities and stock indices alongside BTC and ETH. We poll each venue's public API every 5 minutes and publish one gauge per venue. {{best_name}} lists the most today at {{best_p50}}; Gains at {{p50:gains}} spans crypto, forex, stocks and commodities, - Ondo Perps at {{p50:ondo}} is mostly stocks and ETFs, Hyperliquid at {{p50:hyperliquid}} is crypto only. + Ondo Perps at {{p50:ondo}} is mostly stocks and ETFs, Hyperliquid's core universe at {{p50:hyperliquid}} is crypto only; its + HIP-3 deployer markets are counted on perp-asset-breadth. abstract: | The bench polls the perp DEX cohort every 5 minutes and publishes a live count @@ -56,7 +57,7 @@ methodology: findings: - "{{best_name}} leads the cohort with {{best_p50}} active markets across the {{count}} measured perp DEX venues." - - "{{name:gains}} lists {{p50:gains}} active markets, spanning crypto, forex, stocks, indices and commodities. The widest multi-asset catalog among EVM perp DEXes." + - "{{name:gains}} lists {{p50:gains}} active markets, spanning crypto, forex, stocks, indices and commodities. Crypto, forex, stocks, indices and commodities in one catalog." - "{{name:aster}} lists {{p50:aster}} active markets. BNB Chain perps with a Binance-compatible API." - "{{name:edgex}} lists {{p50:edgex}} active markets. StarkEx-settled offchain CLOB." - "{{name:extended}} lists {{p50:extended}} active markets. StarkEx-based perps venue." @@ -83,9 +84,9 @@ faq: - q: "Why does Gains use Mobula as a source instead of its native API?" a: "The perp-cohort-stats harness uses the Mobula perp pairs catalog for gains because Mobula curates the full market taxonomy with asset-class tags. The count is mainnet-only (testnet chains excluded) and matches the inventory Gains exposes on its interface." - q: "Is market count a good proxy for venue breadth?" - a: "It is the fastest proxy. A venue with {{p50:gains}} markets covering crypto, forex, stocks and commodities (Gains) gives traders far more choice than one with {{p50:hyperliquid}} crypto-only markets (Hyperliquid). Depth per market is a separate signal; pair this bench with perp-volume-share and perp-fees for the full picture." + a: "It is the simplest proxy. A venue with {{p50:gains}} markets covering crypto, forex, stocks and commodities (Gains) gives traders far more choice than one with {{p50:hyperliquid}} crypto-only markets (Hyperliquid). Depth per market is a separate signal; pair this bench with perp-volume-share and perp-fees for the full picture." - q: "Can a venue with more asset classes rank below one with more instruments?" - a: "Yes. Aster, for example, lists more raw instruments (all USDT-margined pairs on BNB Chain including many micro-cap crypto assets). Gains lists fewer instruments but across a broader set of asset classes. The bench ranks by raw count; the findings text calls out gains as the widest multi-asset catalog among EVM DEXes." + a: "Yes. Aster, for example, lists more raw instruments (all USDT-margined pairs on BNB Chain including many micro-cap crypto assets). Gains lists fewer instruments but across a broader set of asset classes. The bench ranks by raw count, so a venue with many micro-cap crypto pairs can outrank one spanning more asset classes." providers: - slug: aster @@ -365,3 +366,15 @@ providers: success: avg_over_time(perp_venue_health{venue="trade-xyz"}[24h]) sample_size: count_over_time(perp_venue_active_markets{venue="trade-xyz"}[24h]) series: perp_venue_active_markets{venue="trade-xyz"} + - slug: papertrade + name: Papertrade + tag: Hyperliquid, single house LP, BTC and ETH only + formula: "Instruments listed by GET /state/trading." + queries: + p50: avg_over_time(perp_venue_active_markets{venue="papertrade"}[24h]) + p90: quantile_over_time(0.90, perp_venue_active_markets{venue="papertrade"}[24h]) + p99: quantile_over_time(0.99, perp_venue_active_markets{venue="papertrade"}[24h]) + mean: avg_over_time(perp_venue_active_markets{venue="papertrade"}[24h]) + success: avg_over_time(perp_venue_health{venue="papertrade"}[24h]) + sample_size: count_over_time(perp_venue_active_markets{venue="papertrade"}[24h]) + series: perp_venue_active_markets{venue="papertrade"} diff --git a/benchmarks/perp-asset-breadth.yml b/benchmarks/perp-asset-breadth.yml index 6fcbcc3d9..cac7d4b3f 100644 --- a/benchmarks/perp-asset-breadth.yml +++ b/benchmarks/perp-asset-breadth.yml @@ -87,7 +87,7 @@ faq: - q: "Where does the asset class come from?" a: "From the venue itself whenever it publishes one: Extended's category and subCategory, Paradex's RWA tag, Aster's underlyingSubType, Ostium's pair group, Ondo's tags, Mobula's assetClass for Gains and Lighter. Hyperliquid HIP-3 dexes and dYdX publish none, so their symbols are matched against a fixed table of forex codes, commodity references and index names, and dYdX carries a reviewed list of its five non-crypto tickers." - q: "Why does Hyperliquid rank high when its own perps are crypto-only?" - a: "Because HIP-3 lets third-party deployers list perps on Hyperliquid's orderbook under a namespaced symbol (xyz:TSLA, mkts:US500, para:AVGO). Those markets settle on the same HyperBFT engine and are reachable from the same account, so the bench counts them as Hyperliquid markets. The core universe (BTC, ETH, SOL and the other 175 coins) contributes zero to the non-crypto count." + a: "Because HIP-3 lets third-party deployers list perps on Hyperliquid's orderbook under a namespaced symbol (xyz:TSLA, mkts:US500, para:AVGO). Those markets settle on the same HyperBFT engine and are reachable from the same account, so the bench counts them as Hyperliquid markets. The core universe (BTC, ETH, SOL and the other core coins) contributes zero to the non-crypto count." - q: "Does a market count if it only trades during exchange hours?" a: "Yes. Most stock and index perps on Extended, Aster and Hyperliquid HIP-3 trade 24/5 or follow the underlying exchange session; the bench counts listed markets, not open ones, so a Saturday reading equals a Tuesday reading. Execution conditions during closed sessions are outside this bench." - q: "How often does the count change?" @@ -205,7 +205,7 @@ providers: series: perp_venue_noncore_markets_total{venue="dydx"} - slug: gmx-v2 - name: GMX v2 + name: GMX tag: Arbitrum and Avalanche pool perps, gold, silver, oil, gas, SPY, QQQ next to crypto formula: "Count of synthetic tokens in GET arbitrum-api.gmxinfra.io/tokens matched as commodity or index by the symbol table, plus the reviewed equity synthetics (SPCX). Live." queries: @@ -270,3 +270,14 @@ providers: mean: avg_over_time(perp_venue_noncore_markets_total{venue="trade-xyz"}[24h]) sample_size: count_over_time(perp_venue_noncore_markets_total{venue="trade-xyz"}[24h]) series: perp_venue_noncore_markets_total{venue="trade-xyz"} + - slug: papertrade + name: Papertrade + tag: Hyperliquid, single house LP, BTC and ETH only + formula: "Non-crypto markets among the instruments listed by GET /state/trading." + queries: + p50: perp_venue_noncore_markets_total{venue="papertrade"} + p90: perp_venue_noncore_markets_total{venue="papertrade"} + p99: perp_venue_noncore_markets_total{venue="papertrade"} + mean: avg_over_time(perp_venue_noncore_markets_total{venue="papertrade"}[24h]) + sample_size: count_over_time(perp_venue_noncore_markets_total{venue="papertrade"}[24h]) + series: perp_venue_noncore_markets_total{venue="papertrade"} diff --git a/benchmarks/perp-capital-efficiency.yml b/benchmarks/perp-capital-efficiency.yml index 5a6d1c60e..44e12e025 100644 --- a/benchmarks/perp-capital-efficiency.yml +++ b/benchmarks/perp-capital-efficiency.yml @@ -20,15 +20,15 @@ seo_intro: | Every perp DEX comparison ranks by volume, fees or funding rates. Few ask the capital question: how many markets can a venue sustain per dollar it locks up? The answer reveals the structural gap between synthetic oracle venues and CLOB - order book venues. A synthetic venue like Gains needs one shared collateral - pool to back unlimited pairs. A CLOB like Hyperliquid needs market-maker inventory + order book venues. A synthetic venue needs one shared collateral pool to back + unlimited pairs. A CLOB like Hyperliquid needs market-maker inventory for every individual pair. The collateral requirements scale completely differently. - Gains supports over 40 active markets per $1M of trading TVL. Hyperliquid - supports less than 0.1 markets per $1M TVL. The 400x gap is not a performance - difference; it is an architectural one. The bench does not judge which model is - better: deep single-pair liquidity has real value. It measures a real dimension - that existing leaderboards ignore entirely. + {{best_name}} leads at {{best_p50}} markets per $1M of trading TVL across + {{count}} venues; {{name:hyperliquid}} reads {{p50:hyperliquid}}. The gap is + architectural, not a performance difference. The bench does not judge which + model is better: deep single-pair liquidity has real value. It measures a real + dimension that existing leaderboards ignore entirely. abstract: | The bench computes active_markets / (trading_tvl_usd / 1_000_000) for each @@ -236,3 +236,17 @@ providers: success: avg_over_time(perp_venue_health{venue="grvt"}[24h]) sample_size: count_over_time(perp_venue_active_markets{venue="grvt"}[24h]) series: perp_venue_active_markets{venue="grvt"} / perp_venue_tvl_usd{venue="grvt"} * 1000000 + - slug: papertrade + name: Papertrade + tag: Hyperliquid, single house LP, BTC and ETH only + confidence_tier: onchain + formula: "Markets listed by GET /state/trading over TVL from GET /query/protocol/history." + queries: + p50: avg_over_time(perp_venue_active_markets{venue="papertrade"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="papertrade"}[24h]) * 1000000 + p90: quantile_over_time(0.90, perp_venue_active_markets{venue="papertrade"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="papertrade"}[24h]) * 1000000 + p99: quantile_over_time(0.99, perp_venue_active_markets{venue="papertrade"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="papertrade"}[24h]) * 1000000 + mean: avg_over_time(perp_venue_active_markets{venue="papertrade"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="papertrade"}[24h]) * 1000000 + success: avg_over_time(perp_venue_health{venue="papertrade"}[24h]) + sample_size: count_over_time(perp_venue_active_markets{venue="papertrade"}[24h]) + series: perp_venue_active_markets{venue="papertrade"} / perp_venue_tvl_usd{venue="papertrade"} * 1000000 + diff --git a/benchmarks/perp-cost-slope.yml b/benchmarks/perp-cost-slope.yml index e818a9dbf..a0a2688bd 100644 --- a/benchmarks/perp-cost-slope.yml +++ b/benchmarks/perp-cost-slope.yml @@ -149,7 +149,7 @@ providers: series: perp_fees_all_in_bps_tier{venue="gains",notional="1000000"} - slug: gmx - name: GMX v2 + name: GMX tag: "Synthetics on Arbitrum, oracle-priced, flat cost at any size" formula: "all_in_bps_tier{notional=1000000}[24h] / all_in_bps_tier{notional=1000}[24h]. Oracle-priced (positionFeeFactor flat % of notional, no orderbook): ratio always 1.00." queries: diff --git a/benchmarks/perp-fees.yml b/benchmarks/perp-fees.yml index 7ef57b89c..c507b8037 100644 --- a/benchmarks/perp-fees.yml +++ b/benchmarks/perp-fees.yml @@ -196,7 +196,7 @@ providers: series: perp_fees_all_in_bps{venue="dydx"} - slug: gmx - name: GMX v2 + name: GMX tag: Synthetics on Arbitrum, oracle-priced formula: "Average over 24h of GMX v2 `positionFeeFactorForNegativeImpact` (worst-case open) for the selected market (ETH, BTC or SOL) from the synthetics-arbitrum subgraph, in bps; oracle priced so no spread component." queries: diff --git a/benchmarks/perp-liq-rate.yml b/benchmarks/perp-liq-rate.yml index e3cdbe798..562c4ea5d 100644 --- a/benchmarks/perp-liq-rate.yml +++ b/benchmarks/perp-liq-rate.yml @@ -461,7 +461,7 @@ providers: series: perp_liq_rate_24h_pct{venue="gains"} - slug: gmx - name: GMX v2 + name: GMX tag: Arbitrum synthetics; on-chain liquidation orders via the public squid formula: "24h sum of sizeDeltaUsd over executed tradeActions with orderType 7 (30-decimal USD, already priced at execution) over peak 24h OI from gmxinfra markets/info, as a percentage." queries: diff --git a/benchmarks/perp-mark-price-lag.yml b/benchmarks/perp-mark-price-lag.yml index 660da9e56..70121d0ca 100644 --- a/benchmarks/perp-mark-price-lag.yml +++ b/benchmarks/perp-mark-price-lag.yml @@ -119,7 +119,7 @@ providers: series: perp_mark_deviation_bps{venue="gains"} - slug: gmx - name: GMX v2 + name: GMX tag: "Oracle-priced (Chainlink), mark read from the GMX v2 oracle API on Arbitrum" formula: "24h p50 of abs(GMX oracle price - Binance spot mid) / Binance spot mid x 10000, sampled every 60 seconds. Mark = mid of minPrice and maxPrice from arbitrum-api.gmxinfra.io/prices/tickers. Binance spot mid is the reference." queries: diff --git a/benchmarks/perp-max-leverage-terms.yml b/benchmarks/perp-max-leverage-terms.yml new file mode 100644 index 000000000..790d1bbcb --- /dev/null +++ b/benchmarks/perp-max-leverage-terms.yml @@ -0,0 +1,211 @@ +# OpenChainBench. Bench 295 + +slug: perp-max-leverage-terms +number: "295" +title: "Perp DEX leverage terms: room to liquidation and what is left" +seo_title: "Perp leverage: how far price moves before liquidation" +seo_description: "How far price moves before Gains, Papertrade, dYdX or Hyperliquid liquidates a position, at each venue's maximum leverage and at shared settings, read on chain." +subtitle: "Adverse price move that liquidates a position, read from each venue's own published parameters and checked against real liquidations. The selector sets the leverage: the default puts each venue at its own maximum, which is not a like for like reading, and the fixed settings put every venue on the same leverage. No row is ranked, because more room is not the same as better terms." +category: Trading +status: live +metric: Room to liquidation +unit: pct +higher_is_better: true +row_noun: { one: venue, many: venues } +zero_is_a_value: true +ledger_reliability: false + +dimensions: + kind: + - { value: BTC, label: BTC } + - { value: ETH, label: ETH } + bucket: + - { value: all, label: "Each venue at its max" } + - { value: 10x, label: 10x } + - { value: 25x, label: 25x } + - { value: 50x, label: 50x } + - { value: 100x, label: 100x } + - { value: 500x, label: 500x } + - { value: 1000x, label: 1000x } + +dimension_labels: + kind: Market + bucket: Leverage + +aggregate_filters: + kind: BTC + +disclaimer: | + Parameters each venue publishes, converted to one model and checked against liquidations that happened. Gains: threshold, max leverage and the pair fee by eth_call on the Arbitrum diamond, checked against 11 liquidations. Papertrade: the instrument tuple on /state/trading, checked as a ceiling on 384 adverse closes. dYdX: both margin fractions on the indexer. Hyperliquid publishes no maintenance requirement, so that row is derived from its documented halving rule. + +seo_intro: | + Four figure leverage is the headline every perp venue leads with, and it says + almost nothing on its own. Leverage is a denominator. What a trader needs is + the numerator: how far the price can move against the position before the + venue closes it, and how much of the collateral comes back when it does. + + Each venue reduces to one parameter, the maintenance requirement it holds back + as a share of the position's notional, plus whatever fees come out of the same + collateral. What separates them is what sets that requirement. Gains ties it + to the collateral, so its room falls close to proportionally with leverage. + Papertrade fixes it in price at about five basis points, so it costs almost + nothing at 10x and roughly half the margin at 1000x. The ranking inverts + depending on which leverage you ask about, which is why the leverage selector + is part of the measurement rather than a convenience. + + Three of the four venues publish every input. Hyperliquid publishes its + maximum leverage but not its maintenance requirement, which its documentation + fixes at half the initial requirement, so that row alone is derived. + +abstract: | + For each venue the bench computes the adverse price move, in percent, that + liquidates a position opened at the selected leverage on the market shown, + the same figure in the unit venues quote among themselves, the venue's maximum + leverage, and the share of collateral returned on liquidation. Everything is + re-read every 5 minutes by the perp-cohort-stats harness. + + One model covers all four. A position at leverage L posts 1/L of notional as + margin and is liquidated when its equity reaches the maintenance requirement + m, also a share of notional. Any fee charged on the way in and out comes out + of that same collateral, so the price has that much less distance to travel: + + room = 1/L - m - 2 x fee collateral lost = room x L + + dYdX publishes m directly. Hyperliquid's is half its initial requirement at + maximum leverage. Gains publishes a liquidation threshold as a share of + collateral, which makes m the complement divided by the leverage, and it + charges a position size fee on both legs. Papertrade publishes a bust buffer + already expressed as a share of price, and charges nothing on either leg. + + The fee term is not a modelling choice. Gains' threshold is 80 percent of + collateral on the x500 pairs, which without fees would put the room at 0.1600 + percent of price. Eleven real liquidations fired at a median 59.4 percent of + collateral, 0.1189 percent of price, and two legs of the 0.02 percent fee at + 500x are exactly the 20 point gap. + +methodology: + - "Cadence: every 5 minutes, on the perp-cohort-stats sweep. These are governance parameters, so they are read fresh rather than averaged, and every series is cleared each tick so a venue that lowers its maximum stops publishing the settings it no longer offers." + - "Leverage selector: the default view puts each venue at its own maximum, which compares four different leverages and is not like for like. The fixed settings put every venue on the same leverage; a venue whose maximum is below the selected setting is absent from that view rather than shown at a leverage it does not offer." + - "Markets: BTC and ETH only. They are the only two markets all four venues list, and the comparison means nothing across different assets." + - "Room formula, every venue: (1/leverage - maintenance requirement - 2 x fee) x 100, every term a share of notional. Collateral lost is that room times the leverage, which is the same measurement in the unit venues quote among themselves." + - "Gains, parameters: pairMaxLeverage and getPairLiquidationParams on the Arbitrum diamond 0xFF162c694eAA571f685030649814282eA457f169, pairs 300 and 313, the x500 cohort rather than the standard pairs which cap at 200x. The fee comes from the pair's fee group, word 9 of pairs() then word 0 of fees() at 1e10, the same read the perp-fees harness runs in production." + - "Gains, checked against liquidations: percentProfit on the LimitExecuted log is the price move times leverage and nothing else, and our liquidation harness reproduces it on 120 of 120 real liquidations. Eleven liquidations on the two degen pairs give a median of 59.4 percent of collateral against the 60.0 percent this model predicts. The remaining gap is the borrowing fee, which accrues with holding time and is not a term at open." + - "Gains, collateral returned: zero at every leverage. amountSentToTrader is 0 on all 27 liquidations found across every pair from 7.5x to 500x. The threshold says when a position closes, not what comes back." + - "Papertrade: max leverage and the bust buffer from the instrument tuple on exchange.papertrade.xyz/state/trading. The buffer is a share of price and is an offset before the zero equity price, not the room itself: their documentation works the 1000x case and gives a move of about 0.052 percent, which is one tenth of a percent minus the buffer." + - "Papertrade, checked against closes: its contract is unverified, so the event fields were identified against external truth rather than an ABI. The price field matches the live Hyperliquid mid under the per instrument factor; the leverage field caps at exactly 1000, the documented maximum; and under the resulting reading the predicted bust distance is never crossed on 384 adverse closes, the closest reaching 92 percent of it." + - "Papertrade, collateral returned: zero. Their documentation calls crossing the bust price a hard bust that forfeits the full isolated margin even where a voluntary close immediately beforehand could return some equity. At 1000x that forgone equity is close to half the margin." + - "dYdX: initialMarginFraction and maintenanceMarginFraction from indexer.dydx.trade/v4/perpetualMarkets, both published per market, with the maximum leverage the reciprocal of the initial fraction. Neither venue's payload carries a fee we can read, so the dYdX and Hyperliquid rows carry no fee legs and their room is the parameter ceiling rather than a fee inclusive figure." + - "Hyperliquid: maxLeverage from the info meta endpoint. The maintenance requirement is not in the payload; its documentation fixes it at half the initial requirement at maximum leverage, so this row alone has a derived input and its share returned reads 50 percent at its maximum on every market by construction." + - "No row is ranked. More room before liquidation comes from holding back less maintenance margin, and the venue that holds back least is the one that keeps everything when it fires. A single ranking on this metric would reward that, whichever venue came out on top, so every row publishes without a rank." + - "Excluded: funding, borrowing, and the liquidation spread. All three move the real trigger closer than the figures here. A position held for days is liquidated by carry long before the price reaches these levels." + - "Cohort: perp venues in the harness that publish enough to compute the figures on BTC and ETH, 4 as of 2026-10-12. Venues that advertise high leverage but publish no maintenance parameter we can read are absent rather than estimated." + +findings: + - "At 500x, the only setting Gains and Papertrade both offer, a Gains position is closed on a 0.12 percent move against it and a Papertrade one on 0.15 percent. Neither returns anything. Gains' figure is the one 11 real liquidations show, not the 0.16 percent its threshold alone would imply." + - "The fee is what separates the threshold from the outcome on Gains. Its contract liquidates at 80 percent of collateral, but the position pays its 0.02 percent fee on the way in and again on the way out, and at 500x those two legs are 20 points of collateral. Liquidations fire at a median 59.4 percent, so the room is a fifth smaller than the parameter suggests." + - "The order changes with the leverage, which is why the selector is part of the measurement. At each venue's own maximum, room shrinks as leverage grows. At 10x, which all four offer, Papertrade leaves the most room and Gains the least, because Gains liquidates at a share of collateral whatever the leverage while Papertrade's buffer is five basis points of price." + - "Neither high leverage venue gives anything back. Gains returns nothing on all 27 liquidations measured from 7.5x to 500x, and Papertrade forfeits the whole margin by rule. The two margin fraction venues do return collateral: dYdX 60 percent and Hyperliquid 50 percent at their own maxima." + - "Read the same rows in collateral terms and the picture changes again. At 1000x Papertrade busts after losing 52 percent of the margin, leaving 48 percent that the hard bust then takes. At 500x it busts at 76 percent against Gains at 60 percent, so the venue that closes you later in price closes you later in collateral too." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/perp-cohort-stats + +prometheus: + window: 24h + expected_freshness_seconds: 1800 + +metric_panels: + - id: collateral_lost + label: Collateral lost + metric: perp_liq_collateral_lost_pct{bucket="all"} + label_key: venue + unit: pct + higher_is_better: true + tab: false + description: "The same measurement as the room, in the unit venues quote among themselves: how much of the margin is already gone when the position is closed. Room times leverage." + - id: max_leverage + label: Max leverage + metric: perp_leverage_max{bucket="all"} + label_key: venue + unit: x + higher_is_better: true + tab: false + description: "The venue's highest leverage setting on this market, shown whatever leverage is selected so you can see how far the selected setting sits from the venue's ceiling." + - id: collateral_kept + label: Collateral returned + metric: perp_liq_collateral_kept_pct{bucket="all"} + label_key: venue + unit: pct + higher_is_better: true + tab: false + description: "Share of the collateral that comes back when the position is liquidated. Zero on Gains, measured on 27 liquidations, and zero on Papertrade, which forfeits the whole margin by rule." + +ledger_columns: + - { label: "Room to liq", slot: p50 } + - { label: "Collateral lost", panel: collateral_lost } + - { label: "Max leverage", panel: max_leverage } + - { label: "Collateral returned", panel: collateral_kept } + +faq: + - q: "What does room to liquidation mean?" + a: "It is the percentage the price has to move against a position before the venue closes it, for a position opened at the selected leverage with no margin added beyond the minimum. It is measured at the moment the position opens and excludes funding and borrowing, which push the real trigger closer every hour the position is held." + - q: "Why does the order change when I change the leverage?" + a: "Because venues set their maintenance requirement against different quantities. Gains ties it to collateral, so its room falls close to proportionally with leverage. Papertrade fixes it in price at about five basis points, so it barely matters at 10x and consumes roughly half the margin at 1000x. At low leverage Papertrade leaves the most room of the four; at its own maximum it leaves the least." + - q: "Why is nothing ranked?" + a: "Because more room is not the same as better terms. Room comes from holding back less maintenance margin, and the venue that holds back least is the one that keeps everything when it fires. A single ranking would reward that, whichever venue came out on top. The rows publish their figures and the reader picks the leverage that matches the question." + - q: "Why does Gains show less room than its own threshold implies?" + a: "Because the fee comes out of the same collateral the threshold is measured against. Its contract liquidates at 80 percent of collateral, which alone would be 0.16 percent of price at 500x, but the position pays 0.02 percent of notional on the way in and again on the way out, and at 500x those two legs are 20 points. Eleven real liquidations fired at a median 59.4 percent of collateral, which is 0.1189 percent of price, against the 0.12 percent this model predicts." + - q: "Does any venue give collateral back on a liquidation?" + a: "The two margin fraction venues do: dYdX returns 60 percent and Hyperliquid 50 percent at their own maxima. Neither high leverage venue does. Gains returned nothing on all 27 liquidations measured across every pair from 7.5x to 500x, and Papertrade's documentation calls crossing the bust price a hard bust that forfeits the full isolated margin." + - q: "How is the Papertrade row checked when its contract is unverified?" + a: "Against external truth rather than an ABI. Its price field matches the live Hyperliquid mid under the per instrument factor from its own instrument tuple, its leverage field caps at exactly the 1000 its documentation states, and under the resulting reading the predicted bust distance is never crossed on 384 adverse closes, the closest reaching 92 percent of it. A ceiling respected 384 times is a measurement, not a decode that happened to look right." + - q: "Why only BTC and ETH?" + a: "They are the only two markets all four venues list. Papertrade lists exactly two instruments; the other three list hundreds, but comparing one venue's BTC terms against another's altcoin terms would not mean anything." + +providers: + - slug: hyperliquid + name: Hyperliquid + tag: Custom L1 CLOB, maintenance requirement derived from the documented halving rule + confidence_tier: estimate + formula: "Room = (1/leverage - 1/(2*maxLeverage)) x 100, with maxLeverage from the info meta endpoint. The maintenance requirement is not published per market; the halving rule is documented. No fee is read from this payload." + queries: + p50: perp_liq_price_move_pct{venue="hyperliquid",bucket="all"} + success: clamp_max(perp_leverage_max{venue="hyperliquid",bucket="all"}, 1) + sample_size: count_over_time(perp_leverage_max{venue="hyperliquid",bucket="all"}[24h]) + series: perp_liq_price_move_pct{venue="hyperliquid",bucket="all"} + ranked: vector(0) + + - slug: dydx + name: dYdX v4 + tag: Cosmos appchain orderbook, both margin fractions published per market + confidence_tier: published + formula: "Room = (1/leverage - maintenanceMarginFraction) x 100, collateral returned = maintenanceMarginFraction x leverage, both fractions from indexer.dydx.trade/v4/perpetualMarkets. No fee is read from this payload." + queries: + p50: perp_liq_price_move_pct{venue="dydx",bucket="all"} + success: clamp_max(perp_leverage_max{venue="dydx",bucket="all"}, 1) + sample_size: count_over_time(perp_leverage_max{venue="dydx",bucket="all"}[24h]) + series: perp_liq_price_move_pct{venue="dydx",bucket="all"} + ranked: vector(0) + + - slug: gains + name: Gains + tag: Synthetic perps on Arbitrum, x500 cohort, threshold and fee read on chain + confidence_tier: onchain + formula: "Room = (threshold - 2 x fee x leverage) / leverage, all read by eth_call on the Arbitrum diamond for pairs 300 and 313. Checked against 11 liquidations: median 59.4% of collateral against 60.0% predicted." + queries: + p50: perp_liq_price_move_pct{venue="gains",bucket="all"} + success: clamp_max(perp_leverage_max{venue="gains",bucket="all"}, 1) + sample_size: count_over_time(perp_leverage_max{venue="gains",bucket="all"}[24h]) + series: perp_liq_price_move_pct{venue="gains",bucket="all"} + ranked: vector(0) + + - slug: papertrade + name: Papertrade + tag: House sided perps on Hyperliquid, bust buffer fixed in price, hard bust + confidence_tier: onchain + formula: "Room = (1/leverage - bust buffer) x 100, the buffer from the /state/trading instrument tuple as a share of price. Verified as a ceiling on 384 adverse closes with no exceedance. Collateral returned = 0, a hard bust." + queries: + p50: perp_liq_price_move_pct{venue="papertrade",bucket="all"} + success: clamp_max(perp_leverage_max{venue="papertrade",bucket="all"}, 1) + sample_size: count_over_time(perp_leverage_max{venue="papertrade",bucket="all"}[24h]) + series: perp_liq_price_move_pct{venue="papertrade",bucket="all"} + ranked: vector(0) diff --git a/benchmarks/perp-protocol-longevity.yml b/benchmarks/perp-protocol-longevity.yml index 3ac27b495..b264ee052 100644 --- a/benchmarks/perp-protocol-longevity.yml +++ b/benchmarks/perp-protocol-longevity.yml @@ -162,7 +162,7 @@ providers: series: perp_protocol_days_clean{venue="paradex"} - slug: gmx - name: GMX v2 + name: GMX tag: "Synthetics on Arbitrum, launched 2023-08-01, exploit 2025-07-09" formula: "Live counter: (current_unix - 2025-07-09T00:00:00Z) / 86400. Incident: reentrancy in vault and order-flow logic, est. USD 42 000 000, source coinperps.xyz post-mortem 2025-07-09. Counter restarts from incident date." queries: diff --git a/benchmarks/perp-volume-oi-ratio.yml b/benchmarks/perp-volume-oi-ratio.yml index 2405f95cc..3fd47c057 100644 --- a/benchmarks/perp-volume-oi-ratio.yml +++ b/benchmarks/perp-volume-oi-ratio.yml @@ -261,7 +261,7 @@ providers: series: perp_venue_volume_24h_usd{venue="gains"} / perp_venue_oi_usd{venue="gains"} - slug: gmx-v2 - name: GMX v2 + name: GMX tag: Arbitrum and Avalanche pool perps; volume from the Subsquid indexer, OI from DefiLlama formula: "avg_over_time(perp_venue_volume_24h_usd{venue=\"gmx-v2\"}[24h]) / avg_over_time(perp_venue_oi_usd{venue=\"gmx-v2\"}[24h])" queries: @@ -363,3 +363,16 @@ providers: success: avg_over_time(perp_venue_health{venue="trade-xyz"}[24h]) sample_size: count_over_time(perp_venue_volume_24h_usd{venue="trade-xyz"}[24h]) series: perp_venue_volume_24h_usd{venue="trade-xyz"} / perp_venue_oi_usd{venue="trade-xyz"} + - slug: papertrade + name: Papertrade + tag: Hyperliquid, single house LP, BTC and ETH only + formula: "Last complete daily volume over open interest, both from the venue's own history API. The four side columns are summed because every position faces the house." + queries: + p50: avg_over_time(perp_venue_volume_24h_usd{venue="papertrade"}[24h]) / avg_over_time(perp_venue_oi_usd{venue="papertrade"}[24h]) + p90: quantile_over_time(0.90, perp_venue_volume_24h_usd{venue="papertrade"}[24h]) / avg_over_time(perp_venue_oi_usd{venue="papertrade"}[24h]) + p99: quantile_over_time(0.99, perp_venue_volume_24h_usd{venue="papertrade"}[24h]) / avg_over_time(perp_venue_oi_usd{venue="papertrade"}[24h]) + mean: avg_over_time(perp_venue_volume_24h_usd{venue="papertrade"}[24h]) / avg_over_time(perp_venue_oi_usd{venue="papertrade"}[24h]) + success: avg_over_time(perp_venue_health{venue="papertrade"}[24h]) + sample_size: count_over_time(perp_venue_volume_24h_usd{venue="papertrade"}[24h]) + series: perp_venue_volume_24h_usd{venue="papertrade"} / perp_venue_oi_usd{venue="papertrade"} + diff --git a/benchmarks/polygon-rpc-ws.yml b/benchmarks/polygon-rpc-ws.yml new file mode 100644 index 000000000..1505a459f --- /dev/null +++ b/benchmarks/polygon-rpc-ws.yml @@ -0,0 +1,177 @@ +# OpenChainBench. Bench № 300 + +slug: polygon-rpc-ws +number: "300" +title: Fastest private Polygon WebSocket push, per-block race across keyed RPC providers +seo_title: "Fastest Polygon WebSocket RPC push 2026, private endpoints" +seo_description: "{{best_name}} has the lowest median lag behind the first Polygon block push among Chainstack, GetBlock, QuickNode and Dwellir." +subtitle: Per-block WebSocket push lag in milliseconds versus the first provider to deliver the same Polygon block, across API-key endpoints probed from us-east, eu-west and Singapore. + +category: RPCs +status: live +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-keyed-latency +metric: Push lag +unit: ms +higher_is_better: false + +seo_intro: | + A bot that subscribes to eth_subscribe(newHeads) does not care what a + provider's median HTTP latency is. It cares who hands it block N first, + and by how much the others trail. This page races the private (Polygon) + cohort: one WebSocket per provider per region, open ten minutes in every + hour across the whole cohort, every frame + timestamped on arrival, each block scored against whichever provider + delivered it earliest. {{best_name}} currently leads at {{best_p50}} + (p50, 24h) across {{count}} keyed providers. + + Because the measurement is relative, the leader reads near zero by + construction. The numbers to read are the gap to the runner-up and the + shape of the trailing distribution, p50 against p99, not the leader's + absolute figure. + +abstract: | + Private, API-key Polygon endpoints raced on WebSocket delivery. One harness + holds an eth_subscribe(newHeads) subscription per provider in + each of three regions and timestamps every push on receipt. Per block, the + earliest arrival across providers in that region defines T0; each provider's + sample is its own arrival minus T0, in milliseconds. + + The same process runs the HTTP arm of the keyed cohort, which is deliberate: + comparing arrival times recorded by separate processes would compare clocks + as much as providers, and the differences here are tens of milliseconds. + +methodology: + - "Race, not latency: all providers on a chain wait for the same block, so only the spread between them belongs to them. Time from block production to first arrival is the chain's block time and is not published as a provider result." + - "One socket per provider per region, eth_subscribe(newHeads), opened for the same ten minutes of every hour across the whole cohort and closed with the window. Every frame is timestamped on receipt. The window is aligned to the clock rather than to each connection, because a race only means something if every provider is listening to the same blocks." + - "Sampled rather than continuous since 2026-10-17, and the reason is ours: a permanent subscription on nine chains pushed about 138 million messages a month per provider against 583 thousand HTTP probes, and one vendor reached its monthly limit. The three fastest chains left the arm and the rest listen a sixth of the time. Racing a provider until its account is exhausted would remove it from nine other pages." + - "Histogram buckets floor at 0.5 ms. The winner reads 0 by construction, so a coarse first bucket collapses every leader's p50 into an interpolation artifact inside it." + - "Reconnections are a published result, not an implementation detail: a provider that drops twice in two minutes is unusable to a subscriber whatever its median says." + - "GetBlock issues one WebSocket token per chain per region, so its three cells are distinct endpoints rather than one URL probed from three places." + - "Alchemy is measured on the HTTP cohort but not here, and the reason is ours rather than theirs: its endpoint bills WebSocket per message, these subscriptions pushed about 116 million frames a month against the Free plan key that row discloses, and the key is shared with the HTTP probes. Racing a provider until its account is exhausted would remove it from nine other pages, so it waits for a message budget guard." + - "Validation Cloud is listed on the HTTP cohorts of this chain but not here: its WebSocket endpoint refuses the upgrade in all three regions (`websocket: bad handshake`), so there is nothing to race. Measured over 3h on 2026-10-17: 542 reconnection attempts, zero frames. The row returns when the endpoint opens." + - "Same keyed endpoints as the HTTP cohort on this chain, same keys, same three regions. Keys live in the probe environment, never in this repository. Plan tiers are disclosed on the HTTP cohort's methodology." + +expected_n: 29000 + +prometheus: + window: 24h + freshness_metric: rpc_ws_head_seen_total + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is fixed for +# the whole bench, so cells key on provider and region. +rank_matrix_query: histogram_quantile(0.50, sum by (provider, region, le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{chain="polygon",tier="keyed"})) + +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +faq: + - q: "Which private RPC provider pushes new Polygon blocks first over WebSocket?" + a: "{{best_name}} has the lowest median lag behind the first arrival over 24h; a figure inside the first histogram bucket reads as zero. {{worst_name}} trails most, at {{worst_p50}} (p50, 24h). The board re-scores on every Polygon block. Read the p99 column before wiring a latency-sensitive bot to one provider." + - q: "Why does the fastest provider show a lag near zero?" + a: "By construction. Each block is a race: the earliest arrival defines T0 and every sample is measured against it, so the provider that wins most races accumulates mostly zero samples. A single vantage point cannot measure absolute production-to-client latency without a node co-located with block producers; it can measure fairly which provider delivers the same block earlier over the same path. Read the gap to the runner-up and the trailing distribution, not the leader's absolute number." + - q: "Is this the same as the HTTP latency cohort on this chain?" + a: "Same providers, same keys, same regions, different question. The HTTP cohort measures a request you send; this measures a push you wait for, and the two orderings are not the same. GetBlock issues one WebSocket token per chain per region, so its three cells are distinct endpoints rather than one URL probed from three places." + - q: "Could a provider win by pushing blocks before validating them?" + a: "In principle, at the cost of occasionally pushing something that gets retracted. That is why coverage is published next to the lag: a provider that pushes early but wrong shows gaps, and the coverage column counts blocks seen against the cohort maximum. Hash-level cross-checking against an independent reference is not in this version." + +providers: + - slug: chainstack + name: Chainstack + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Polygon block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="polygon",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="polygon",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="polygon",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="polygon",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="chainstack",chain="polygon",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="chainstack",chain="polygon",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="chainstack",chain="polygon",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="polygon",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="chainstack",chain="polygon",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="polygon",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="polygon",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="polygon",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="polygon",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="polygon",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="chainstack",chain="polygon",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="chainstack",chain="polygon",tier="keyed",region="sgp"}[1h]))) + + - slug: getblock + name: GetBlock + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Polygon block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="polygon",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="polygon",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="polygon",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="polygon",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="getblock",chain="polygon",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="getblock",chain="polygon",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="getblock",chain="polygon",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="polygon",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="getblock",chain="polygon",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="polygon",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="polygon",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="polygon",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="polygon",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="polygon",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="getblock",chain="polygon",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="getblock",chain="polygon",tier="keyed",region="sgp"}[1h]))) + + - slug: quicknode + name: QuickNode + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Polygon block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="polygon",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="polygon",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="polygon",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="polygon",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="quicknode",chain="polygon",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="quicknode",chain="polygon",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="quicknode",chain="polygon",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="polygon",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="quicknode",chain="polygon",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="polygon",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="polygon",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="polygon",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="polygon",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="polygon",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="quicknode",chain="polygon",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="quicknode",chain="polygon",tier="keyed",region="sgp"}[1h]))) + + - slug: dwellir + name: Dwellir + tag: Private WS, same keyed endpoint as the HTTP cohort + formula: "Median ms behind the first provider to push each Polygon block over eth_subscribe(newHeads), from one socket per provider per region, histogram_quantile over 24h. The per-block winner scores 0 by construction." + queries: + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="polygon",tier="keyed"})) + p90: histogram_quantile(0.90, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="polygon",tier="keyed"})) + p95: histogram_quantile(0.95, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="polygon",tier="keyed"})) + p99: histogram_quantile(0.99, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="polygon",tier="keyed"})) + mean: sum(ocb:rpc_ws_head_lead_milliseconds_sum:rate24h{provider="dwellir",chain="polygon",tier="keyed"}) / sum(ocb:rpc_ws_head_lead_milliseconds_count:rate24h{provider="dwellir",chain="polygon",tier="keyed"}) + success: clamp_max(sum(increase(rpc_ws_head_seen_total{provider="dwellir",chain="polygon",tier="keyed"}[24h])) / max(sum by (provider) (increase(rpc_ws_head_seen_total{chain="polygon",tier="keyed"}[24h]))), 1) + sample_size: sum(increase(rpc_ws_head_seen_total{provider="dwellir",chain="polygon",tier="keyed"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="polygon",tier="keyed"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="polygon",tier="keyed",region="us-east"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="polygon",tier="keyed",region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="polygon",tier="keyed",region="eu-west"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="polygon",tier="keyed",region="eu-west"}[1h]))) + - region: ap-southeast + p50: histogram_quantile(0.50, sum by (le) (ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h{provider="dwellir",chain="polygon",tier="keyed",region="sgp"})) + series: histogram_quantile(0.50, sum by (le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket{provider="dwellir",chain="polygon",tier="keyed",region="sgp"}[1h]))) + diff --git a/benchmarks/polygon-rpc.yml b/benchmarks/polygon-rpc.yml index ec856eacd..f0733ebea 100644 --- a/benchmarks/polygon-rpc.yml +++ b/benchmarks/polygon-rpc.yml @@ -5,7 +5,7 @@ number: "050" title: "Polygon RPC endpoints: free public URLs and private API-key providers by latency" seo_title: "Polygon RPC endpoints: public and private, ranked by latency" seo_description: "{{best_name}} leads free Polygon RPC at {{best_p50}} (p50, 24h); URLs for all {{count}} no-key endpoints. {{best_name:tier:keyed}} leads the private (API-key) cohort at {{best_p50:tier:keyed}}." -subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Polygon RPC endpoint (probed every 60 seconds) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock and QuickNode (every 120 seconds), from 3 regions. The two cohorts are ranked separately, never against each other. +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Polygon RPC endpoint (probed every 60 seconds) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock, QuickNode and Dwellir (every 120 seconds), from 3 regions. The two cohorts are ranked separately, never against each other. category: RPCs status: live @@ -17,7 +17,7 @@ seo_intro: | Polygon has no chain-official endpoint in the free tier, so this is the purest gateway-versus-gateway comparison in the cluster: {{count}} gateways (the endpoints table names them), all answering the same `eth_getBlockByNumber` probe every 60 seconds from three regions. With no house endpoint to anchor expectations, the regional flips between gateways decide the ranking, and they flip often. abstract: | - Polygon: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock and QuickNode on their keyed Polygon endpoints every 120 seconds: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. + Polygon: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock, QuickNode and Dwellir on their keyed Polygon endpoints every 120 seconds: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. 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, {{count}} providers, every 60 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: @@ -25,7 +25,7 @@ methodology: - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}`. Plain HTTP POST, identical for every endpoint, no API key in any request. The request id rotates, but that does not guarantee a cache miss: many caches normalise `id` away. Measured 2026-10-08, fixed and rotating ids answer within 2 to 4 percent of each other." - "Latency measurement, call-result classification (`ok`, `http_err`, `jsonrpc_err`, `stale`, `timeout`) and the archive-depth audit follow the cluster method documented on [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities#methodology); this page is the Polygon-scoped view of the same harness, with the same exclusion rules." - "Chain scope: every query on this page is pinned to chain=\"polygon\". Provider coverage: {{count}} no-key public-read endpoints live today, each listed with its URL in the endpoints table on this page. Exclusions follow the cluster-wide rules documented on the parent benchmark." - - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock and QuickNode probed on their keyed Polygon endpoints every 120 seconds from the same 3 regions, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." + - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock, QuickNode and Dwellir probed on their keyed Polygon endpoints every 120 seconds from the same 3 regions, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." - "Keys live in the probe environment, never in the repo. Plan tiers, disclosed: Alchemy: a Free plan key (30M CU per month, 300 compute units per second), the tier any reader gets; Alchemy states free and paid keys share one routing; Chainstack: a Global Node on the Growth plan; QuickNode: an endpoint on a Build plan ($49 per month), shared fleet; GetBlock: an endpoint on the Enterprise plan, shared by the three probe regions. Chainstack, GetBlock and QuickNode provided their endpoints." - "Dwellir plan tier, disclosed: a Developer plan key ($49 per month, 25 million API responses, 100 responses per second; 1 RPC response = 1 API credit, no compute units). Shared nodes, the same product a reader buys. Dwellir publishes no Solana endpoint, so it is absent from that page rather than shown as unavailable." - "GetBlock regional pairing: each probe region uses GetBlock's nearest endpoint, us-east to New York, eu-west to Frankfurt, sgp to Singapore. Alchemy, Chainstack and QuickNode publish one routed entry point per chain instead, which in the general case does land a probe on a nearby node. Some of any gap between the two arrangements is deployment topology rather than node speed, and a deployment in one of these regions would see the same benefit GetBlock's endpoint gives the probe." @@ -146,32 +146,6 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="polygon", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="polygon", region="sgp"}[1h]) - - slug: nodies - name: Nodies - endpoint: https://polygon-pokt.nodies.app - 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_getBlockByNumber` POST sent every 60s 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"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="nodies", chain="polygon"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="nodies", chain="polygon"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="nodies", chain="polygon"}) / sum(ocb:rpc_call:rate_24h{provider="nodies", chain="polygon"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="nodies", chain="polygon"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="nodies", chain="polygon"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="polygon", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="nodies", chain="polygon", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="polygon", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="nodies", chain="polygon", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="polygon", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="nodies", chain="polygon", region="sgp"}[1h]) - - # API-key cohort (tier: keyed). Queries pin tier="keyed", the label the - # rpc-keyed-latency harness emits; the public rows above carry no tier label. - slug: alchemy name: Alchemy tier: keyed @@ -180,6 +154,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="polygon",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="alchemy",chain="polygon",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="alchemy",chain="polygon",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="alchemy",chain="polygon",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="alchemy",chain="polygon",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="polygon",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="polygon",tier="keyed"}) @@ -204,6 +179,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="polygon",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="chainstack",chain="polygon",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="chainstack",chain="polygon",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="chainstack",chain="polygon",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="chainstack",chain="polygon",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="polygon",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="polygon",tier="keyed"}) @@ -228,6 +204,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="getblock",chain="polygon",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="getblock",chain="polygon",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="getblock",chain="polygon",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="getblock",chain="polygon",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="getblock",chain="polygon",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="polygon",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="polygon",tier="keyed"}) @@ -252,6 +229,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="polygon",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="quicknode",chain="polygon",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="quicknode",chain="polygon",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="quicknode",chain="polygon",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="quicknode",chain="polygon",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="polygon",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="polygon",tier="keyed"}) @@ -276,6 +254,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir",chain="polygon",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="dwellir",chain="polygon",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="dwellir",chain="polygon",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="dwellir",chain="polygon",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="dwellir",chain="polygon",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="polygon",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="polygon",tier="keyed"}) @@ -300,6 +279,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="alchemy",chain="polygon",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="alchemy",chain="polygon",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="alchemy",chain="polygon",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="alchemy",chain="polygon",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="alchemy",chain="polygon",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="polygon",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="polygon",tier="keyed"}) @@ -323,6 +303,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="chainstack",chain="polygon",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="chainstack",chain="polygon",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="chainstack",chain="polygon",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="chainstack",chain="polygon",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="chainstack",chain="polygon",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="polygon",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="polygon",tier="keyed"}) @@ -346,6 +327,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="getblock",chain="polygon",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="getblock",chain="polygon",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="getblock",chain="polygon",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="getblock",chain="polygon",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="getblock",chain="polygon",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="polygon",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="polygon",tier="keyed"}) @@ -369,6 +351,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="quicknode",chain="polygon",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="quicknode",chain="polygon",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="quicknode",chain="polygon",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="quicknode",chain="polygon",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="quicknode",chain="polygon",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="polygon",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="polygon",tier="keyed"}) @@ -392,6 +375,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="dwellir",chain="polygon",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="dwellir",chain="polygon",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="dwellir",chain="polygon",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="dwellir",chain="polygon",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="dwellir",chain="polygon",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="polygon",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="polygon",tier="keyed"}) @@ -407,3 +391,4 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="dwellir",chain="polygon",tier="keyed",region="sgp"}) series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="dwellir",chain="polygon",tier="keyed",region="sgp"}[1h])) + diff --git a/benchmarks/robinhood-rpc.yml b/benchmarks/robinhood-rpc.yml index a7f2a5c59..e3625e9a5 100644 --- a/benchmarks/robinhood-rpc.yml +++ b/benchmarks/robinhood-rpc.yml @@ -3,9 +3,9 @@ slug: robinhood-rpc number: "243" title: "Robinhood Chain RPC endpoints: free public URLs and private API-key providers by latency" -seo_title: "Robinhood Chain RPC endpoints: public and private, by latency" +seo_title: "Robinhood Chain RPC: public and private endpoints by latency" seo_description: "{{best_name}} leads free Robinhood Chain RPC at {{best_p50}} (p50, 24h); URLs for all {{count}} no-key endpoints. {{best_name:tier:keyed}} leads the private (API-key) cohort at {{best_p50:tier:keyed}}." -subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Robinhood Chain RPC endpoint (probed every 60 seconds from 3 regions) and, under the Access selector (Private), against the API-key endpoints of Chainstack, Alchemy, QuickNode and GetBlock (every 120 seconds from us-east and eu-west). The two cohorts are ranked separately, never against each other. +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Robinhood Chain RPC endpoint (probed every 60 seconds from 3 regions) and, under the Access selector (Private), against the API-key endpoints of Alchemy, Chainstack, GetBlock, QuickNode and Dwellir (every 120 seconds). The two cohorts are ranked separately, never against each other. category: RPCs status: live @@ -18,30 +18,31 @@ seo_intro: | tokenized equity trading, live on mainnet since July 2026 with about 100 ms blocks. Its RPC field is young: a rate-limited official endpoint, three public gateways (dRPC, PublicNode, bloXroute) and keyed endpoints at - Chainstack, Alchemy, QuickNode and GetBlock. This page measures both sides + Alchemy, Chainstack, GetBlock, QuickNode and Dwellir. This page measures both sides with one probe, `eth_getBlockByNumber` with a rotating request id, every 60 seconds on the {{count}} free endpoints from us-east, eu-west and Singapore, and - every 120 seconds on the keyed ones from us-east and eu-west: GetBlock does - not offer Robinhood Chain in Singapore, and averaging a two-region row - against three-region rows would compare different populations, so the keyed - cohort is measured from the two regions the chain is served from. The Access + every 120 seconds on the keyed ones from the same three regions. The keyed + headline averages us-east and eu-west only: GetBlock does not offer + Robinhood Chain in Singapore, and averaging a two-region row against + three-region rows would compare different populations. Singapore is + published per region for the five vendors that answer the Singapore probe. The Access selector switches between the two cohorts, which are never ranked against each other. abstract: | - Robinhood Chain: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}; the Robinhood-operated endpoint measures {{p50:robinhood-official}}. The same page carries the private cohort (API key), Chainstack, Alchemy, QuickNode and GetBlock on their keyed Robinhood Chain endpoints every 120 seconds from us-east and eu-west: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. - We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Robinhood Chain endpoint that sustains continuous probing, {{count}} providers, every 60 seconds, from us-east, eu-west and Singapore, and against the keyed endpoints of Chainstack, Alchemy, QuickNode and GetBlock every 120 seconds from us-east and eu-west, the two regions the chain is served from. 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. Robinhood Chain runs on the Arbitrum Orbit stack with about 100 ms block times; the probe samples the serving path, not block production. + Robinhood Chain: {{count}} free public RPC endpoints answering `eth_getBlockByNumber` every 60 seconds from us-east, eu-west and Singapore, ranked by 24h median. {{best_name}} leads at {{best_p50}}; the Robinhood-operated endpoint measures {{p50:robinhood-official}}. The same page carries the private cohort (API key), Alchemy, Chainstack, GetBlock, QuickNode and Dwellir on their keyed Robinhood Chain endpoints every 120 seconds from us-east and eu-west: switch the Access selector; {{best_name:tier:keyed}} leads it at {{best_p50:tier:keyed}}. + We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Robinhood Chain endpoint that sustains continuous probing, {{count}} providers, every 60 seconds, from us-east, eu-west and Singapore, and against the keyed endpoints of Alchemy, Chainstack, GetBlock, QuickNode and Dwellir every 120 seconds from the same three regions, the keyed headline averaging us-east and eu-west because GetBlock has no Robinhood Chain endpoint in 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. Robinhood Chain runs on the Arbitrum Orbit stack with about 100 ms block times; the probe samples the serving path, not block production. methodology: - - "Cadence: the public cohort every 60 seconds per provider from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore), its headline p50/p90/p99 aggregating all 3 via Prometheus `avg(quantile_over_time(...))`. The keyed cohort runs every 120 seconds from us-east and eu-west and aggregates those two. Per-region breakdowns are first-class via the region tabs." + - "Cadence: the public cohort every 60 seconds per provider from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore), its headline p50/p90/p99 aggregating all 3 via Prometheus `avg(quantile_over_time(...))`. The keyed cohort runs every 120 seconds from the same 3 regions; its headline averages us-east and eu-west only, for the GetBlock coverage reason below. 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. The request id rotates, but that does not guarantee a cache miss: many caches normalise `id` away. Measured 2026-10-08, fixed and rotating ids answer within 2 to 4 percent of each other." - "Latency measurement, call-result classification (`ok`, `http_err`, `jsonrpc_err`, `stale`, `timeout`) and the archive-depth audit follow the cluster method documented on [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities#methodology); this page is the Robinhood Chain-scoped view of the same harness, with the same exclusion rules." - "Chain scope: every query on this page is pinned to chain=\"robinhood\" (chain id 4663). Provider coverage: {{count}} no-key public-read endpoints live today, each listed with its URL in the endpoints table on this page. Exclusions follow the cluster-wide rules documented on the parent benchmark." - - "Private cohort (Access selector, Private): Chainstack, Alchemy, QuickNode and GetBlock probed on their keyed Robinhood Chain endpoints every 120 seconds from us-east and eu-west, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." + - "Private cohort (Access selector, Private): Alchemy, Chainstack, GetBlock, QuickNode and Dwellir probed on their keyed Robinhood Chain endpoints every 120 seconds from all 3 probe regions, same call (eth_getBlockByNumber, rotating id), same ok / http_err / jsonrpc_err / stale / timeout classification." - "Keys live in the probe environment, never in the repo. Plan tiers, disclosed: Chainstack: a Global Node on the Growth plan; Alchemy: a Free plan key (30M CU per month, 300 compute units per second), the tier any reader gets; Alchemy states free and paid keys share one routing; QuickNode: an endpoint on a Build plan ($49 per month), shared fleet; GetBlock: an endpoint on the Enterprise plan, shared by the two probe regions. Chainstack, GetBlock and QuickNode provided their endpoints." - "Dwellir plan tier, disclosed: a Developer plan key ($49 per month, 25 million API responses, 100 responses per second; 1 RPC response = 1 API credit, no compute units). Shared nodes, the same product a reader buys. Dwellir publishes no Solana endpoint, so it is absent from that page rather than shown as unavailable." - "GetBlock regional pairing: us-east uses GetBlock's New York endpoint and eu-west its Frankfurt endpoint. Alchemy, Chainstack and QuickNode publish one routed entry point per chain instead, which in the general case does land a probe on a nearby node. Some of any gap between the two arrangements is deployment topology rather than node speed, and a deployment in one of these regions would see the same benefit GetBlock's endpoint gives the probe." - - "GetBlock coverage: GetBlock does not offer Robinhood Chain in Singapore, so no GetBlock row exists for that region: the endpoint is not offered there, which is not a failed or pending measurement. The whole keyed cohort is therefore measured from us-east and eu-west, because averaging a two-region row against three-region rows would compare different populations." + - "GetBlock coverage: GetBlock does not offer Robinhood Chain in Singapore, so no GetBlock figure exists for that region: the endpoint is not offered there, which is not a failed or pending measurement. The keyed headline therefore averages us-east and eu-west for every vendor, because averaging a two-region row against three-region rows would compare different populations. Singapore is published per region for the five that answer the Singapore probe." - "Two cohorts, two rankings: the public endpoints are shared, rate-limited gateways probed every 60 seconds; the keyed endpoints are metered or dedicated and probed every 120 seconds. The selector switches the whole page (ranking, chart, region tabs, badges, citation) between them. No figure on this page compares a public endpoint with a keyed one." - "Robinhood Chain runs on the Arbitrum Orbit stack with about 100 ms block times; the probe samples the serving path, not block production. The stale check tolerates 200 blocks behind the cross-provider tip (20 s of wall clock, the same tolerance as the 1 s chains at 20 blocks) on the public cohort and 20 blocks on the keyed one." @@ -55,7 +56,7 @@ faq: - q: "Which Robinhood Chain RPCs work without an API key?" a: "{{declared_count}} endpoints are probed on this chain; each is listed with its URL, 24h median and success rate in the endpoints table on this page, and the list changes as providers come and go. Every (provider, chain) pair was live-verified no-key before inclusion; anything that key-gates, region-blocks or rate-limits below the 60-second cadence is excluded rather than listed with an asterisk." - q: "Which Robinhood Chain RPC provider is fastest with an API key?" - a: "Per the live data: {{best_name:tier:keyed}} at {{best_p50:tier:keyed}} (eth_getBlockByNumber p50 over 24h, averaged across us-east and eu-west); Alchemy measures {{p50:alchemy}}, Chainstack {{p50:chainstack}} and QuickNode {{p50:quicknode}}; GetBlock measures {{p50:getblock}}. The keyed cohort is measured from those two regions only, because GetBlock does not offer Robinhood Chain in Singapore and averaging a two-region row against three-region rows would compare different populations; no keyed row exists for the Singapore tab, which is a coverage fact rather than a failed measurement. On the free cohort {{best_name}} leads at {{best_p50}}, measured on the same call from 3 regions; the two cohorts are never ranked in one table (shared gateways at 60 s against metered endpoints at 120 s). Switch the Access selector to Private for the region tabs and the p99 column; plan tiers are disclosed in the methodology." + a: "Per the live data: {{best_name:tier:keyed}} at {{best_p50:tier:keyed}} (eth_getBlockByNumber p50 over 24h, averaged across us-east and eu-west); Alchemy measures {{p50:alchemy}}, Chainstack {{p50:chainstack}} and QuickNode {{p50:quicknode}}; GetBlock measures {{p50:getblock}}. The headline averages those two regions only, because GetBlock does not offer Robinhood Chain in Singapore and averaging a two-region row against three-region rows would compare different populations; the Singapore figures are published per region for the five vendors that answer that probe, and GetBlock's absence there is a coverage fact rather than a failed measurement. On the free cohort {{best_name}} leads at {{best_p50}}, measured on the same call from 3 regions; the two cohorts are never ranked in one table (shared gateways at 60 s against metered endpoints at 120 s). Switch the Access selector to Private for the region tabs and the p99 column; plan tiers are disclosed in the methodology." - q: "What is Robinhood Chain?" a: "Robinhood Chain (chain ID 4663) is an Arbitrum Orbit L2 built by Robinhood for tokenized equity trading. It launched on mainnet in July 2026 with about 100 ms block times and a gas fee subsidy running through September 29, 2026. It supports EVM tooling and standard eth_ JSON-RPC methods; the official endpoint rpc.mainnet.chain.robinhood.com is free, rate-limited and documented as suited to wallet connectivity and testing." @@ -192,6 +193,11 @@ providers: # rebuilt to remove, so the whole cohort is measured from us-east and # eu-west. The public cohort above is untouched and still spans 3 regions. # + # Each vendor that answers from Singapore still carries an explicit + # `- region: sgp` entry below. Those are separate queries from the headline, + # so the hub and the geo view can show the Singapore measurement without it + # entering the two-region average the ranking rests on. + # # Written as a negative matcher, not region=~"us-east|eu-west", and that is # load-bearing: injectLabels() treats any `region=` (including `=~`) as # already filtered and would then inject nothing, so every region tab would @@ -206,6 +212,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="robinhood",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="chainstack",chain="robinhood",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="chainstack",chain="robinhood",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="chainstack",chain="robinhood",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="chainstack",chain="robinhood",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="robinhood",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="robinhood",tier="keyed",region!="sgp"}) @@ -218,6 +225,9 @@ providers: - region: eu-west p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="robinhood",tier="keyed",region="eu-west"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="chainstack",chain="robinhood",tier="keyed",region="eu-west"}[1h])) + - region: sgp + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="robinhood",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="chainstack",chain="robinhood",tier="keyed",region="sgp"}[1h])) - slug: alchemy name: Alchemy @@ -227,6 +237,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="robinhood",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="alchemy",chain="robinhood",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="alchemy",chain="robinhood",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="alchemy",chain="robinhood",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="alchemy",chain="robinhood",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="robinhood",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="robinhood",tier="keyed",region!="sgp"}) @@ -239,6 +250,9 @@ providers: - region: eu-west p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="robinhood",tier="keyed",region="eu-west"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="alchemy",chain="robinhood",tier="keyed",region="eu-west"}[1h])) + - region: sgp + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="robinhood",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="alchemy",chain="robinhood",tier="keyed",region="sgp"}[1h])) - slug: quicknode name: QuickNode @@ -248,6 +262,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="robinhood",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="quicknode",chain="robinhood",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="quicknode",chain="robinhood",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="quicknode",chain="robinhood",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="quicknode",chain="robinhood",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="robinhood",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="robinhood",tier="keyed",region!="sgp"}) @@ -260,6 +275,9 @@ providers: - region: eu-west p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="robinhood",tier="keyed",region="eu-west"}) series: avg(avg_over_time(rpc_latency_milliseconds{provider="quicknode",chain="robinhood",tier="keyed",region="eu-west"}[1h])) + - region: sgp + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="robinhood",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="quicknode",chain="robinhood",tier="keyed",region="sgp"}[1h])) - slug: getblock name: GetBlock @@ -269,6 +287,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="getblock",chain="robinhood",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="getblock",chain="robinhood",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="getblock",chain="robinhood",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="getblock",chain="robinhood",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="getblock",chain="robinhood",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="robinhood",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="robinhood",tier="keyed",region!="sgp"}) @@ -290,6 +309,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="chainstack",chain="robinhood",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="chainstack",chain="robinhood",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="chainstack",chain="robinhood",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="chainstack",chain="robinhood",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="chainstack",chain="robinhood",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="robinhood",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="robinhood",tier="keyed",region!="sgp"}) @@ -302,6 +322,9 @@ providers: - region: eu-west p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="chainstack",chain="robinhood",tier="keyed",region="eu-west"}) series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="chainstack",chain="robinhood",tier="keyed",region="eu-west"}[1h])) + - region: sgp + p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="chainstack",chain="robinhood",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="chainstack",chain="robinhood",tier="keyed",region="sgp"}[1h])) - slug: dwellir name: Dwellir tier: keyed @@ -310,6 +333,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir",chain="robinhood",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="dwellir",chain="robinhood",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="dwellir",chain="robinhood",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="dwellir",chain="robinhood",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="dwellir",chain="robinhood",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="robinhood",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="robinhood",tier="keyed"}) @@ -334,6 +358,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="alchemy",chain="robinhood",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="alchemy",chain="robinhood",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="alchemy",chain="robinhood",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="alchemy",chain="robinhood",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="alchemy",chain="robinhood",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="robinhood",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="robinhood",tier="keyed",region!="sgp"}) @@ -346,6 +371,9 @@ providers: - region: eu-west p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="alchemy",chain="robinhood",tier="keyed",region="eu-west"}) series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="alchemy",chain="robinhood",tier="keyed",region="eu-west"}[1h])) + - region: sgp + p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="alchemy",chain="robinhood",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="alchemy",chain="robinhood",tier="keyed",region="sgp"}[1h])) - slug: quicknode-cold name: QuickNode tier: keyed-cold @@ -354,6 +382,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="quicknode",chain="robinhood",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="quicknode",chain="robinhood",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="quicknode",chain="robinhood",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="quicknode",chain="robinhood",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="quicknode",chain="robinhood",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="robinhood",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="robinhood",tier="keyed",region!="sgp"}) @@ -366,6 +395,9 @@ providers: - region: eu-west p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="quicknode",chain="robinhood",tier="keyed",region="eu-west"}) series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="quicknode",chain="robinhood",tier="keyed",region="eu-west"}[1h])) + - region: sgp + p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="quicknode",chain="robinhood",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="quicknode",chain="robinhood",tier="keyed",region="sgp"}[1h])) - slug: getblock-cold name: GetBlock tier: keyed-cold @@ -374,6 +406,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="getblock",chain="robinhood",tier="keyed",region!="sgp"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="getblock",chain="robinhood",tier="keyed",region!="sgp"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="getblock",chain="robinhood",tier="keyed",region!="sgp"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="getblock",chain="robinhood",tier="keyed",region!="sgp"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="getblock",chain="robinhood",tier="keyed",region!="sgp"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="robinhood",tier="keyed",region!="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="robinhood",tier="keyed",region!="sgp"}) @@ -394,6 +427,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="dwellir",chain="robinhood",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="dwellir",chain="robinhood",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="dwellir",chain="robinhood",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="dwellir",chain="robinhood",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="dwellir",chain="robinhood",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir",chain="robinhood",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir",chain="robinhood",tier="keyed"}) @@ -409,3 +443,4 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="dwellir",chain="robinhood",tier="keyed",region="sgp"}) series: avg(avg_over_time(rpc_latency_cold_milliseconds{provider="dwellir",chain="robinhood",tier="keyed",region="sgp"}[1h])) + diff --git a/benchmarks/rpc-free-tier.yml b/benchmarks/rpc-free-tier.yml new file mode 100644 index 000000000..997762379 --- /dev/null +++ b/benchmarks/rpc-free-tier.yml @@ -0,0 +1,261 @@ +# OpenChainBench. Bench № 283 + +slug: rpc-free-tier +number: "283" +title: Most requests a free RPC plan buys, across 12 providers +seo_title: "Best free RPC provider 2026: how far each free plan goes" +seo_description: "How many requests a month each free RPC plan actually buys. Alchemy, Infura, Ankr, dRPC, Chainstack and 7 more, converted from credits into requests." +subtitle: How many requests of a real workload each provider's free plan allowance buys in a month, once credits, Compute Units and Request Units are converted into the requests they actually pay for. + +category: RPCs +status: live +metric: Free allowance +unit: count +higher_is_better: true +row_noun: { one: plan, many: plans } + +disclaimer: > + Allowance, not latency and not reliability. A plan that buys the most + requests is not thereby the fastest or the most correct; those are measured + on the per-chain RPC pages. Published allowances, human-verified against + each provider's own pricing page with the date it was read. A free plan is + one that costs nothing and renews, so QuickNode's one-month trial is not + here. Where a provider publishes a daily cap it is multiplied by 30. + +seo_intro: | + "200 million credits a month" tells you nothing until you know what a credit + buys. Providers meter free plans in four different units, and the same unit + costs a different number of credits depending on the method called, so the + advertised numbers cannot be compared as printed. This page converts every + free allowance into the one unit a developer can reason about: how many + requests of a real workload it pays for before the plan stops or starts + charging. The answer moves with the workload, which is why the figure is + published per workload rather than as one number. A dapp frontend calling + eth_call and eth_getBalance drains an allowance at a different rate than a + log indexer calling eth_getLogs, and on BlockPI's free plan the gap between + the cheapest workload and the most expensive is large on a single plan, + with nothing on the pricing page saying + so. Every row carries the two ends of that span next to its headline + figure, so the plans a workload cannot surprise are visible without + reading four tabs: Ankr and dRPC meter flat, and their floor is their + ceiling. + +abstract: | + We take each provider's free plan allowance as published, in whatever unit + the provider meters, and divide it by what one request of a given workload + costs in that unit. The result is the number of requests the free plan buys + in a month. The unit conversion is the whole point: Alchemy meters Compute + Units weighted per method, Chainstack meters one flat Request Unit for every + method, Infura meters credits on a daily cap, and Ankr meters flat requests. + Those four numbers are not comparable until they are divided by the method + mix that spends them. + + Five mixes are priced and four are offered as tabs. The unfiltered view is + the dapp frontend mix on Ethereum, the case most readers arrive with. A plan + that leads one workload can trail on another, and the table does not hide + that by averaging them: an allowance averaged across a trace mix and a + simple-read mix describes no workload anyone runs, and taking each plan's + best workload instead would rank every provider on its most flattering + measurement. So the span is reported rather than collapsed. Beside each + headline figure sit the least and the most generous workload that plan + serves on that chain, which is what separates an allowance that can be + budgeted from one that happens to be large on the mix you opened. + +methodology: + - "Allowance source: each provider's published free plan, read from its own pricing page or API, with the date of reading and a confidence flag recorded in the catalogue. The same catalogue prices the paid plans on bench 282; this page reads only the rows whose monthly price is zero and whose allowance renews." + - "Confidence, stated rather than implied. Eight of the ten ranked allowances are verified against the provider's own published figure. Two are not, and both are named here. dRPC publishes its 210M CU allowance but nothing about what happens on exhausting it. Blockdaemon's documentation disagrees with itself: an October plan table says 1M CU a month where an April page and an archived pricing card say 3M, and 3M is what two sources carry." + - "Unit conversion: allowance divided by the weighted cost of one request of the selected workload, in the provider's own unit. A workload is a fixed method mix with shares that sum to 1, so the weighted cost is the sum of each method's unit weight times its share. A provider that meters flat requests has a weight of 1 on every method." + - "Periods that are not a month: Infura, GetBlock and OnFinality publish a daily allowance, multiplied by 30, the most generous reading, and OnFinality at 40 response units a second cannot reach its monthly figure in a burst. BlockPI's 50M RU package arrives on the 1st of every month and stays valid 32 days without accumulating, so a month delivers 50M and the overlap is forfeited. Every plan stays subject to its throughput ceiling, which this page does not score." + - "What counts as free: a plan that costs nothing and renews. QuickNode's Free trial is excluded because it runs for one month and does not renew; it is priced on bench 282 as an entry plan instead. Public endpoints with no key and no published allowance are also excluded: there is no allowance to rank." + - "Headline slice: the unfiltered view is the dapp mix on Ethereum, published under the `all` label as the headline slice and not as a pooled average, because an allowance averaged across workloads describes none of them. Switching a tab reads a different slice, never a blend. The chain is a property of the workload rather than a second axis: the Ethereum mixes cover 10 of the 12 plans and the Solana bot mix covers 11, so a provider absent from one tab serves a chain that tab does not ask about." + - "Floor and ceiling: the two columns beside the headline are the least and the most generous of the workloads that plan serves on the selected chain, over all five priced mixes including the simple-read mix, which has no tab. They describe the plan rather than the open tab, so they read the same on every tab. A workload the plan refuses is excluded, not counted as zero: Alchemy's free tier does no trace, so its floor is its indexer mix and the refusal shows as its absence from the trace tab." + - "Why the span is reported and not collapsed: the two ends are published separately rather than averaged into one number, and the provider's own best workload is never used as its score. Both would rank each plan on a different workload from its neighbour. Floor equal to ceiling is a measurement, not a placeholder: a plan metering one flat unit per method buys the same number of requests whatever is called, and on Solana only one mix is priced, so every row there reports a degenerate span." + - "A plan that refuses the workload is absent from its tab, not ranked at zero, and one plan is absent from all four. Dwellir's Free plan buys 100,000 responses a day at one credit each, 3M simple reads a month, but it excludes eth_getLogs, which the dapp and indexer mixes call, and serves no trace or debug, and Dwellir serves no Solana. Its allowance is real and spends on none of the workloads this page asks about. Its paid plans are on bench 282." + - "Not scored here: latency, reliability, archive depth, throughput ceilings and support. A large allowance behind a 25 requests per second cap serves a different application than the same allowance behind 300. The per-chain RPC pages measure the speed of these same endpoints." + +faq: + - q: "Which free RPC plan gives the most requests?" + a: "It depends on the workload, and the table reorders when you change it. On the Ethereum dapp mix the leader is the plan at the top of the board right now; on a log indexer mix the order is different, because methods like eth_getLogs cost more units on providers that weight per method and cost the same as any other call on providers that do not. Read the figure for the workload you actually run, and read the two columns beside it: they give the worst and the best that plan does across every workload it serves, so you can see whether its headline is a floor or a best case before committing to it." + - q: "Why not just compare the advertised numbers?" + a: "Because they are in four different units. 30M Compute Units, 3M Request Units, 3M daily credits and 1M requests are four numbers that cannot be ordered until each is divided by what one request of your workload costs in that unit. That division is what this page does." + - q: "Is the biggest allowance the best free plan?" + a: "Not on its own. Throughput ceilings, archive access and reliability are not scored here, and a plan with a large allowance behind a low rate limit will not serve a busy frontend. This page answers one question, how far the allowance goes, and the per-chain RPC pages answer how fast those endpoints respond." + +dimension_labels: + kind: Workload + +dimensions: + # One axis, not two. A workload already belongs to one chain, so a chain + # axis beside this one offered cells no profile fills: picking Solana with + # the default workload asked for an Ethereum mix on Solana and the table + # came back empty. The chain is named in each label instead. + # + # `all` is the headline slice, not a pooled average: an allowance averaged + # across a trace mix and a simple-read mix describes no workload anyone runs. + # + # Four tabs, for five measured mixes. The simple-read mix is still priced + # and still feeds the floor and ceiling columns, but it had no tab of its + # own to earn: it draws the same 10 plans as the dapp tab in the same + # order bar one mid-table swap, because it is the optimistic edge of that + # same mix rather than a different thing to build. The ceiling column + # reports the number a tab would have shown, on every row at once. What + # the remaining four ask is what you are building, which is the only + # version of this question a reader can answer about themselves. + kind: + - { value: all, label: "Dapp frontend, Ethereum" } + - { value: indexer, label: "Log indexer, Ethereum" } + - { value: trace, label: "Trace and debug, Ethereum" } + - { value: solana-bot, label: "Trading bot, Solana" } + +# Three columns for one measurement: the selected workload, and the two ends +# of the span across every workload this plan serves on that chain. +# +# The headline alone was read as a promise on plans where it is a best case. +# BlockPI publishes one free tier that buys 3.3M simple reads and 392k +# traces, a factor of eight, and Infura and Blockdaemon swing five; a reader +# who landed on one tab had no way to learn that without clicking the others +# and holding the numbers in their head. Ankr and dRPC meter flat and +# report floor equal to ceiling, so the +# two columns also say outright which plans the workload cannot surprise, +# which is the fact that decides whether an allowance can be budgeted. +ledger_columns: + - { label: Free allowance, slot: p50, unit: count } + - { label: Least generous workload, slot: p90, unit: count } + - { label: Most generous workload, slot: p99, unit: count } + +source: https://github.com/ChainBench/OpenChainBench/tree/dev/harnesses/rpc-cost + +prometheus: + # 24h is the chart's window, not the headline's. The scalar queries look + # back 3h, matching expected_freshness_seconds below and the harness's + # hourly cycle, because a range selector keeps a retired series alive for + # its whole length: the chain alias was removed on 2026-10-06 and the old + # `chain="all"` series went on answering for 21 more hours, nulling the + # headline column, while dRPC's Solana allowance kept being published by a + # query whose fix had already shipped. A lookback longer than the freshness + # the page promises is a lookback that outlives its own corrections. + window: 24h + expected_freshness_seconds: 10800 + freshness_timestamp_metric: rpc_cost_last_run_timestamp_seconds + url: https://prom.openchainbench.com + +# Every query aggregates, and that is not decoration. The metric carries a +# `chain` label this bench does not axis on, because a workload already +# belongs to one chain, and a provider publishes exactly one chain per +# workload. So the aggregation is a no-op on correct data. What it buys is +# survival across a harness label change: when the chain alias was removed on +# 2026-10-06 the retired `chain="all"` series stayed inside the 24h range +# selector for another 21 hours, every quantile_over_time returned two series +# where the loader wanted one, and the headline column went null on all 14 +# rows while the floor and ceiling columns beside it read fine. A page whose +# numbers vanish for a day after a correct fix to the harness is reporting on +# its own deploy history. +providers: + - slug: onfinality + name: OnFinality + tag: "Developer, 400k response units a day, 40/s, no trace" + queries: + p50: max(quantile_over_time(0.50, rpc_free_allowance_requests{provider="onfinality",kind="all"}[3h])) + p90: max(quantile_over_time(0.50, rpc_free_allowance_requests_floor{provider="onfinality",kind="all"}[3h])) + p99: max(quantile_over_time(0.50, rpc_free_allowance_requests_ceiling{provider="onfinality",kind="all"}[3h])) + series: max by (provider) (rpc_free_allowance_requests{provider="onfinality",kind="all"}) + + - slug: drpc + name: dRPC + tag: "Free, 210M Compute Units a month" + queries: + p50: max(quantile_over_time(0.50, rpc_free_allowance_requests{provider="drpc",kind="all"}[3h])) + p90: max(quantile_over_time(0.50, rpc_free_allowance_requests_floor{provider="drpc",kind="all"}[3h])) + p99: max(quantile_over_time(0.50, rpc_free_allowance_requests_ceiling{provider="drpc",kind="all"}[3h])) + series: max by (provider) (rpc_free_allowance_requests{provider="drpc",kind="all"}) + + - slug: chainstack + name: Chainstack + tag: "Developer, 3M Request Units a month, one flat RU per method" + queries: + p50: max(quantile_over_time(0.50, rpc_free_allowance_requests{provider="chainstack",kind="all"}[3h])) + p90: max(quantile_over_time(0.50, rpc_free_allowance_requests_floor{provider="chainstack",kind="all"}[3h])) + p99: max(quantile_over_time(0.50, rpc_free_allowance_requests_ceiling{provider="chainstack",kind="all"}[3h])) + series: max by (provider) (rpc_free_allowance_requests{provider="chainstack",kind="all"}) + + - slug: blockpi + name: BlockPI + tag: "Free, 50M RU per 32-day package, 400 RU/s, weighted per method" + queries: + p50: max(quantile_over_time(0.50, rpc_free_allowance_requests{provider="blockpi",kind="all"}[3h])) + p90: max(quantile_over_time(0.50, rpc_free_allowance_requests_floor{provider="blockpi",kind="all"}[3h])) + p99: max(quantile_over_time(0.50, rpc_free_allowance_requests_ceiling{provider="blockpi",kind="all"}[3h])) + series: max by (provider) (rpc_free_allowance_requests{provider="blockpi",kind="all"}) + + - slug: alchemy + name: Alchemy + tag: "Free, 30M Compute Units a month, hard stop" + queries: + p50: max(quantile_over_time(0.50, rpc_free_allowance_requests{provider="alchemy",kind="all"}[3h])) + p90: max(quantile_over_time(0.50, rpc_free_allowance_requests_floor{provider="alchemy",kind="all"}[3h])) + p99: max(quantile_over_time(0.50, rpc_free_allowance_requests_ceiling{provider="alchemy",kind="all"}[3h])) + series: max by (provider) (rpc_free_allowance_requests{provider="alchemy",kind="all"}) + + - slug: validation-cloud + name: Validation Cloud + tag: "Free, weighted per method" + queries: + p50: max(quantile_over_time(0.50, rpc_free_allowance_requests{provider="validation-cloud",kind="all"}[3h])) + p90: max(quantile_over_time(0.50, rpc_free_allowance_requests_floor{provider="validation-cloud",kind="all"}[3h])) + p99: max(quantile_over_time(0.50, rpc_free_allowance_requests_ceiling{provider="validation-cloud",kind="all"}[3h])) + series: max by (provider) (rpc_free_allowance_requests{provider="validation-cloud",kind="all"}) + + - slug: ankr + name: Ankr + tag: "Freemium, flat request count" + queries: + p50: max(quantile_over_time(0.50, rpc_free_allowance_requests{provider="ankr",kind="all"}[3h])) + p90: max(quantile_over_time(0.50, rpc_free_allowance_requests_floor{provider="ankr",kind="all"}[3h])) + p99: max(quantile_over_time(0.50, rpc_free_allowance_requests_ceiling{provider="ankr",kind="all"}[3h])) + series: max by (provider) (rpc_free_allowance_requests{provider="ankr",kind="all"}) + + - slug: infura + name: Infura + tag: "Core, 3M credits a day, hard stop" + queries: + p50: max(quantile_over_time(0.50, rpc_free_allowance_requests{provider="infura",kind="all"}[3h])) + p90: max(quantile_over_time(0.50, rpc_free_allowance_requests_floor{provider="infura",kind="all"}[3h])) + p99: max(quantile_over_time(0.50, rpc_free_allowance_requests_ceiling{provider="infura",kind="all"}[3h])) + series: max by (provider) (rpc_free_allowance_requests{provider="infura",kind="all"}) + + - slug: blockdaemon + name: Blockdaemon + tag: "Free, weighted per method" + queries: + p50: max(quantile_over_time(0.50, rpc_free_allowance_requests{provider="blockdaemon",kind="all"}[3h])) + p90: max(quantile_over_time(0.50, rpc_free_allowance_requests_floor{provider="blockdaemon",kind="all"}[3h])) + p99: max(quantile_over_time(0.50, rpc_free_allowance_requests_ceiling{provider="blockdaemon",kind="all"}[3h])) + series: max by (provider) (rpc_free_allowance_requests{provider="blockdaemon",kind="all"}) + + - slug: getblock + name: GetBlock + tag: "Free, 50k Compute Units a day" + queries: + p50: max(quantile_over_time(0.50, rpc_free_allowance_requests{provider="getblock",kind="all"}[3h])) + p90: max(quantile_over_time(0.50, rpc_free_allowance_requests_floor{provider="getblock",kind="all"}[3h])) + p99: max(quantile_over_time(0.50, rpc_free_allowance_requests_ceiling{provider="getblock",kind="all"}[3h])) + series: max by (provider) (rpc_free_allowance_requests{provider="getblock",kind="all"}) + + - slug: helius + name: Helius + tag: "Free, Solana only" + queries: + p50: max(quantile_over_time(0.50, rpc_free_allowance_requests{provider="helius",kind="all"}[3h])) + p90: max(quantile_over_time(0.50, rpc_free_allowance_requests_floor{provider="helius",kind="all"}[3h])) + p99: max(quantile_over_time(0.50, rpc_free_allowance_requests_ceiling{provider="helius",kind="all"}[3h])) + series: max by (provider) (rpc_free_allowance_requests{provider="helius",kind="all"}) + + - slug: syndica + name: Syndica + tag: "Free, Solana only" + queries: + p50: max(quantile_over_time(0.50, rpc_free_allowance_requests{provider="syndica",kind="all"}[3h])) + p90: max(quantile_over_time(0.50, rpc_free_allowance_requests_floor{provider="syndica",kind="all"}[3h])) + p99: max(quantile_over_time(0.50, rpc_free_allowance_requests_ceiling{provider="syndica",kind="all"}[3h])) + series: max by (provider) (rpc_free_allowance_requests{provider="syndica",kind="all"}) + diff --git a/benchmarks/solana-rpc.yml b/benchmarks/solana-rpc.yml index 7701599bd..0e686edcd 100644 --- a/benchmarks/solana-rpc.yml +++ b/benchmarks/solana-rpc.yml @@ -200,6 +200,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="solana",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="alchemy",chain="solana",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="alchemy",chain="solana",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="alchemy",chain="solana",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="alchemy",chain="solana",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="solana",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="solana",tier="keyed"}) @@ -224,6 +225,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="solana",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="chainstack",chain="solana",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="chainstack",chain="solana",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="chainstack",chain="solana",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="chainstack",chain="solana",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="solana",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="solana",tier="keyed"}) @@ -248,6 +250,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="getblock",chain="solana",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="getblock",chain="solana",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="getblock",chain="solana",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="getblock",chain="solana",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="getblock",chain="solana",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="solana",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="solana",tier="keyed"}) @@ -272,6 +275,7 @@ providers: queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="solana",tier="keyed"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="quicknode",chain="solana",tier="keyed"}) + p95: avg(ocb:rpc_latency_milliseconds:p95_24h{provider="quicknode",chain="solana",tier="keyed"}) p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="quicknode",chain="solana",tier="keyed"}) mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="quicknode",chain="solana",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="solana",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="solana",tier="keyed"}) @@ -296,6 +300,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="alchemy",chain="solana",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="alchemy",chain="solana",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="alchemy",chain="solana",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="alchemy",chain="solana",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="alchemy",chain="solana",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="solana",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="solana",tier="keyed"}) @@ -319,6 +324,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="chainstack",chain="solana",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="chainstack",chain="solana",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="chainstack",chain="solana",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="chainstack",chain="solana",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="chainstack",chain="solana",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="solana",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="solana",tier="keyed"}) @@ -342,6 +348,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="getblock",chain="solana",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="getblock",chain="solana",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="getblock",chain="solana",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="getblock",chain="solana",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="getblock",chain="solana",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="getblock",chain="solana",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="getblock",chain="solana",tier="keyed"}) @@ -365,6 +372,7 @@ providers: queries: p50: avg(ocb:rpc_latency_cold_milliseconds:p50_24h{provider="quicknode",chain="solana",tier="keyed"}) p90: avg(ocb:rpc_latency_cold_milliseconds:p90_24h{provider="quicknode",chain="solana",tier="keyed"}) + p95: avg(ocb:rpc_latency_cold_milliseconds:p95_24h{provider="quicknode",chain="solana",tier="keyed"}) p99: avg(ocb:rpc_latency_cold_milliseconds:p99_24h{provider="quicknode",chain="solana",tier="keyed"}) mean: avg(ocb:rpc_latency_cold_milliseconds:mean_24h{provider="quicknode",chain="solana",tier="keyed"}) success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="solana",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="solana",tier="keyed"}) diff --git a/benchmarks/terminal-execution-quality.yml b/benchmarks/terminal-execution-quality.yml index bf99e440e..2a6633517 100644 --- a/benchmarks/terminal-execution-quality.yml +++ b/benchmarks/terminal-execution-quality.yml @@ -305,10 +305,13 @@ providers: series: tfq_loss_ex_fee_bps{terminal="banana-gun",chain="all",stat="median",bucket="all"} # Product slug is "binance" (one product page for the exchange's RPC and - # its wallet); the harness label stays terminal="binance". + # its wallet); the harness label stays terminal="binance". The display + # name must not: the row measures the self-custody wallet's swap router, + # and only the page shows the tag beside it. The headline, the citable + # API and the share card carry the name alone. - slug: binance - name: Binance - tag: "Binance Wallet swap, BNB + Ethereum + Base" + name: Binance Wallet + tag: "Swap router on BNB, Ethereum and Base" formula: "Transactions sent to Binance Wallet's swap router (one address on BNB, Ethereum and Base) in a sample of blocks read in full, valued at the pool's state before the swap; loss in bps (gas inside), median over 24 h." queries: p50: tfq_loss_ex_fee_bps{terminal="binance",chain="all",stat="median",bucket="all"} diff --git a/benchmarks/terminal-fill-quality.yml b/benchmarks/terminal-fill-quality.yml index 8a0e8eb97..763bd50b8 100644 --- a/benchmarks/terminal-fill-quality.yml +++ b/benchmarks/terminal-fill-quality.yml @@ -360,10 +360,15 @@ providers: series: tfq_loss_bps{terminal="banana-gun",chain="all",stat="median",bucket="all"} # Product slug is "binance" (one product page for the exchange's RPC and - # its wallet); the harness label stays terminal="binance". + # its wallet); the harness label stays terminal="binance". The display + # name must not: the row measures the self-custody wallet's swap router, + # and only the page shows the tag beside it. The headline, the citable + # API and the share card carry the name alone, so "Binance posts the + # lowest cost per swap" read as the exchange on every surface a reader + # meets away from the table. - slug: binance - name: Binance - tag: "Binance Wallet swap, BNB + Ethereum + Base" + name: Binance Wallet + tag: "Swap router on BNB, Ethereum and Base" formula: "Transactions sent to Binance Wallet's swap router (one address on BNB, Ethereum and Base) in a sample of blocks read in full, valued at the pool's state before the swap; loss in bps (gas inside), median over 24 h." queries: p50: tfq_loss_bps{terminal="binance",chain="all",stat="median",bucket="all"} diff --git a/benchmarks/token-quote-coverage.yml b/benchmarks/token-quote-coverage.yml index a15a60705..fe10c8450 100644 --- a/benchmarks/token-quote-coverage.yml +++ b/benchmarks/token-quote-coverage.yml @@ -73,6 +73,7 @@ abstract: | methodology: - "Token discovery: five sources in parallel. (1) Dexscreener token-boosts: pump-fun (Solana) and pons (Robinhood). (2) Virtuals Protocol API: Virtuals agents on Base and Robinhood chain (virtuals). (3) GeckoTerminal BSC new_pools: four-meme. (4) GeckoTerminal Solana moonshot/pools: moonshot. (5) GeckoTerminal Solana meteora-dbc/pools: meteora-dbc. Boosted tokens from (1) are enriched via Dexscreener batch lookup to resolve dexId to venue labels." + - "Cohort composition: four of the five are DEX aggregators (Jupiter on Solana, KyberSwap and ParaSwap on EVM, Mobula on both). Relay is a cross-chain relayer, scored here because it answers the same probe, not because it is an aggregator; the rest of the site types it the same way on bridge-fee and bridge-realized-cost. Read the type beside each name before the rank." - "Quote probe: 1 USDC → token, fired in parallel to all in-scope providers with a 10s timeout. Input token is always USDC (native USDC on Solana and Base, bridged USDC.e on BNB). A probe is a coverage hit when the HTTP response is 2xx and the parsed outAmount field is a non-zero positive integer or decimal. All other outcomes (4xx, 5xx, no-route JSON, empty outAmount, timeout) record as misses." - "Aggregator endpoints: Jupiter lite-api.jup.ag/swap/v1/quote (Solana), Mobula /api/2/swap/quoting (EVM + Solana, API key), Relay api.relay.link/quote (EVM + Solana, no key), KyberSwap aggregator-api.kyberswap.com/{chain}/api/v1/routes (EVM only, no key), ParaSwap apiv5.paraswap.io/prices (EVM only, no key). Each adapter normalises chain identifiers, input/output decimals, and error response shapes before recording outcome." - "Jupiter strictMode=false: queried without the curated-token filter so Jupiter will attempt routing for any token with a discoverable on-chain pool, matching how most wallets call the API. strictMode=true would trivially exclude new tokens and defeat the purpose of the benchmark." @@ -140,6 +141,7 @@ rank_matrix_query: > providers: - slug: mobula name: Mobula + type: aggregator tag: Multi-chain, Solana + Base + BNB + Robinhood, API key required formula: "Rolling 24h ratio of token_quote_coverage_success_total to token_quote_coverage_attempts_total for provider=mobula, across all launchpads and chains Mobula supports." queries: @@ -155,6 +157,7 @@ providers: - slug: jupiter name: Jupiter + type: aggregator tag: Solana only, strictMode=false formula: "Rolling 24h ratio of hits to attempts for provider=jupiter. Solana launchpads only (Pump.fun, Moonshot, Meteora DBC). EVM launchpad cells are null by construction." queries: @@ -170,6 +173,7 @@ providers: - slug: relay name: Relay + type: relay tag: Multi-chain, Solana + Base + BNB + Robinhood, no key formula: "Rolling 24h ratio for provider=relay. Covers all 4 chains including Solana (via dFlow router). No API key required." queries: @@ -185,6 +189,7 @@ providers: - slug: kyberswap name: KyberSwap + type: aggregator tag: EVM aggregator, Base + BNB + Robinhood, no key formula: "Rolling 24h ratio for provider=kyberswap. EVM-only: Base, BNB Chain, Robinhood. No Solana support (structural, not a coverage gap). No API key required." queries: @@ -200,6 +205,7 @@ providers: - slug: paraswap name: ParaSwap + type: aggregator tag: EVM aggregator, Base + BNB + Robinhood, no key formula: "Rolling 24h ratio for provider=paraswap. EVM-only: Base, BNB Chain, Robinhood. No Solana support (structural). No API key required." queries: diff --git a/benchmarks/trading-platform-wars.yml b/benchmarks/trading-platform-wars.yml index 7b17798b6..8a30bf693 100644 --- a/benchmarks/trading-platform-wars.yml +++ b/benchmarks/trading-platform-wars.yml @@ -45,6 +45,7 @@ methodology: - "Fee revenue: what this source reports as fees. It is demonstrably more than the terminal's own commission, measured against published fee schedules: Trojan advertises 0.9% and this source reads 1.12%. The exact composition is not documented by the source." - "Windows are sums over days, never averages of daily rates. Volume, fees and transactions add over days the same way they add over chains, and the take rate built on them is volume-weighted by construction: total fees over the volume of the days whose fees could be measured." - "A day a platform reports exactly zero fees while charging on another day or chain is treated as a gap, not a price, and leaves the ratio entirely, numerator and denominator together. A platform reporting nothing anywhere is uniformly free and keeps a real zero." + - "Two gates sit between a reported fee and a published take rate. A day whose fee exceeds 10% of its own volume leaves the ratio: every measured rate here falls between 0.39% and 2.2%, so a day far above that is a fee attributed to the wrong denominator, not a terminal keeping a tenth of the ticket. The all-chains rate also needs half the platform's volume on measurable days, because a rate read off a sliver is not the rate of the whole. Volume and transactions survive it." - "Single source by decision, and this is what it costs: nothing here is cross-checked against a second indexer. On 2026-10-07 this source read pump.fun 2.2x above DeFiLlama on app volume and 3.7x above both DeFiLlama and Mobula on launchpad volume, while agreeing with DeFiLlama within 30% on six other platforms. The figures published are this source's." - "Latest complete day: the source publishes daily and its latest day is normally yesterday. A terminal with no activity on the selected chain reads unresponsive rather than carrying its last live day forward." diff --git a/benchmarks/xrplevm-rpc.yml b/benchmarks/xrplevm-rpc.yml index 1568d3cd4..48f7d99c9 100644 --- a/benchmarks/xrplevm-rpc.yml +++ b/benchmarks/xrplevm-rpc.yml @@ -122,3 +122,27 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="polkachu", chain="xrplevm", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="polkachu", chain="xrplevm", region="sgp"}[1h]) + + - slug: itrocket + name: ITRocket + endpoint: https://xrplevm-mainnet-rpc.itrocket.net + tag: ITRocket validator public Tendermint RPC, listed on XRPL EVM's Public APIs page + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single Tendermint `status` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to ITRocket's no-key XRPL EVM RPC endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="itrocket", chain="xrplevm"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="itrocket", chain="xrplevm"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="itrocket", chain="xrplevm"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="itrocket", chain="xrplevm"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="itrocket", chain="xrplevm"}) / sum(ocb:rpc_call:rate_24h{provider="itrocket", chain="xrplevm"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="itrocket", chain="xrplevm"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="itrocket", chain="xrplevm"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="itrocket", chain="xrplevm", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="itrocket", chain="xrplevm", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="itrocket", chain="xrplevm", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="itrocket", chain="xrplevm", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="itrocket", chain="xrplevm", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="itrocket", chain="xrplevm", region="sgp"}[1h]) diff --git a/crm/.env.example b/crm/.env.example index a855f73fb..ec5f02cc8 100644 --- a/crm/.env.example +++ b/crm/.env.example @@ -16,3 +16,14 @@ REFRESH_MINUTES=15 SNAPSHOT_DIR=/data # Optional: Dune API key, to show credits left on the plan. DUNE_API_KEY= +# Vercel Observability, for the bot and agent section: every edge request with +# Vercel's own bot classification, which is the only source that sees a crawler +# reading an ISR-cached page. A token scoped to the OpenChainBench project is +# enough; the team and project ids are the ones in .vercel/project.json. +VERCEL_API_TOKEN= +VERCEL_TEAM_ID= +VERCEL_PROJECT_ID= +# Days of bot history to ask for, and the shorter window for the per-path +# query (which times out over the full one). +AGENT_WINDOW_DAYS= +AGENT_PATHS_WINDOW_DAYS= diff --git a/crm/README.md b/crm/README.md index ca08c36d9..1d894beeb 100644 --- a/crm/README.md +++ b/crm/README.md @@ -68,8 +68,15 @@ railway logs ``` Variables (Railway service settings): `CRM_PASSWORD`, `CRM_SESSION_SECRET`, `POSTHOG_PERSONAL_API_KEY`, -`POSTHOG_PROJECT_ID`, optionally `DUNE_API_KEY`, `POSTHOG_HOURLY_BUDGET`, -`REFRESH_MINUTES`. `SNAPSHOT_DIR=/data` and `PORT` are set on the service. +`POSTHOG_PROJECT_ID`, `VERCEL_API_TOKEN`, `VERCEL_TEAM_ID`, `VERCEL_PROJECT_ID`, optionally +`DUNE_API_KEY`, `POSTHOG_HOURLY_BUDGET`, `REFRESH_MINUTES`. `SNAPSHOT_DIR=/data` and `PORT` +are set on the service. + +The three `VERCEL_*` variables feed the bot and agent section, which reads Vercel +Observability rather than PostHog. PostHog cannot answer that question: its SDK is +client-side JavaScript and the server capture covers three routes, two of them cached, +so a crawler reading an ISR-cached page is invisible to it. Without the variables the +section says so and every other section is unaffected. See `lib/vercel-obs.ts`. ## Sessions diff --git a/crm/app/audience/page.tsx b/crm/app/audience/page.tsx index b6ec7ef12..98a25ee2d 100644 --- a/crm/app/audience/page.tsx +++ b/crm/app/audience/page.tsx @@ -69,6 +69,9 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro const [snap, sp] = await Promise.all([readSnapshot(), searchParams]); const w = parseWindow(sp.w); const t = trafficFor(snap, w.key); + // Bot traffic is not windowed: it comes from Vercel, not from the PostHog + // sections the ?w= switch re-queries, and carries its own window. + const ag = snap.agents; const a = t.audience; const active = a?.activeVisitors ?? 0; const firstDay = t.daily?.[0]?.day ?? null; @@ -190,6 +193,79 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro

+ {/* Bots and agents, measured by Vercel. Everything else on this page comes + from PostHog, which sees only what runs our JavaScript or reaches an + uncached route: an ISR-cached HTML page read by a crawler appears + nowhere in it. See lib/vercel-obs.ts. */} +
+

Bots and agents, {ag?.windowDays ?? 30} d (every edge request, from Vercel)

+ {ag && ag.totalRequests > 0 ? ( + <> +
+ + 0 ? `${fmtPct(ag.aiRequests / ag.totalRequests)} of all requests` : undefined} /> + + c.category.startsWith("search_engine")).reduce((n, c) => n + c.requests, 0))} + sub="googlebot, bingbot, and the rest" + /> +
+
+
+

By bot

+ + + + + + + + + + + {ag.bots.slice(0, 25).map((b) => ( + + + + + + + ))} + +
BotCategoryRequestsShare of bot traffic
{b.bot}{b.category || "unclassified"}{fmtInt(b.requests)} + {b.sharePct.toFixed(1)}% +
+
+
+

What AI crawlers read, {ag.aiPathsWindowDays} d

+ {ag.aiPaths.length > 0 ? ( + ({ label: r.path, value: r.requests }))} /> + ) : ( + + )} +

+ A crawler that reads the same handful of pages hundreds of times is looping, not indexing. Compare the top + paths with the bot table: if one bot carries most of the volume, the shape here is that bot's alone. +

+
+
+ + ) : ( + + )} +

+ Source: Vercel Observability, every edge request on production including cache hits, with Vercel's own bot + classification. This is the only section on the page that sees traffic which never runs our JavaScript, which is + all crawler traffic on ISR-cached pages. Retention starts when Observability was enabled on the team + {ag?.firstDayWithData ? ` (first day with data: ${ag.firstDayWithData})` : ""}; days before that read zero. +

+
+

{`Endpoints read by agents, ${w.label}`}

@@ -236,6 +312,7 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro {families.length > 0 ? : }

Family is a coarse user-agent bucket (gptbot, claudebot, perplexitybot, googlebot, curl, python, browser). Distinct agents are counted per user agent per day, never per IP. + These two panels count reads of the three server surfaces only, and /api/stat and /api/citable behind their edge caches count cache fills. For who actually reads the site, use the Vercel section above: on 2026-09-30 these panels attributed most "agent" traffic to our own curl probes and Blackbox exporter, while Vercel counted 75,429 AI-crawler requests over the same three days.

diff --git a/crm/lib/snapshot.ts b/crm/lib/snapshot.ts index f6f469aab..83a8aac88 100644 --- a/crm/lib/snapshot.ts +++ b/crm/lib/snapshot.ts @@ -14,6 +14,7 @@ import path from "node:path"; import { budget, BudgetExhausted, HOURLY_BUDGET, posthogConfigured, RateLimited } from "@/lib/posthog"; import { loadBenchHealth, loadDuneUsage, loadHarnessHealth, type BenchHealth, type DuneUsage, type HarnessHealth } from "@/lib/ocb"; import { isWindowed, loadTrafficSection, TRAFFIC_SECTIONS, type Traffic } from "@/lib/traffic"; +import { loadAgentTraffic, vercelObsConfigured, type AgentTraffic } from "@/lib/vercel-obs"; import { WINDOWS, type ReportWindow, type WindowKey } from "@/lib/window"; // 15 min by default: 48 queries per pass (28 sections over 7 d plus the 20 @@ -43,6 +44,8 @@ export type Snapshot = { benches: BenchHealth | null; harness: HarnessHealth | null; dune: DuneUsage | null; + /** Bot and agent traffic, from Vercel rather than PostHog; see lib/vercel-obs.ts. */ + agents: AgentTraffic | null; status: Record; budget: { used: number; limit: number }; }; @@ -58,7 +61,7 @@ export type HistoryLine = { targetsDown: number; }; -const EMPTY: Snapshot = { v: 1, refreshedAt: null, posthogConfigured: posthogConfigured(), traffic: {}, traffic24h: {}, benches: null, harness: null, dune: null, status: {}, budget: { used: 0, limit: HOURLY_BUDGET } }; +const EMPTY: Snapshot = { v: 1, refreshedAt: null, posthogConfigured: posthogConfigured(), traffic: {}, traffic24h: {}, benches: null, harness: null, dune: null, agents: null, status: {}, budget: { used: 0, limit: HOURLY_BUDGET } }; function clampInt(raw: string | undefined, fallback: number, min: number, max: number): number { const n = Number.parseInt(raw ?? "", 10); @@ -186,6 +189,16 @@ async function doRefresh(reason: string): Promise { await step("dune", async () => { next.dune = await loadDuneUsage(); }); + // Vercel, not PostHog: every other section here is blind to anything that + // does not run our JavaScript or reach an uncached route of ours. + if (vercelObsConfigured()) { + delete next.status.agents; + await step("agents", async () => { + next.agents = await loadAgentTraffic(); + }); + } else { + next.status.agents = { at: null, error: "VERCEL_API_TOKEN, VERCEL_TEAM_ID or VERCEL_PROJECT_ID not set" }; + } if (posthogConfigured()) { // The "not configured" note from earlier refreshes must not outlive the fix. delete next.status.posthog; @@ -224,6 +237,7 @@ async function doRefresh(reason: string): Promise { "harness", "dune", "posthog", + "agents", ...TRAFFIC_SECTIONS.map((s) => statusKey(s, WINDOWS["7d"])), ...TRAFFIC_SECTIONS.filter(isWindowed).map((s) => statusKey(s, WINDOWS["24h"])), ]); diff --git a/crm/lib/vercel-obs.ts b/crm/lib/vercel-obs.ts new file mode 100644 index 000000000..608808088 --- /dev/null +++ b/crm/lib/vercel-obs.ts @@ -0,0 +1,274 @@ +/** + * Agent traffic, read from Vercel Observability rather than PostHog. + * + * PostHog cannot answer "who reads the site" and never will. Its client SDK + * is JavaScript, so nothing that skips JS is ever in it, and the server-side + * capture in src/lib/analytics-server.ts is wired to three routes only + * (/api/md, /api/stat, /api/citable), two of which are cached (60 s and one + * hour) and therefore emit one event per cache fill rather than per read. + * Every HTML page is ISR-cached, so a crawler fetching /benchmarks/ + * runs no code of ours and appears nowhere. + * + * Measured on 2026-09-30: PostHog showed 19 reads from identified AI agents + * over its whole history and attributed 65 % of "agent" traffic to our own + * curl probes and Blackbox exporter. Vercel, over the same three days, counted + * 75,429 requests from AI crawlers alone. The two are not close because they + * are not measuring the same thing. + * + * So this file does not duplicate the collection. Vercel already records every + * edge request with a classified bot, cache hits included; we query it. + * + * The API is `/v2/observability/query`, the one `vercel metrics` uses. It is + * not in Vercel's public REST documentation, so the pieces worth keeping: + * - the filter dialect is OData: `environment eq 'production'`, single + * quotes, not `environment:production` (that is the CLI's own surface + * syntax, rejected by the API); + * - `orderBy` must name a rollup column, `vercel_request_count_sum`, not + * the aggregation; + * - grouped dimensions come back snake_cased (`bot_name`, `bot_category`) + * even though `groupBy` takes them camelCased; + * - `vercel metrics schema vercel.request.count` lists every dimension. + * + * Retention starts when the Observability entitlement was enabled on the team + * (2026-09-28 here), so a 30-day window returns empty days before that. + */ +import { z } from "zod"; + +const API = "https://api.vercel.com/v2/observability/query"; +const TIMEOUT_MS = 45_000; + +const TOKEN = process.env.VERCEL_API_TOKEN ?? ""; +const TEAM = process.env.VERCEL_TEAM_ID ?? ""; +const PROJECT = process.env.VERCEL_PROJECT_ID ?? ""; + +/** Days of history each refresh asks for. Vercel bills nothing per query. */ +const WINDOW_DAYS = Number.parseInt(process.env.AGENT_WINDOW_DAYS ?? "", 10) || 30; + +/** + * The per-path query is the expensive one and 408s over the full window + * (`query_timeout` from Vercel, reproduced on 2026-09-30 at 31 days). Seven + * days serves in a few seconds and is the right question anyway: what the + * crawlers are reading now, not what they read a month ago. + */ +const PATHS_WINDOW_DAYS = Number.parseInt(process.env.AGENT_PATHS_WINDOW_DAYS ?? "", 10) || 7; + +export function vercelObsConfigured(): boolean { + return TOKEN.length > 0 && TEAM.length > 0 && PROJECT.length > 0; +} + +/** + * Vercel's own bot taxonomy, as returned in `botCategory`. Grouped here into + * the three questions the dashboard actually asks, so a new category Vercel + * adds shows up as itself rather than being silently folded into "other". + */ +const AI_CATEGORIES = new Set(["ai_crawler", "ai_assistant"]); +const SEARCH_CATEGORIES = new Set(["search_engine_crawler", "search_engine_optimization"]); + +export type AgentBotRow = { + bot: string; + category: string; + requests: number; + /** Share of all classified bot requests in the window. */ + sharePct: number; +}; +export type AgentCategoryRow = { category: string; requests: number }; +export type AgentDay = { day: string; ai: number; search: number; unclassified: number; other: number }; +export type AgentPathRow = { path: string; requests: number }; + +export type AgentTraffic = { + /** Window asked for, and the first day that actually carried data. */ + windowDays: number; + firstDayWithData: string | null; + /** Every request Vercel logged on production in the window, bots included. */ + totalRequests: number; + /** Requests whose bot category is an AI one. */ + aiRequests: number; + /** Requests from AI bots excluding the single largest one, which on this + * site is a crawler in a loop and swamps every other signal. */ + aiRequestsExcludingTop: number; + topAiBot: string | null; + categories: AgentCategoryRow[]; + bots: AgentBotRow[]; + daily: AgentDay[]; + /** What the AI crawlers actually read, over a shorter window than the rest. */ + aiPathsWindowDays: number; + aiPaths: AgentPathRow[]; +}; + +const rowSchema = z.object({ + timestamp: z.string().optional(), + vercel_request_count_sum: z.number(), + bot_name: z.string().optional(), + bot_category: z.string().optional(), + request_path: z.string().optional(), +}); +const responseSchema = z.object({ data: z.array(rowSchema) }); + +type Row = z.infer; + +function isoDay(offsetDays: number): string { + const d = new Date(); + d.setUTCHours(0, 0, 0, 0); + d.setUTCDate(d.getUTCDate() + offsetDays); + return d.toISOString(); +} + +/** + * One grouped query. `filter` is OData; see the header note. + * + * Granularity is always one day: the API rejects anything but 5m, 15m, 1h and + * 1d (`Unsupported duration {"days":31}`), so a whole-window total cannot be + * asked for and is summed from the daily buckets here instead. `limit` is + * per bucket, not per result set, which is why it is generous. + */ +async function query(opts: { + groupBy: string[]; + days: number; + filter?: string; + limit?: number; +}): Promise { + const body = { + scope: { type: "project", ownerId: TEAM, projectIds: [PROJECT] }, + metric: "vercel.request.count", + selection: { aggregation: "count" }, + startTime: isoDay(-opts.days), + endTime: isoDay(1), + granularity: { days: 1 }, + groupBy: opts.groupBy, + filter: opts.filter ?? "environment eq 'production'", + limit: opts.limit ?? 40, + orderBy: "vercel_request_count_sum", + orderDirection: "desc", + }; + const res = await fetch(API + `?teamId=${encodeURIComponent(TEAM)}`, { + method: "POST", + headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!res.ok) { + const detail = (await res.text()).slice(0, 300); + throw new Error(`vercel observability ${res.status}: ${detail}`); + } + return responseSchema.parse(await res.json()).data; +} + +export async function loadAgentTraffic(): Promise { + if (!vercelObsConfigured()) { + throw new Error("VERCEL_API_TOKEN, VERCEL_TEAM_ID or VERCEL_PROJECT_ID not set"); + } + + const [daily, bots, aiPaths] = await Promise.all([ + query({ groupBy: ["botCategory"], days: WINDOW_DAYS, limit: 20 }), + query({ groupBy: ["botName", "botCategory"], days: WINDOW_DAYS, limit: 40 }), + query({ + groupBy: ["requestPath"], + days: PATHS_WINDOW_DAYS, + filter: + "environment eq 'production' and (botCategory eq 'ai_crawler' or botCategory eq 'ai_assistant')", + limit: 15, + }), + ]); + return shapeAgentTraffic({ daily, bots, aiPaths, windowDays: WINDOW_DAYS, pathsWindowDays: PATHS_WINDOW_DAYS }); +} + +/** + * The shaping, kept pure and exported so it can be tested without the network. + * + * Two decisions live here rather than in the view. A category the taxonomy + * does not know (Vercel ships a dozen: browser_impersonation, http_client, + * client_anomaly, ...) is carried through as itself instead of being folded + * into "other", so a new one is visible the day it appears. And the AI total + * is published twice, with and without the single largest AI bot, because one + * crawler in a loop can be 84 % of it and a single number would read as + * adoption. + */ +export function shapeAgentTraffic(input: { + daily: Row[]; + bots: Row[]; + aiPaths: Row[]; + windowDays: number; + pathsWindowDays: number; +}): AgentTraffic { + const { daily, bots, aiPaths, windowDays: WINDOW_DAYS, pathsWindowDays: PATHS_WINDOW_DAYS } = input; + + // ── daily series, folded into the three questions ── + const byDay = new Map(); + for (const r of daily) { + const day = (r.timestamp ?? "").slice(0, 10); + if (!day) continue; + const cat = r.bot_category ?? ""; + const d = byDay.get(day) ?? { day, ai: 0, search: 0, unclassified: 0, other: 0 }; + if (AI_CATEGORIES.has(cat)) d.ai += r.vercel_request_count_sum; + else if (SEARCH_CATEGORIES.has(cat)) d.search += r.vercel_request_count_sum; + else if (cat === "") d.unclassified += r.vercel_request_count_sum; + else d.other += r.vercel_request_count_sum; + byDay.set(day, d); + } + const series = [...byDay.values()].sort((a, b) => a.day.localeCompare(b.day)); + + // A day is "with data" once anything at all was logged: the window reaches + // back past the day Observability was switched on, and those days are zero. + const firstDayWithData = + series.find((d) => d.ai + d.search + d.unclassified + d.other > 0)?.day ?? null; + + const categoryTotals = new Map(); + for (const r of daily) { + const cat = r.bot_category || "(unclassified)"; + categoryTotals.set(cat, (categoryTotals.get(cat) ?? 0) + r.vercel_request_count_sum); + } + + // ── bots ── + const botTotals = new Map(); + for (const r of bots) { + const name = r.bot_name ?? ""; + if (!name) continue; // the unclassified bucket is not a bot + const cur = botTotals.get(name) ?? { category: r.bot_category ?? "", requests: 0 }; + cur.requests += r.vercel_request_count_sum; + if (!cur.category && r.bot_category) cur.category = r.bot_category; + botTotals.set(name, cur); + } + const classifiedTotal = [...botTotals.values()].reduce((s, b) => s + b.requests, 0); + const botRows: AgentBotRow[] = [...botTotals.entries()] + .map(([bot, v]) => ({ + bot, + category: v.category, + requests: v.requests, + sharePct: classifiedTotal > 0 ? (v.requests / classifiedTotal) * 100 : 0, + })) + .sort((a, b) => b.requests - a.requests); + + const aiBots = botRows.filter((b) => AI_CATEGORIES.has(b.category)); + const aiRequests = aiBots.reduce((s, b) => s + b.requests, 0); + const topAiBot = aiBots[0]?.bot ?? null; + const aiRequestsExcludingTop = aiRequests - (aiBots[0]?.requests ?? 0); + + return { + windowDays: WINDOW_DAYS, + firstDayWithData, + totalRequests: daily.reduce((s, r) => s + r.vercel_request_count_sum, 0), + aiRequests, + aiRequestsExcludingTop, + topAiBot, + categories: [...categoryTotals.entries()] + .map(([category, requests]) => ({ category, requests })) + .sort((a, b) => b.requests - a.requests), + bots: botRows, + daily: series, + aiPathsWindowDays: PATHS_WINDOW_DAYS, + aiPaths: sumByPath(aiPaths), + }; +} + +/** Daily buckets summed into one row per path; see the granularity note. */ +function sumByPath(rows: Row[]): AgentPathRow[] { + const totals = new Map(); + for (const r of rows) { + if (r.vercel_request_count_sum === 0) continue; + const path = r.request_path || "/"; + totals.set(path, (totals.get(path) ?? 0) + r.vercel_request_count_sum); + } + return [...totals.entries()] + .map(([path, requests]) => ({ path, requests })) + .sort((a, b) => b.requests - a.requests); +} diff --git a/crm/scripts/agents-probe.ts b/crm/scripts/agents-probe.ts new file mode 100644 index 000000000..71c4fe7da --- /dev/null +++ b/crm/scripts/agents-probe.ts @@ -0,0 +1,41 @@ +/** + * Run the Vercel agent-traffic loader once and print what it returns. + * + * The queries are undocumented (see lib/vercel-obs.ts), so this exists to + * prove the loader against the live API without a full refresh. + * + * VERCEL_API_TOKEN=... VERCEL_TEAM_ID=... VERCEL_PROJECT_ID=... \ + * pnpm exec tsx scripts/agents-probe.ts + */ +import { loadAgentTraffic, vercelObsConfigured } from "../lib/vercel-obs"; + +async function main() { + if (!vercelObsConfigured()) { + console.error("not configured: set VERCEL_API_TOKEN, VERCEL_TEAM_ID, VERCEL_PROJECT_ID"); + process.exit(2); + } + const t = await loadAgentTraffic(); + console.log( + `window ${t.windowDays} d, first day with data ${t.firstDayWithData ?? "none"}\n` + + `total ${t.totalRequests.toLocaleString()} ` + + `ai ${t.aiRequests.toLocaleString()} ` + + `ai without ${t.topAiBot ?? "-"} ${t.aiRequestsExcludingTop.toLocaleString()}\n`, + ); + console.log("categories:"); + for (const c of t.categories) console.log(` ${c.requests.toString().padStart(9)} ${c.category}`); + console.log("\ntop bots:"); + for (const b of t.bots.slice(0, 15)) { + console.log(` ${b.requests.toString().padStart(9)} ${b.sharePct.toFixed(1).padStart(5)}% ${b.bot} (${b.category || "unclassified"})`); + } + console.log(`\ntop AI paths, ${t.aiPathsWindowDays} d:`); + for (const p of t.aiPaths.slice(0, 10)) console.log(` ${p.requests.toString().padStart(9)} ${p.path}`); + console.log("\ndaily (non-zero):"); + for (const d of t.daily.filter((d) => d.ai + d.search + d.unclassified + d.other > 0)) { + console.log(` ${d.day} ai ${d.ai.toString().padStart(7)} search ${d.search.toString().padStart(6)} other ${d.other.toString().padStart(6)} unclassified ${d.unclassified.toString().padStart(7)}`); + } +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/crm/test/vercel-obs.test.ts b/crm/test/vercel-obs.test.ts new file mode 100644 index 000000000..1e4f19a69 --- /dev/null +++ b/crm/test/vercel-obs.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import { shapeAgentTraffic } from "../lib/vercel-obs"; + +const day = (d: string) => `${d}T00:00:00.000Z`; + +describe("agent traffic shaping", () => { + test("publishes the AI total both with and without the largest bot", () => { + // The reason this figure is published twice: on 2026-09-30 a single + // crawler, meta-externalagent, was 73,192 of 75,429 AI requests, and it + // was looping over fifteen static pages rather than indexing. One number + // would have read as adoption. + const t = shapeAgentTraffic({ + daily: [], + bots: [ + { timestamp: day("2026-09-29"), vercel_request_count_sum: 73192, bot_name: "meta-externalagent", bot_category: "ai_crawler" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 1432, bot_name: "oai-searchbot", bot_category: "ai_assistant" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 210, bot_name: "claudebot", bot_category: "ai_crawler" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 3335, bot_name: "googlebot", bot_category: "search_engine_crawler" }, + ], + aiPaths: [], + windowDays: 30, + pathsWindowDays: 7, + }); + expect(t.aiRequests).toBe(73192 + 1432 + 210); + expect(t.topAiBot).toBe("meta-externalagent"); + expect(t.aiRequestsExcludingTop).toBe(1432 + 210); + // Googlebot is not an AI bot and must not be in the AI total. + expect(t.bots.find((b) => b.bot === "googlebot")?.category).toBe("search_engine_crawler"); + }); + + test("a bot's daily rows are summed, and the share is of bot traffic only", () => { + const t = shapeAgentTraffic({ + daily: [], + bots: [ + { timestamp: day("2026-09-28"), vercel_request_count_sum: 100, bot_name: "claudebot", bot_category: "ai_crawler" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 300, bot_name: "claudebot", bot_category: "ai_crawler" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 100, bot_name: "googlebot", bot_category: "search_engine_crawler" }, + // The unclassified bucket comes back with an empty bot name; it is not a bot. + { timestamp: day("2026-09-29"), vercel_request_count_sum: 9999, bot_name: "", bot_category: "" }, + ], + aiPaths: [], + windowDays: 30, + pathsWindowDays: 7, + }); + expect(t.bots.map((b) => b.bot)).toEqual(["claudebot", "googlebot"]); + expect(t.bots[0].requests).toBe(400); + expect(t.bots[0].sharePct).toBeCloseTo(80, 5); + }); + + test("an unknown category is carried through as itself, not folded into other", () => { + // Vercel ships more categories than the two AI ones (browser_impersonation, + // http_client, client_anomaly...). Folding them would hide a new one. + const t = shapeAgentTraffic({ + daily: [ + { timestamp: day("2026-09-29"), vercel_request_count_sum: 500, bot_category: "browser_impersonation" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 300, bot_category: "ai_crawler" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 200, bot_category: "" }, + ], + bots: [], + aiPaths: [], + windowDays: 30, + pathsWindowDays: 7, + }); + expect(t.categories.map((c) => c.category)).toContain("browser_impersonation"); + expect(t.totalRequests).toBe(1000); + const d = t.daily[0]; + expect(d.ai).toBe(300); + expect(d.other).toBe(500); + expect(d.unclassified).toBe(200); + }); + + test("the first day with data skips the empty days before Observability was on", () => { + // The window reaches back past the day the entitlement was enabled, and + // those days come back as real zero rows rather than as absent rows. + const t = shapeAgentTraffic({ + daily: [ + { timestamp: day("2026-09-20"), vercel_request_count_sum: 0, bot_category: "" }, + { timestamp: day("2026-09-21"), vercel_request_count_sum: 0, bot_category: "ai_crawler" }, + { timestamp: day("2026-09-28"), vercel_request_count_sum: 11672, bot_category: "" }, + ], + bots: [], + aiPaths: [], + windowDays: 30, + pathsWindowDays: 7, + }); + expect(t.firstDayWithData).toBe("2026-09-28"); + expect(t.daily.map((d) => d.day)).toEqual(["2026-09-20", "2026-09-21", "2026-09-28"]); + }); + + test("paths are summed across daily buckets and zero rows dropped", () => { + const t = shapeAgentTraffic({ + daily: [], + bots: [], + aiPaths: [ + { timestamp: day("2026-09-28"), vercel_request_count_sum: 1200, request_path: "/about" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 2271, request_path: "/about" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 3538, request_path: "/contribute" }, + { timestamp: day("2026-09-27"), vercel_request_count_sum: 0, request_path: "/never-read" }, + ], + windowDays: 30, + pathsWindowDays: 7, + }); + expect(t.aiPaths).toEqual([ + { path: "/contribute", requests: 3538 }, + { path: "/about", requests: 3471 }, + ]); + expect(t.aiPathsWindowDays).toBe(7); + }); +}); diff --git a/harnesses/hl-archive/.env.example b/harnesses/hl-archive/.env.example index e36e17f79..fde842d0c 100644 --- a/harnesses/hl-archive/.env.example +++ b/harnesses/hl-archive/.env.example @@ -8,3 +8,12 @@ HL_ARCHIVE_UPSTASH_KEY= # Daily cron hour UTC (default 2) HL_ARCHIVE_CRON_HOUR= LOG_LEVEL= + +# --- trader leaderboard audit (`hl-archive traders`) --- +# Highest-PnL accounts given an info API `portfolio` read. 0 disables the +# sweep; the leaderboard audit still publishes without it. +# HL_TRADERS_SAMPLE_SIZE=500 +# Info API requests per second. The endpoint is weight-limited per IP. +# HL_TRADERS_RPS=5 +# Snapshot key. Separate from HL_ARCHIVE_UPSTASH_KEY on purpose. +# HL_TRADERS_UPSTASH_KEY=ocb:hl-traders:v1 diff --git a/harnesses/hl-archive/README.md b/harnesses/hl-archive/README.md index c433cc5dd..09a59340c 100644 --- a/harnesses/hl-archive/README.md +++ b/harnesses/hl-archive/README.md @@ -243,3 +243,92 @@ Payload size grows linearly with the builder count and the timeseries length. Wi | Failure mode if down | Live card stale, history still served | History stale, live card unaffected | The two harnesses are intentionally decoupled: live and historical views have very different freshness, infra, and disk requirements, and pinning them together would force the live card to wait on a 24 h batch or the history to be pinned to OVH disk. Each side serves what it is good at. + +## The trader leaderboard audit (`traders`) + +A second collector in the same binary, independent of the builder-fills +pipeline above. It audits Hyperliquid's own published trader leaderboard +rather than reproducing it. + +```bash +hl-archive traders +``` + +### What it reads + +| Source | Shape | Auth | +|---|---|---| +| `stats-data.hyperliquid.xyz/Mainnet/leaderboard` | one JSON blob, ~39 MB, ~47k accounts with PnL / ROI / volume over day, week, month, allTime plus equity | none | +| `api.hyperliquid.xyz/info` `{"type":"portfolio","user":"0x…"}` | per account: `pnlHistory` and `accountValueHistory` over the same four windows **plus a perp-only mirror of each** | none | + +### Why it exists + +Three published properties of the leaderboard contradict each other, and +the venue's UI gives no way to see it: + +1. **`pnl` and `vlm` count different universes.** `vlm` is perp notional; + `pnl` also absorbs spot, vault deposits (HLP) and staking. On the + first full read (2026-10-03) **2,904 of 47,123 accounts published a + non-zero all-time PnL against exactly zero all-time volume, worth + $5.3 bn, 32 % of the leaderboard's aggregate PnL.** +2. **The aggregate cannot be what it says.** The rows sum to **+$16.54 bn** + with **51.3 % of accounts in profit**. Perp PnL is zero-sum between + longs and shorts net of fees, so a positive aggregate over a majority + of winners is arithmetic proof the published set is not the + population. Spot and HLP yield genuinely are not zero-sum, which is + the same finding from the other side: three P&L universes summed into + one sortable column. +3. **`roi` ships without its denominator.** 590 accounts publish an ROI + above 10,000 % in absolute value, the highest past 2,600,000 % on five + figures of volume. + +### Why no node + +Per-fill data for every account needs a Hyperliquid node. The public CDN +serves `builder_fills` and nothing else: `node_fills`, `fills`, +`node_trades`, `trades`, `asset_ctxs`, `market_data`, `liquidations` and +`funding` all answer 403. The SGP node that used to provide it stalled on +2026-09-23 and only the requester-pays S3 snapshot would bootstrap it. + +It is also unnecessary here. `portfolio` publishes per-account PnL +directly, with the perp/non-perp split already decomposed, so a node +would add only trade-level detail this collector does not use. + +### Sampling is biased on purpose + +`HL_TRADERS_SAMPLE_SIZE` accounts get a `portfolio` read, taken from the +**highest-PnL** rows. Those are the rows the leaderboard showcases, so +auditing them is the point, but ranking by PnL selects for the +zero-volume cohort by construction: the top 8 came back **81.7 % +non-perp** against **18 %** on a single mid-table account. Anything +consuming `hl_traders_non_perp_pnl_pct` must say "of the top N", which is +why `hl_traders_sampled_accounts` is published alongside it. + +### Configuration + +| Env | Default | Meaning | +|---|---|---| +| `HL_TRADERS_SAMPLE_SIZE` | `500` | highest-PnL accounts given a `portfolio` read. `0` disables sampling. | +| `HL_TRADERS_RPS` | `5` | info API requests per second. The endpoint is weight-limited per IP and this is a courtesy read of a public service. | +| `HL_TRADERS_UPSTASH_KEY` | `ocb:hl-traders:v1` | snapshot key. Separate from `HL_ARCHIVE_UPSTASH_KEY`; the two payloads never share a key. | + +The snapshot is capped at `tradersUpstashRowCap` (500) account rows, +because the Upstash free-tier ceiling is 1 MB per key and the full blob +is ~39 MB. The audit figures are aggregates over all ~47k accounts +regardless of that cap. + +### Metrics + +`hl_traders_*` on the existing `/metrics` endpoint, so no new Prometheus +scrape job is needed. Gauges are set from the leaderboard audit **before** +the info API sweep begins, so a rate-limited or interrupted sweep still +leaves the leaderboard figures published rather than nothing. + +### Stateless + +The run needs no DuckDB: every figure is an aggregate over a single +snapshot, so a failed cycle costs nothing but the next one. The +per-account history that a rank-persistence measurement wants already +ships inside the `portfolio` response (`allTime` carries ~90 points back +to the account's first deposit), so storage is a follow-up rather than a +precondition. diff --git a/harnesses/hl-archive/script/cli.go b/harnesses/hl-archive/script/cli.go index fedc291d8..49c8abaf8 100644 --- a/harnesses/hl-archive/script/cli.go +++ b/harnesses/hl-archive/script/cli.go @@ -80,6 +80,8 @@ func Run(args []string) error { return cmdServe(rest) case "query": return cmdQuery(rest) + case "traders": + return cmdTraders(rest) case "-h", "--help", "help": printUsage() return nil @@ -98,6 +100,7 @@ Usage: hl-archive rebuild --confirm hl-archive serve hl-archive query --builder 0x... --days N + hl-archive traders `) } @@ -442,3 +445,36 @@ func builderActiveOn(b Builder, day time.Time) bool { } return !day.Before(vf) } + +// cmdTraders runs one trader-leaderboard audit cycle. +// +// Stateless on purpose: every figure it publishes is an aggregate over a +// single snapshot of the venue's own blob, so the run needs no DuckDB and +// a failed cycle costs nothing but the next one. The per-account history +// the persistence measurement wants already ships inside the info API +// response (see portfolio.go), so storage is a follow-up concern rather +// than a precondition. +func cmdTraders(_ []string) error { + ctx, cancel := signalCtx() + defer cancel() + + snap, err := RunTraders(ctx, TradersOptionsFromEnv()) + if err != nil { + return err + } + in := snap.Integrity + Log.Info("leaderboard audit", + "accounts", in.Accounts, + "winners_pct", fmt.Sprintf("%.1f", in.WinnersPct), + "agg_pnl_usd", fmt.Sprintf("%.0f", in.AggPnL), + "zero_vlm_accounts", in.ZeroVlmAccounts, + "zero_vlm_pnl_pct", fmt.Sprintf("%.1f", in.ZeroVlmPnLPct), + "top100_pnl_pct", fmt.Sprintf("%.1f", in.Top100PnLPct), + "roi_outliers", in.ROIOutliers, + "malformed_rows", in.MalformedRows, + ) + if err := PushTradersUpstash(ctx, snap); err != nil { + return err + } + return nil +} diff --git a/harnesses/hl-archive/script/metrics.go b/harnesses/hl-archive/script/metrics.go index 89787d889..d959bc34e 100644 --- a/harnesses/hl-archive/script/metrics.go +++ b/harnesses/hl-archive/script/metrics.go @@ -58,3 +58,83 @@ var ( Help: "HTTP requests served by the API.", }, []string{"path", "code"}) ) + +// Trader-leaderboard audit gauges. One snapshot of the venue's published +// table per run; every figure is a count or a sum over its rows, so a +// reader can recompute all of them from the same public blob. +var ( + MetricTradersAccounts = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_traders_accounts", + Help: "Accounts present in Hyperliquid's published leaderboard blob.", + }) + + MetricTradersWinnersPct = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_traders_winners_pct", + Help: "Share of published accounts with all-time PnL above zero. Perp PnL is zero-sum net of fees, so a majority in profit means the published set is not the population.", + }) + + MetricTradersAggPnL = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_traders_agg_pnl_usd", + Help: "Sum of all-time PnL across every published account. Positive by the same argument as hl_traders_winners_pct.", + }) + + MetricTradersAggEquity = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_traders_agg_equity_usd", + Help: "Sum of current account equity across every published account.", + }) + + MetricTradersAggVlm = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_traders_agg_vlm_usd", + Help: "Sum of all-time volume across every published account. Counts perp notional only, unlike the PnL field beside it.", + }) + + MetricTradersZeroVlmAccounts = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_traders_zero_volume_accounts", + Help: "Accounts publishing a non-zero all-time PnL against exactly zero all-time volume: the seam between the two fields.", + }) + + MetricTradersZeroVlmPnL = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_traders_zero_volume_pnl_usd", + Help: "Summed all-time PnL of the zero-volume cohort, in USD.", + }) + + MetricTradersZeroVlmPnLPct = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_traders_zero_volume_pnl_pct", + Help: "The zero-volume cohort's share of the leaderboard's aggregate PnL.", + }) + + MetricTradersTop100PnLPct = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_traders_top100_pnl_pct", + Help: "Share of aggregate all-time PnL carried by the hundred highest rows.", + }) + + MetricTradersROIOutliers = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_traders_roi_outliers", + Help: "Accounts whose published ROI exceeds 10,000 % in absolute value. ROI ships without its denominator, so it is not comparable across rows at any value.", + }) + + MetricTradersNonPerpPnLPct = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_traders_non_perp_pnl_pct", + Help: "Share of lifetime PnL that the perp-only window does not claim (spot, vaults, staking), across the highest-PnL accounts sampled from the info API portfolio request. Read it as a property of the rows the leaderboard showcases, never of the population: sampling the top by PnL selects for the zero-volume cohort by construction. Pair it with hl_traders_sampled_accounts.", + }) + + MetricTradersSampled = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_traders_sampled_accounts", + Help: "Accounts successfully read from the info API portfolio endpoint on the last run.", + }) + + MetricTradersPortfolioErrors = promauto.NewCounter(prometheus.CounterOpts{ + Name: "hl_traders_portfolio_errors_total", + Help: "Failed info API portfolio reads, cumulative.", + }) + + MetricTradersMalformedRows = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_traders_malformed_rows", + Help: "Leaderboard rows whose lifetime window was absent or unreadable. Expected zero; non-zero means the published blob's shape moved and every other hl_traders_* figure needs re-reading before it is quoted.", + }) + + MetricTradersLastRun = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_traders_last_run_unix_seconds", + Help: "Unix timestamp of the last completed trader-leaderboard audit.", + }) +) diff --git a/harnesses/hl-archive/script/portfolio.go b/harnesses/hl-archive/script/portfolio.go new file mode 100644 index 000000000..f413eebcb --- /dev/null +++ b/harnesses/hl-archive/script/portfolio.go @@ -0,0 +1,204 @@ +// portfolio.go — per-account PnL from the info API, and the one number +// the published leaderboard cannot give you. +// +// `POST /info {"type":"portfolio","user":"0x…"}` answers with eight +// windows: day / week / month / allTime and a perp-only mirror of each. +// Every window carries `pnlHistory` and `accountValueHistory` as +// [millis, value] pairs, back to the account's first deposit (2024-01-24 +// on the account probed while designing this). +// +// Two consequences worth stating, because both correct an assumption we +// started from: +// +// - the history already exists. The leaderboard is a snapshot, so the +// obvious plan was to archive it daily and wait two months for a +// persistence series. Unnecessary: `allTime` ships ~90 points per +// account, `month` ~47 at 16 h granularity. Rank persistence is +// computable on day one. +// - the perp/non-perp split is published, not inferred. Reading +// `perpAllTime` against `allTime` gives, per account, the share of +// lifetime PnL that did not come from perps. On the account probed +// on 2026-10-03 that was $36.1 m of $198.9 m, 18 %, sitting in a +// leaderboard column beside a volume field that counts perps only. +package script + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "time" +) + +// portfolioWindow is one window of the info API response. +type portfolioWindow struct { + AccountValueHistory [][2]json.RawMessage `json:"accountValueHistory"` + PnLHistory [][2]json.RawMessage `json:"pnlHistory"` +} + +// PortfolioSample is what we keep per account: the latest point of the +// two lifetime series. The full history stays on the wire for now; the +// persistence measurement reads it in a follow-up and does not change +// this shape. +type PortfolioSample struct { + Address string `json:"address"` + PnL float64 `json:"pnl_usd"` + PerpPnL float64 `json:"perp_pnl_usd"` + Equity float64 `json:"equity_usd"` +} + +// NonPerpPnL is the part of lifetime PnL the perp window does not claim: +// spot, vault deposits (HLP), staking. Can be negative when perps carried +// the account and the rest lost, which is itself worth seeing. +func (p PortfolioSample) NonPerpPnL() float64 { return p.PnL - p.PerpPnL } + +// lastValue reads the final value of a [millis, value] series. The value +// is published as a JSON string; a series that is empty or unparseable +// yields 0, false so callers can tell "no data" from "zero". +func lastValue(series [][2]json.RawMessage) (float64, bool) { + if len(series) == 0 { + return 0, false + } + var s string + if err := json.Unmarshal(series[len(series)-1][1], &s); err != nil { + return 0, false + } + v := parseFloat(s) + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0, false + } + return v, true +} + +// FetchPortfolio asks the info API for one account. +func FetchPortfolio(ctx context.Context, client *http.Client, addr string) (PortfolioSample, error) { + body, err := json.Marshal(map[string]string{"type": "portfolio", "user": addr}) + if err != nil { + return PortfolioSample{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, InfoAPIURL, bytes.NewReader(body)) + if err != nil { + return PortfolioSample{}, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgent) + + resp, err := client.Do(req) + if err != nil { + return PortfolioSample{}, fmt.Errorf("portfolio post: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return PortfolioSample{}, fmt.Errorf("portfolio http %d", resp.StatusCode) + } + return ParsePortfolio(addr, resp.Body) +} + +// ParsePortfolio decodes the [name, window] pair array. +func ParsePortfolio(addr string, r io.Reader) (PortfolioSample, error) { + var raw [][]json.RawMessage + if err := json.NewDecoder(r).Decode(&raw); err != nil { + return PortfolioSample{}, fmt.Errorf("decode portfolio: %w", err) + } + out := PortfolioSample{Address: addr} + var seen bool + for _, pair := range raw { + if len(pair) != 2 { + continue + } + var name string + if err := json.Unmarshal(pair[0], &name); err != nil { + continue + } + if name != "allTime" && name != "perpAllTime" { + continue + } + var w portfolioWindow + if err := json.Unmarshal(pair[1], &w); err != nil { + continue + } + pnl, ok := lastValue(w.PnLHistory) + if !ok { + continue + } + switch name { + case "allTime": + out.PnL = pnl + seen = true + if eq, ok := lastValue(w.AccountValueHistory); ok { + out.Equity = eq + } + case "perpAllTime": + out.PerpPnL = pnl + } + } + if !seen { + return PortfolioSample{}, fmt.Errorf("portfolio carried no allTime window") + } + return out, nil +} + +// Decomposition aggregates a sample into the published figure. +type Decomposition struct { + Sampled int `json:"sampled"` + Failed int `json:"failed"` + PnL float64 `json:"pnl_usd"` + PerpPnL float64 `json:"perp_pnl_usd"` + NonPerpPnL float64 `json:"non_perp_pnl_usd"` + NonPerpPct float64 `json:"non_perp_pnl_pct"` + // WithNonPerp counts accounts where the two windows disagree by more + // than a dollar. A count, not a share of dollars, so one whale + // cannot carry the finding on its own. + WithNonPerp int `json:"accounts_with_non_perp_pnl"` +} + +// Decompose folds samples into the aggregate split. +func Decompose(samples []PortfolioSample, failed int) Decomposition { + d := Decomposition{Sampled: len(samples), Failed: failed} + for _, s := range samples { + d.PnL += s.PnL + d.PerpPnL += s.PerpPnL + if math.Abs(s.NonPerpPnL()) > 1 { + d.WithNonPerp++ + } + } + d.NonPerpPnL = d.PnL - d.PerpPnL + d.NonPerpPct = pct(d.NonPerpPnL, d.PnL) + return d +} + +// SamplePortfolios walks addresses at a fixed request rate. +// +// Paced deliberately: the info API is weight-limited per IP and this is a +// courtesy read of a public endpoint, not a right. A failed account is +// counted and skipped rather than retried, because the aggregate is a +// share and one missing row moves it less than a stalled sweep would. +func SamplePortfolios(ctx context.Context, client *http.Client, addrs []string, perSecond float64) (Decomposition, []PortfolioSample) { + if perSecond <= 0 { + perSecond = 5 + } + tick := time.NewTicker(time.Duration(float64(time.Second) / perSecond)) + defer tick.Stop() + + samples := make([]PortfolioSample, 0, len(addrs)) + var failed int + for _, addr := range addrs { + select { + case <-ctx.Done(): + Log.Warn("portfolio sweep cut short", "done", len(samples), "of", len(addrs)) + return Decompose(samples, failed), samples + case <-tick.C: + } + s, err := FetchPortfolio(ctx, client, addr) + if err != nil { + failed++ + MetricTradersPortfolioErrors.Inc() + continue + } + samples = append(samples, s) + } + return Decompose(samples, failed), samples +} diff --git a/harnesses/hl-archive/script/traders.go b/harnesses/hl-archive/script/traders.go new file mode 100644 index 000000000..a2bd7141c --- /dev/null +++ b/harnesses/hl-archive/script/traders.go @@ -0,0 +1,294 @@ +// traders.go — the trader side of the Hyperliquid archive. +// +// Hyperliquid publishes its own trader leaderboard as a single public +// blob: `stats-data.hyperliquid.xyz/Mainnet/leaderboard`, ~39 MB, no key, +// ~47k accounts, each carrying PnL / ROI / volume over day, week, month +// and allTime plus current equity. +// +// We do not reproduce that table. The venue already renders it, and a +// copy would say nothing new. What is missing is an audit, because three +// properties of the published blob contradict each other and the page +// offers no way to see it: +// +// - `pnl` and `vlm` sit in the same row and count different universes. +// `vlm` is perp notional; `pnl` absorbs spot, vaults and staking too. +// On the first full read (2026-10-03) 2,898 accounts published a +// non-zero PnL against exactly zero all-time volume, worth $5.29 bn, +// 32 % of the leaderboard's aggregate PnL. +// - the aggregate is strongly positive (+$16.53 bn across 47,107 +// accounts, 51.2 % of them in profit). Perp PnL is zero-sum between +// longs and shorts net of fees, so a positive aggregate is proof the +// published set is not the population: the losers are absent. Spot +// and HLP yield are genuinely not zero-sum and explain part of the +// surplus, which is the same finding from the other side. three +// different P&L universes summed into one sortable column. +// - `roi` is published without its denominator. One account shows +// 26412.04 (2,641,204 %) on $16 k of volume. +// +// None of this needs a node. The retired SGP node was the only source of +// complete per-fill data (the public CDN serves `builder_fills` and +// nothing else: node_fills, fills, node_trades, trades, asset_ctxs, +// market_data, liquidations and funding all answer 403), but per-account +// PnL does not require fills. The info API's `portfolio` request returns +// pnlHistory and accountValueHistory per address over four windows plus a +// perp-only mirror of each, which is exactly the decomposition the +// leaderboard flattens. Reading `perpAllTime` against `allTime` turns the +// zero-volume cohort from an inference into a per-account measurement. +package script + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "sort" + "strconv" + "strings" +) + +const ( + // LeaderboardURL is the venue's own published table. Public, keyless. + LeaderboardURL = "https://stats-data.hyperliquid.xyz/Mainnet/leaderboard" + // InfoAPIURL serves the per-account `portfolio` request. + InfoAPIURL = "https://api.hyperliquid.xyz/info" + + // leaderboardMaxBytes caps the decompressed read. The blob measured + // 38.8 MB on 2026-10-03; 256 MB leaves room for years of growth while + // still refusing an unbounded body. + leaderboardMaxBytes = 256 << 20 + + // roiOutlierThreshold is the |roi| above which the published ratio + // cannot be read as a return. 100 means 10,000 %: survivable for a + // genuine small-base account, which is the point. the figure is + // published next to PnL with no denominator, so it is not comparable + // across rows whatever its value. + roiOutlierThreshold = 100.0 +) + +// WindowPerf is one window's figures, as published (strings). +type WindowPerf struct { + PnL string `json:"pnl"` + ROI string `json:"roi"` + Vlm string `json:"vlm"` +} + +// Perf is a parsed window. +type Perf struct { + PnL float64 + ROI float64 + Vlm float64 +} + +// Account is one leaderboard row, parsed. +type Account struct { + Address string + Equity float64 + // Windows keyed by the venue's own names: day, week, month, allTime. + Windows map[string]Perf + // AllTimeOK reports that the lifetime window was present and both its + // PnL and volume parsed. Only such rows may enter the zero-volume + // cohort; see parseFloatOK. + AllTimeOK bool +} + +// AllTime is the lifetime window, or the zero value when the row omits +// it. Callers treat a missing window as zeros rather than erroring: the +// blob is the venue's, and a shape change should degrade the audit, not +// stop the archive. +func (a Account) AllTime() Perf { return a.Windows["allTime"] } + +// leaderboardEnvelope mirrors the published JSON. windowPerformances is a +// heterogeneous array of [name, figures] pairs, so it decodes through +// RawMessage rather than a struct. +type leaderboardEnvelope struct { + LeaderboardRows []struct { + EthAddress string `json:"ethAddress"` + AccountValue string `json:"accountValue"` + WindowPerfs [][]json.RawMessage `json:"windowPerformances"` + } `json:"leaderboardRows"` +} + +// parseFloat is lenient by design: the blob is all strings, and a field +// we cannot read is worth zero rather than an aborted run. +func parseFloat(s string) float64 { + v, _ := parseFloatOK(s) + return v +} + +// parseFloatOK is the same parse, reporting whether it succeeded. +// +// The distinction matters in exactly one place and it is the headline. +// The zero-volume cohort is defined as "volume is zero while PnL is +// not", so a `vlm` that merely failed to parse would enter the count as +// a real zero and inflate the finding silently. On 2026-10-03 the live +// blob had zero non-numeric fields across all 47,123 rows, so this +// guards a format change rather than today's data. Unreadable rows are +// counted into Integrity.MalformedRows and published, so a change is +// loud instead of flattering. +func parseFloatOK(s string) (float64, bool) { + v, err := strconv.ParseFloat(strings.TrimSpace(s), 64) + if err != nil || math.IsNaN(v) || math.IsInf(v, 0) { + return 0, false + } + return v, true +} + +// ParseLeaderboard decodes the published blob into accounts. +func ParseLeaderboard(r io.Reader) ([]Account, error) { + var env leaderboardEnvelope + dec := json.NewDecoder(io.LimitReader(r, leaderboardMaxBytes)) + if err := dec.Decode(&env); err != nil { + return nil, fmt.Errorf("decode leaderboard: %w", err) + } + if len(env.LeaderboardRows) == 0 { + return nil, fmt.Errorf("leaderboard carried no rows") + } + out := make([]Account, 0, len(env.LeaderboardRows)) + for _, row := range env.LeaderboardRows { + if row.EthAddress == "" { + continue + } + acc := Account{ + Address: strings.ToLower(row.EthAddress), + Equity: parseFloat(row.AccountValue), + Windows: make(map[string]Perf, len(row.WindowPerfs)), + } + for _, pair := range row.WindowPerfs { + if len(pair) != 2 { + continue + } + var name string + if err := json.Unmarshal(pair[0], &name); err != nil { + continue + } + var w WindowPerf + if err := json.Unmarshal(pair[1], &w); err != nil { + continue + } + pnl, pnlOK := parseFloatOK(w.PnL) + vlm, vlmOK := parseFloatOK(w.Vlm) + acc.Windows[name] = Perf{ + PnL: pnl, + ROI: parseFloat(w.ROI), + Vlm: vlm, + } + if name == "allTime" { + acc.AllTimeOK = pnlOK && vlmOK + } + } + out = append(out, acc) + } + return out, nil +} + +// Integrity is the audit, computed from one snapshot. Every field is a +// count or a sum over the published rows: nothing here is modelled, so a +// reader can recompute all of it from the same blob. +type Integrity struct { + Accounts int `json:"accounts"` + // Winners counts accounts with all-time PnL > 0. Its share is the + // zero-sum tell: a perp market cannot hand a majority a profit. + Winners int `json:"winners"` + WinnersPct float64 `json:"winners_pct"` + AggPnL float64 `json:"agg_pnl_usd"` + AggEquity float64 `json:"agg_equity_usd"` + AggVlm float64 `json:"agg_vlm_usd"` + // ZeroVlm is the cohort whose row claims profit on no volume: the + // seam between the two fields, in accounts and in dollars. + ZeroVlmAccounts int `json:"zero_vlm_accounts"` + ZeroVlmPnL float64 `json:"zero_vlm_pnl_usd"` + ZeroVlmPnLPct float64 `json:"zero_vlm_pnl_pct"` + // Top100PnLPct is concentration: how much of the aggregate the first + // hundred rows carry. + Top100PnLPct float64 `json:"top100_pnl_pct"` + // ROIOutliers counts rows whose published ROI cannot be read as a + // return (see roiOutlierThreshold). + ROIOutliers int `json:"roi_outliers"` + // LosersPnL is the summed loss, kept separate so the aggregate's + // sign is attributable rather than a single net figure. + LosersPnL float64 `json:"losers_pnl_usd"` + // MalformedRows counts rows whose lifetime window was absent or + // unreadable. Expected to be zero; a non-zero value means the blob's + // shape moved and every figure above needs re-reading before it is + // quoted. + MalformedRows int `json:"malformed_rows"` +} + +// Audit computes the integrity figures over a parsed leaderboard. +func Audit(accounts []Account) Integrity { + in := Integrity{Accounts: len(accounts)} + if len(accounts) == 0 { + return in + } + pnls := make([]float64, 0, len(accounts)) + for _, a := range accounts { + at := a.AllTime() + in.AggPnL += at.PnL + in.AggEquity += a.Equity + in.AggVlm += at.Vlm + if at.PnL > 0 { + in.Winners++ + } else { + in.LosersPnL += at.PnL + } + if !a.AllTimeOK { + in.MalformedRows++ + } else if at.Vlm == 0 && at.PnL != 0 { + // Only a row we actually read may count: a vlm that failed + // to parse is not a zero. + in.ZeroVlmAccounts++ + in.ZeroVlmPnL += at.PnL + } + if math.Abs(at.ROI) > roiOutlierThreshold { + in.ROIOutliers++ + } + pnls = append(pnls, at.PnL) + } + in.WinnersPct = pct(float64(in.Winners), float64(in.Accounts)) + in.ZeroVlmPnLPct = pct(in.ZeroVlmPnL, in.AggPnL) + + sort.Sort(sort.Reverse(sort.Float64Slice(pnls))) + n := 100 + if len(pnls) < n { + n = len(pnls) + } + var top float64 + for _, v := range pnls[:n] { + top += v + } + in.Top100PnLPct = pct(top, in.AggPnL) + return in +} + +// pct guards the division so an empty or zero-sum denominator yields 0 +// rather than NaN, which would poison a Prometheus gauge silently. +func pct(num, den float64) float64 { + if den == 0 { + return 0 + } + return 100 * num / den +} + +// FetchLeaderboard GETs the published blob. +func FetchLeaderboard(ctx context.Context, client *http.Client, url string) ([]Account, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", userAgent) + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("leaderboard get: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("leaderboard http %d", resp.StatusCode) + } + return ParseLeaderboard(resp.Body) +} + +// userAgent identifies the probe to the venue. A shared public CDN is a +// courtesy we should be identifiable on. +const userAgent = "OpenChainBench/1.0 (+https://openchainbench.com)" diff --git a/harnesses/hl-archive/script/traders_run.go b/harnesses/hl-archive/script/traders_run.go new file mode 100644 index 000000000..17fba760b --- /dev/null +++ b/harnesses/hl-archive/script/traders_run.go @@ -0,0 +1,200 @@ +// traders_run.go — one audit cycle, and the snapshot the site reads. +package script + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "sort" + "strconv" + "time" +) + +// tradersUpstashRowCap bounds the account rows pushed to Upstash. +// +// The whole blob is ~39 MB for ~47k accounts and the Upstash free-tier +// ceiling is 1 MB per key, which the builder payload already sizes +// against (upstashTimeseriesDaysCap). 500 rows at roughly 120 B of JSON +// each lands near 60 KB, leaving the audit figures and future fields +// plenty of room. The full set stays on the harness; the page ranks the +// top, and the audit figures are aggregates over all 47k regardless of +// this cap. +const tradersUpstashRowCap = 500 + +// TraderRow is one published account, as the page ranks it. +type TraderRow struct { + Address string `json:"address"` + Equity float64 `json:"equity_usd"` + PnL float64 `json:"pnl_usd"` + ROI float64 `json:"roi"` + Vlm float64 `json:"vlm_usd"` + PnLDay float64 `json:"pnl_day_usd"` + // NoVolume flags a row claiming PnL on zero volume, so the page can + // mark it in place instead of dropping it and publishing the + // flattering half of the table. + NoVolume bool `json:"no_volume"` +} + +// TradersSnapshot is the payload the Next.js page consumes. +type TradersSnapshot struct { + UpdatedAt string `json:"updated_at"` + Source string `json:"source"` + Integrity Integrity `json:"integrity"` + Decomposition Decomposition `json:"decomposition"` + // Top is capped (tradersUpstashRowCap) and ordered by all-time PnL. + Top []TraderRow `json:"top"` + // RowCap is published so the page can say "top 500 of 47,107" + // instead of implying the table is complete. + RowCap int `json:"row_cap"` +} + +// TradersOptions configures one cycle. Zero values take the documented +// defaults so `traders` runs with no env set. +type TradersOptions struct { + // SampleSize is how many of the highest-PnL accounts get an info API + // portfolio read for the perp/non-perp split. 0 disables sampling. + // + // Sampling the top is deliberate: those are the rows the leaderboard + // showcases, and auditing them is the point. It is also biased by + // construction, because the highest-PnL rows are disproportionately + // the zero-volume cohort. On 2026-10-03 the top 8 came back 81.7 % + // non-perp against 18 % on a single mid-table account. Whatever + // consumes hl_traders_non_perp_pnl_pct has to say "of the top N", + // which is why Decomposition.Sampled travels with it. + SampleSize int + // RequestsPerSecond paces the info API sweep. + RequestsPerSecond float64 +} + +func envInt(key string, def int) int { + if v, err := strconv.Atoi(os.Getenv(key)); err == nil && v >= 0 { + return v + } + return def +} + +func envFloat(key string, def float64) float64 { + if v, err := strconv.ParseFloat(os.Getenv(key), 64); err == nil && v > 0 { + return v + } + return def +} + +// TradersOptionsFromEnv reads the two knobs. +func TradersOptionsFromEnv() TradersOptions { + return TradersOptions{ + SampleSize: envInt("HL_TRADERS_SAMPLE_SIZE", 500), + RequestsPerSecond: envFloat("HL_TRADERS_RPS", 5), + } +} + +// RunTraders fetches the published leaderboard, audits it, samples the +// per-account decomposition, and publishes both. +// +// Ordering matters: gauges are set from the audit before the info API +// sweep starts, so a rate-limited or interrupted sweep still leaves the +// leaderboard figures published rather than nothing. +func RunTraders(ctx context.Context, opts TradersOptions) (*TradersSnapshot, error) { + client := &http.Client{Timeout: 3 * time.Minute} + + accounts, err := FetchLeaderboard(ctx, client, LeaderboardURL) + if err != nil { + return nil, err + } + Log.Info("leaderboard read", "accounts", len(accounts)) + + audit := Audit(accounts) + publishAuditGauges(audit) + + rows := rankRows(accounts) + + var dec Decomposition + if opts.SampleSize > 0 { + n := opts.SampleSize + if n > len(rows) { + n = len(rows) + } + addrs := make([]string, 0, n) + for _, r := range rows[:n] { + addrs = append(addrs, r.Address) + } + // A deadline sized to the pace: the sweep needs n/rps seconds, so + // four times that plus a minute absorbs retries and slow + // responses while guaranteeing the cycle ends well before the + // next daily run can overlap it. + budget := time.Duration(float64(n)/opts.RequestsPerSecond*4)*time.Second + time.Minute + sweepCtx, cancel := context.WithTimeout(ctx, budget) + defer cancel() + sampleClient := &http.Client{Timeout: 30 * time.Second} + dec, _ = SamplePortfolios(sweepCtx, sampleClient, addrs, opts.RequestsPerSecond) + MetricTradersSampled.Set(float64(dec.Sampled)) + MetricTradersNonPerpPnLPct.Set(dec.NonPerpPct) + Log.Info("portfolio sweep done", + "sampled", dec.Sampled, "failed", dec.Failed, + "non_perp_pct", fmt.Sprintf("%.1f", dec.NonPerpPct)) + } + + if len(rows) > tradersUpstashRowCap { + rows = rows[:tradersUpstashRowCap] + } + snap := &TradersSnapshot{ + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + Source: LeaderboardURL, + Integrity: audit, + Decomposition: dec, + Top: rows, + RowCap: tradersUpstashRowCap, + } + MetricTradersLastRun.Set(float64(time.Now().Unix())) + return snap, nil +} + +// rankRows orders every account by all-time PnL, descending. +func rankRows(accounts []Account) []TraderRow { + rows := make([]TraderRow, 0, len(accounts)) + for _, a := range accounts { + at := a.AllTime() + rows = append(rows, TraderRow{ + Address: a.Address, + Equity: a.Equity, + PnL: at.PnL, + ROI: at.ROI, + Vlm: at.Vlm, + PnLDay: a.Windows["day"].PnL, + NoVolume: at.Vlm == 0 && at.PnL != 0, + }) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].PnL > rows[j].PnL }) + return rows +} + +func publishAuditGauges(in Integrity) { + MetricTradersAccounts.Set(float64(in.Accounts)) + MetricTradersWinnersPct.Set(in.WinnersPct) + MetricTradersAggPnL.Set(in.AggPnL) + MetricTradersAggEquity.Set(in.AggEquity) + MetricTradersAggVlm.Set(in.AggVlm) + MetricTradersZeroVlmAccounts.Set(float64(in.ZeroVlmAccounts)) + MetricTradersZeroVlmPnL.Set(in.ZeroVlmPnL) + MetricTradersZeroVlmPnLPct.Set(in.ZeroVlmPnLPct) + MetricTradersTop100PnLPct.Set(in.Top100PnLPct) + MetricTradersROIOutliers.Set(float64(in.ROIOutliers)) + MetricTradersMalformedRows.Set(float64(in.MalformedRows)) +} + +// PushTradersUpstash writes the snapshot under its own key, leaving the +// builder payload untouched. No-op when Upstash is unconfigured so local +// runs work. +func PushTradersUpstash(ctx context.Context, snap *TradersSnapshot) error { + key := os.Getenv("HL_TRADERS_UPSTASH_KEY") + if key == "" { + key = "ocb:hl-traders:v1" + } + body, err := json.Marshal(snap) + if err != nil { + return fmt.Errorf("marshal traders snapshot: %w", err) + } + return upstashSet(ctx, key, body) +} diff --git a/harnesses/hl-archive/script/traders_test.go b/harnesses/hl-archive/script/traders_test.go new file mode 100644 index 000000000..72189648c --- /dev/null +++ b/harnesses/hl-archive/script/traders_test.go @@ -0,0 +1,244 @@ +package script + +import ( + "math" + "strings" + "testing" +) + +// The envelope shape is the venue's, verbatim: every figure a string, +// and windowPerformances a heterogeneous [name, figures] array. Row 1 +// carries the case the audit exists for (PnL on zero volume), row 3 an +// ROI that cannot be read as a return, and row 4 a malformed window that +// must degrade to zeros rather than fail the run. +const sampleLeaderboard = `{"leaderboardRows":[ + {"ethAddress":"0xAAA","accountValue":"50","windowPerformances":[ + ["day",{"pnl":"5","roi":"0.1","vlm":"0"}], + ["allTime",{"pnl":"100","roi":"0.5","vlm":"0"}]]}, + {"ethAddress":"0xBBB","accountValue":"10","windowPerformances":[ + ["day",{"pnl":"-1","roi":"-0.01","vlm":"20"}], + ["allTime",{"pnl":"-40","roi":"-0.4","vlm":"1000"}]]}, + {"ethAddress":"0xCCC","accountValue":"5","windowPerformances":[ + ["allTime",{"pnl":"20","roi":"200","vlm":"500"}]]}, + {"ethAddress":"0xDDD","accountValue":"not-a-number","windowPerformances":[ + ["allTime",{"pnl":"oops","roi":"","vlm":"nope"}]]} +]}` + +func TestParseLeaderboard(t *testing.T) { + accs, err := ParseLeaderboard(strings.NewReader(sampleLeaderboard)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(accs) != 4 { + t.Fatalf("accounts = %d, want 4", len(accs)) + } + // Addresses are lowercased so the Upstash row keys and any future + // join against the builder archive agree on one casing. + if accs[0].Address != "0xaaa" { + t.Errorf("address = %q, want lowercased 0xaaa", accs[0].Address) + } + if got := accs[0].AllTime().PnL; got != 100 { + t.Errorf("allTime pnl = %v, want 100", got) + } + if got := accs[0].Windows["day"].PnL; got != 5 { + t.Errorf("day pnl = %v, want 5", got) + } + // An unreadable figure is worth zero, not an aborted archive. + if got := accs[3].AllTime().PnL; got != 0 { + t.Errorf("unparseable pnl = %v, want 0", got) + } + if got := accs[3].Equity; got != 0 { + t.Errorf("unparseable equity = %v, want 0", got) + } +} + +func TestParseLeaderboardRejectsEmpty(t *testing.T) { + if _, err := ParseLeaderboard(strings.NewReader(`{"leaderboardRows":[]}`)); err == nil { + t.Fatal("want an error on an empty blob: a silent zero would publish as a real reading") + } +} + +func TestAudit(t *testing.T) { + accs, err := ParseLeaderboard(strings.NewReader(sampleLeaderboard)) + if err != nil { + t.Fatalf("parse: %v", err) + } + in := Audit(accs) + + // Aggregate: 100 - 40 + 20 + 0. + if in.AggPnL != 80 { + t.Errorf("AggPnL = %v, want 80", in.AggPnL) + } + if in.AggEquity != 65 { + t.Errorf("AggEquity = %v, want 65", in.AggEquity) + } + if in.AggVlm != 1500 { + t.Errorf("AggVlm = %v, want 1500", in.AggVlm) + } + // Winners are strictly above zero, so the unparseable row (0) is not + // one: 0xAAA and 0xCCC of four rows. + if in.Winners != 2 { + t.Errorf("Winners = %d, want 2", in.Winners) + } + if math.Abs(in.WinnersPct-50) > 1e-9 { + t.Errorf("WinnersPct = %v, want 50", in.WinnersPct) + } + if in.LosersPnL != -40 { + t.Errorf("LosersPnL = %v, want -40", in.LosersPnL) + } + // The cohort the whole audit is about. + if in.ZeroVlmAccounts != 1 { + t.Errorf("ZeroVlmAccounts = %d, want 1", in.ZeroVlmAccounts) + } + if in.ZeroVlmPnL != 100 { + t.Errorf("ZeroVlmPnL = %v, want 100", in.ZeroVlmPnL) + } + // 100 of an aggregate of 80: the share exceeds 100 % because the + // losers net the denominator down. Surprising and correct, so it is + // pinned here rather than clamped in the code. + if math.Abs(in.ZeroVlmPnLPct-125) > 1e-9 { + t.Errorf("ZeroVlmPnLPct = %v, want 125", in.ZeroVlmPnLPct) + } + if in.ROIOutliers != 1 { + t.Errorf("ROIOutliers = %d, want 1 (the 200 = 20,000%% row)", in.ROIOutliers) + } + // Fewer than a hundred rows, so the top hundred is everything. + if math.Abs(in.Top100PnLPct-100) > 1e-9 { + t.Errorf("Top100PnLPct = %v, want 100", in.Top100PnLPct) + } + // 0xDDD's lifetime window did not read, and that is published rather + // than absorbed. + if in.MalformedRows != 1 { + t.Errorf("MalformedRows = %d, want 1", in.MalformedRows) + } +} + +// An unreadable volume is not a zero volume. +// +// Regression guard for the way this audit could quietly lie: the +// zero-volume cohort is the headline, it is defined as "volume zero, +// PnL not", and a lenient parse turns any future format change into +// thousands of fabricated members of that cohort. The live blob had no +// such row on 2026-10-03, so only this test keeps the distinction real. +func TestUnreadableVolumeIsNotZeroVolume(t *testing.T) { + const blob = `{"leaderboardRows":[ + {"ethAddress":"0xEEE","accountValue":"10","windowPerformances":[ + ["allTime",{"pnl":"500","roi":"0.1","vlm":"n/a"}]]} + ]}` + accs, err := ParseLeaderboard(strings.NewReader(blob)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if accs[0].AllTimeOK { + t.Error("AllTimeOK should be false when vlm did not parse") + } + in := Audit(accs) + if in.ZeroVlmAccounts != 0 { + t.Errorf("ZeroVlmAccounts = %d, want 0: an unparseable volume is not a zero", in.ZeroVlmAccounts) + } + if in.ZeroVlmPnL != 0 { + t.Errorf("ZeroVlmPnL = %v, want 0", in.ZeroVlmPnL) + } + if in.MalformedRows != 1 { + t.Errorf("MalformedRows = %d, want 1 so the shape change is visible", in.MalformedRows) + } + // The PnL still counts toward the aggregate: the row is real, only + // its volume is unreadable. + if in.AggPnL != 500 { + t.Errorf("AggPnL = %v, want 500", in.AggPnL) + } +} + +func TestAuditEmptyIsZeroNotNaN(t *testing.T) { + in := Audit(nil) + if in.Accounts != 0 || in.WinnersPct != 0 || in.ZeroVlmPnLPct != 0 { + t.Fatalf("empty audit = %+v, want zeros", in) + } + // A NaN reaching a gauge publishes as a real sample and poisons every + // query that touches it. + for name, v := range map[string]float64{ + "WinnersPct": in.WinnersPct, "ZeroVlmPnLPct": in.ZeroVlmPnLPct, + "Top100PnLPct": in.Top100PnLPct, + } { + if math.IsNaN(v) || math.IsInf(v, 0) { + t.Errorf("%s = %v, want a finite number", name, v) + } + } +} + +// The info API shape, with the figures from the account probed on +// 2026-10-03: allTime $198.89 m against perpAllTime $162.79 m. +const samplePortfolio = `[ + ["day",{"accountValueHistory":[[1,"1"]],"pnlHistory":[[1,"1"]],"vlm":"1"}], + ["allTime",{"accountValueHistory":[[1,"0"],[2,"119678434"]],"pnlHistory":[[1,"0"],[2,"198893626"]]}], + ["perpAllTime",{"accountValueHistory":[[1,"0"],[2,"100"]],"pnlHistory":[[1,"0"],[2,"162788845"]]}] +]` + +func TestParsePortfolio(t *testing.T) { + s, err := ParsePortfolio("0xabc", strings.NewReader(samplePortfolio)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if s.PnL != 198893626 { + t.Errorf("PnL = %v, want 198893626", s.PnL) + } + if s.PerpPnL != 162788845 { + t.Errorf("PerpPnL = %v, want 162788845", s.PerpPnL) + } + // Equity comes from the allTime window, not the perp mirror. + if s.Equity != 119678434 { + t.Errorf("Equity = %v, want 119678434", s.Equity) + } + if got := s.NonPerpPnL(); got != 36104781 { + t.Errorf("NonPerpPnL = %v, want 36104781", got) + } +} + +func TestParsePortfolioNeedsAllTime(t *testing.T) { + _, err := ParsePortfolio("0xabc", strings.NewReader(`[["day",{"pnlHistory":[[1,"5"]]}]]`)) + if err == nil { + t.Fatal("want an error when allTime is absent: a zero would read as a flat account") + } +} + +func TestDecompose(t *testing.T) { + d := Decompose([]PortfolioSample{ + {Address: "a", PnL: 198893626, PerpPnL: 162788845}, + {Address: "b", PnL: 100, PerpPnL: 100}, // pure perp + {Address: "c", PnL: 50, PerpPnL: -50}, // perps lost, spot carried + }, 2) + if d.Sampled != 3 || d.Failed != 2 { + t.Errorf("Sampled/Failed = %d/%d, want 3/2", d.Sampled, d.Failed) + } + // 36104781 + 0 + 100. + if d.NonPerpPnL != 36104881 { + t.Errorf("NonPerpPnL = %v, want 36104881", d.NonPerpPnL) + } + // Counted, not weighted: 'b' is pure perp so only a and c qualify. + if d.WithNonPerp != 2 { + t.Errorf("WithNonPerp = %d, want 2", d.WithNonPerp) + } + want := 100 * 36104881.0 / 198893776.0 + if math.Abs(d.NonPerpPct-want) > 1e-9 { + t.Errorf("NonPerpPct = %v, want %v", d.NonPerpPct, want) + } +} + +func TestRankRowsOrdersAndFlags(t *testing.T) { + accs, err := ParseLeaderboard(strings.NewReader(sampleLeaderboard)) + if err != nil { + t.Fatalf("parse: %v", err) + } + rows := rankRows(accs) + if rows[0].PnL != 100 || rows[len(rows)-1].PnL != -40 { + t.Fatalf("not sorted by PnL desc: %v .. %v", rows[0].PnL, rows[len(rows)-1].PnL) + } + // The zero-volume row stays in the table, marked, rather than being + // dropped into a flattering silence. + if !rows[0].NoVolume { + t.Error("top row should be flagged NoVolume") + } + if rows[1].NoVolume { + t.Error("a row with real volume should not be flagged") + } +} diff --git a/harnesses/hl-archive/script/upstash.go b/harnesses/hl-archive/script/upstash.go index bd6e0914b..e48dbcd7f 100644 --- a/harnesses/hl-archive/script/upstash.go +++ b/harnesses/hl-archive/script/upstash.go @@ -41,16 +41,8 @@ type UpstashBuilder struct { // they're constant-size per builder. const upstashTimeseriesDaysCap = 90 -// PushUpstash sends the payload to the configured Upstash key. No-op -// (returns nil) when UPSTASH_REDIS_REST_URL/TOKEN are unset so local -// dev runs don't fail. +// PushUpstash sends the builder payload to the configured Upstash key. func PushUpstash(ctx context.Context, payload UpstashPayload) error { - base := strings.TrimRight(strings.TrimSpace(os.Getenv("UPSTASH_REDIS_REST_URL")), "/") - token := strings.TrimSpace(os.Getenv("UPSTASH_REDIS_REST_TOKEN")) - if base == "" || token == "" { - Log.Warn("upstash disabled (env not set)") - return nil - } key := strings.TrimSpace(os.Getenv("HL_ARCHIVE_UPSTASH_KEY")) if key == "" { key = "ocb:hl-archive:v1" @@ -69,6 +61,21 @@ func PushUpstash(ctx context.Context, payload UpstashPayload) error { if err != nil { return fmt.Errorf("marshal payload: %w", err) } + return upstashSet(ctx, key, body) +} + +// upstashSet performs one REST SET against the configured instance. +// +// Extracted from PushUpstash when the trader audit gained its own key: +// two payloads, two keys, one transport. Returns nil (not an error) when +// UPSTASH_REDIS_REST_URL/TOKEN are unset so local dev runs don't fail. +func upstashSet(ctx context.Context, key string, body []byte) error { + base := strings.TrimRight(strings.TrimSpace(os.Getenv("UPSTASH_REDIS_REST_URL")), "/") + token := strings.TrimSpace(os.Getenv("UPSTASH_REDIS_REST_TOKEN")) + if base == "" || token == "" { + Log.Warn("upstash disabled (env not set)", "key", key) + return nil + } endpoint := fmt.Sprintf("%s/set/%s", base, url.PathEscape(key)) start := time.Now() diff --git a/harnesses/hl-fills-reduce/README.md b/harnesses/hl-fills-reduce/README.md index 3315bb1d6..deba41a5a 100644 --- a/harnesses/hl-fills-reduce/README.md +++ b/harnesses/hl-fills-reduce/README.md @@ -28,6 +28,19 @@ block, and each fill carries `builder` and `builderFee` alongside the trader, coin, price, size, `crossed` and `closedPnl`. Everything the site reports is in there; it is just four gigabytes a day, so this reduces it to about 1.6 MB. +`--write-raw-book-diffs` is deliberately not passed, and should not be added +back. It wrote 4 GB on 2026-10-13 against 123 MB of fills, roughly 32x the +volume of the data anything uses, and nothing reads it: the only reference to +`node_raw_book_diffs_by_block` anywhere is `deploy/hl-cleanup.sh`, which +deletes it after three days. It had accumulated 85 GB written to be thrown +away. It also cost throughput where the node could least afford it: catching up +from an outage, the node was applying 787 blocks/min against the 837/min the +chain produces, so it lost ground until it hit its own limit and panicked with +`too many blocks to request`, at which point hl-visor restarted it and the +cycle repeated. Measure the chain's rate before judging such a shortfall: the +node logs the network's `latest_height`, and two readings an hour apart give it +directly. Assuming a block time turns a 6% deficit into an apparent 23% one. + ## What it is not A second opinion. Where this has a whole day, the consumer uses it *instead @@ -83,9 +96,25 @@ the data and the health of the machine that produces it travel together. | `hl_reduce_newest_day_age_seconds` | staleness of the newest reduced day | | `hl_reduce_node_last_hour_age_seconds` | **the node's real liveness**: systemd can call it active while it has stopped writing fills | | `hl_reduce_disk_used_pct`, `hl_reduce_disk_free_bytes` | the filesystem holding the node data | - -Alerts live in `deploy/ocb_hl_node_box.yml`, installed into -`/opt/ocb/prom-rules/` on the OCB VPS. +| `hl_reduce_root_disk_used_pct`, `hl_reduce_root_disk_free_bytes` | the root filesystem, which holds the node binaries, the journal and `/tmp`. `-1` when `statfs` fails, so an unmeasured mount cannot read as an empty one | + +The two disk readings are separate because a healthy data volume is not +evidence that the host is healthy. On 2026-10-08 the root filled to 100% while +`/mnt/hyperliquid` sat at 29%, held by a deleted-but-open trace file a +container had kept open since August. `hl-visor` stages new node binaries +through `/tmp`, which lives on the root, so it could not write the 106 MB +binary a hardfork required, and the node froze at the fork height from +2026-10-10 08:17 until it was found three days later. Every disk signal on the +box pointed at the data volume, so none of them moved. Deleted-but-open files +are invisible to `du` and counted by `df`, which is the gap to look for first: +`sudo lsof -nP +L1 | sort -k8 -rn | head`, then truncate the descriptor +(`truncate -s 0 /proc//fd/`) rather than restarting the holder. + +Alerts live in `deploy/ocb_hl_node_box.yml`, loaded from +`/etc/prometheus/rules/` in the `ocb-prom` container on the OCB VPS. Validate a +change with `docker exec ocb-prom promtool check rules ` before copying +it in, and confirm the group is live with +`docker exec ocb-prom wget -qO- http://localhost:9090/api/v1/rules`. ## Three things worth knowing before changing this diff --git a/harnesses/hl-fills-reduce/cmd/script/main.go b/harnesses/hl-fills-reduce/cmd/script/main.go index a588739bb..725654221 100644 --- a/harnesses/hl-fills-reduce/cmd/script/main.go +++ b/harnesses/hl-fills-reduce/cmd/script/main.go @@ -38,6 +38,10 @@ const ( defaultAddr = "127.0.0.1:2115" defaultFillsDir = "/mnt/hyperliquid/data/node_fills_by_block/hourly" defaultOutDir = "/mnt/hyperliquid/ocb-fills-daily" + // Days rebuilt from the public export plus the per-wallet info API, for + // outages the node cannot replay. Kept apart from the reduced days so a + // rebuilt day can never be mistaken for a measured one. + defaultRebuiltDir = "/mnt/hyperliquid/ocb-fills-rebuilt" // A day is reduced only once it can no longer change. The node writes // hour N's file when hour N ends, so a day is final shortly after 00:00 // the next day; the margin absorbs a slow flush and a restart. @@ -46,13 +50,14 @@ const ( func main() { var ( - addr = flag.String("addr", envOr("HL_REDUCE_ADDR", defaultAddr), "metrics and data listen address") - fillsDir = flag.String("fills", envOr("HL_REDUCE_FILLS", defaultFillsDir), "node hourly fills directory") - outDir = flag.String("out", envOr("HL_REDUCE_OUT", defaultOutDir), "where reduced days are written") - mount = flag.String("mount", envOr("HL_REDUCE_MOUNT", "/mnt/hyperliquid"), "filesystem to report free space for") - every = flag.Duration("every", 30*time.Minute, "how often to look for days to reduce") - once = flag.Bool("once", false, "reduce everything pending and exit") - backfill = flag.Int("backfill", 45, "how many days back to consider") + addr = flag.String("addr", envOr("HL_REDUCE_ADDR", defaultAddr), "metrics and data listen address") + fillsDir = flag.String("fills", envOr("HL_REDUCE_FILLS", defaultFillsDir), "node hourly fills directory") + outDir = flag.String("out", envOr("HL_REDUCE_OUT", defaultOutDir), "where reduced days are written") + rebuiltDir = flag.String("rebuilt", envOr("HL_REDUCE_REBUILT", defaultRebuiltDir), "where rebuilt days are read from, served only for days the node has no whole day for") + mount = flag.String("mount", envOr("HL_REDUCE_MOUNT", "/mnt/hyperliquid"), "filesystem to report free space for") + every = flag.Duration("every", 30*time.Minute, "how often to look for days to reduce") + once = flag.Bool("once", false, "reduce everything pending and exit") + backfill = flag.Int("backfill", 45, "how many days back to consider") ) flag.Parse() @@ -61,10 +66,11 @@ func main() { } r := &Reducer{ - FillsDir: *fillsDir, - OutDir: *outDir, - Mount: *mount, - Backfill: *backfill, + FillsDir: *fillsDir, + OutDir: *outDir, + RebuiltDir: *rebuiltDir, + Mount: *mount, + Backfill: *backfill, } if *once { diff --git a/harnesses/hl-fills-reduce/cmd/script/reduce.go b/harnesses/hl-fills-reduce/cmd/script/reduce.go index d3e6e8ae2..b6143ccd0 100644 --- a/harnesses/hl-fills-reduce/cmd/script/reduce.go +++ b/harnesses/hl-fills-reduce/cmd/script/reduce.go @@ -60,14 +60,35 @@ const reduceSchema = 2 // Day is the reduced form of one day of the node's fill stream. type Day struct { - Schema int `json:"schema"` - Day string `json:"day"` - Hours int `json:"hours"` - Complete bool `json:"complete"` - FirstFill string `json:"first_fill,omitempty"` - LastFill string `json:"last_fill,omitempty"` - ReducedAt int64 `json:"reduced_at"` - Builders map[string]*Totals `json:"builders"` + Schema int `json:"schema"` + Day string `json:"day"` + Hours int `json:"hours"` + Complete bool `json:"complete"` + FirstFill string `json:"first_fill,omitempty"` + LastFill string `json:"last_fill,omitempty"` + ReducedAt int64 `json:"reduced_at"` + // Rebuilt marks a day assembled from the public export plus the + // per-wallet info API instead of measured by the node. Absent on every + // day the reducer writes itself, so a consumer that does not know the + // field still cannot read a reconstruction as a measurement. + Rebuilt bool `json:"rebuilt,omitempty"` + // RebuiltFrom carries a rebuilt day's provenance and, above all, how far + // short it is expected to fall. Accuracy depends almost entirely on how much + // of the day the export's morning covered, which is known before any + // rebuilding happens, so the figure can travel with the day instead of + // living in someone's head: two complete days scored against the node gave + // -0.43 per cent at a 68.8 per cent morning and -28.2 per cent at 27.2. + RebuiltFrom *RebuiltFrom `json:"rebuilt_from,omitempty"` + Builders map[string]*Totals `json:"builders"` +} + +// RebuiltFrom is the subset of a rebuilt day's provenance this needs. The +// rebuild tool writes more than these fields; unknown keys decode away, and +// nothing here ever rewrites a rebuilt file. +type RebuiltFrom struct { + ExpectedErrorPct float64 `json:"expected_error_pct"` + MorningSharePct float64 `json:"morning_share_pct"` + Extrapolated bool `json:"expected_error_extrapolated"` } // fillEvent mirrors only the fields this reads. The node's record carries a @@ -92,8 +113,14 @@ type blockLine struct { type Reducer struct { FillsDir string OutDir string - Mount string - Backfill int + // RebuiltDir holds days rebuilt from the public export plus the info API, + // for outages the node cannot replay: it fast-forwards instead of + // backfilling, so a gap is permanent locally. Served only for days OutDir + // has no whole day for, and always flagged, because a rebuilt day is not + // a measured one and this harness exists to keep that distinction. + RebuiltDir string + Mount string + Backfill int } // outPath is where a reduced day lands. Gzipped because the trader lists @@ -168,6 +195,15 @@ func (r *Reducer) alreadyWhole(day string) (bool, error) { return hours <= d.Hours, nil } +// hoursPresent counts the hours a day actually holds. +// +// An empty file does not count. The node creates an hour file when the hour +// turns and writes into it as it applies blocks, so a node that is stalled or +// catching up leaves 0-byte files behind: on 2026-10-13 hour 12 was 0 bytes +// while hours 13 to 16 held between 13% and 54% of a normal hour's volume. +// Counting that file made the day report 5 hours of 24 when it held none for +// that hour at all, and coverage is exactly what the consumer uses to decide +// whether to trust this day over the truncated public export. func (r *Reducer) hoursPresent(day string) (int, error) { ents, err := os.ReadDir(dayPath(r.FillsDir, day)) if err != nil { @@ -175,9 +211,13 @@ func (r *Reducer) hoursPresent(day string) (int, error) { } n := 0 for _, e := range ents { - if !e.IsDir() { - n++ + if e.IsDir() { + continue } + if info, err := e.Info(); err == nil && info.Size() == 0 { + continue + } + n++ } return n, nil } @@ -198,6 +238,13 @@ func (r *Reducer) ReduceDay(day string) error { if err != nil { continue } + // Skip empty hours for the same reason hoursPresent does: the file + // exists because the hour turned, not because the node wrote + // anything into it. Counting it inflates the day's coverage, and the + // two counts must agree or alreadyWhole re-reduces for ever. + if info, err := e.Info(); err == nil && info.Size() == 0 { + continue + } hours = append(hours, h) } sort.Ints(hours) @@ -340,7 +387,12 @@ func (r *Reducer) write(d *Day) error { // LoadDay reads a reduced day back. func (r *Reducer) LoadDay(day string) (*Day, error) { - f, err := os.Open(r.outPath(day)) + return r.loadFrom(r.outPath(day)) +} + +// loadFrom reads one reduced or rebuilt day from an explicit path. +func (r *Reducer) loadFrom(path string) (*Day, error) { + f, err := os.Open(path) if err != nil { return nil, err } @@ -357,10 +409,31 @@ func (r *Reducer) LoadDay(day string) (*Day, error) { return &d, nil } -// freeSpace reports the mount's free fraction, 0..1. +// rootMount is the filesystem holding the binaries, the journal and the +// system temp directory. It is watched alongside the data volume because a +// full root stops the node without touching the data volume at all: on +// 2026-10-08 this box's root filled to 100% while /mnt/hyperliquid sat at +// 29%, hl-visor could not stage the 106 MB node binary a hardfork required +// through /tmp, and the node froze at the fork height for three days. Every +// disk signal on the box pointed at the data volume, so nothing fired. +const rootMount = "/" + +// freeSpace reports free and total bytes on the mount holding the node data. func (r *Reducer) freeSpace() (free, total uint64) { + return spaceOn(r.Mount) +} + +// rootSpace reports free and total bytes on the root filesystem. +func (r *Reducer) rootSpace() (free, total uint64) { + return spaceOn(rootMount) +} + +// spaceOn returns zeroes when the path cannot be read, which callers must +// treat as "not measured" rather than "empty": a 0% gauge on an unreadable +// mount is the reassuring lie this whole file exists to avoid. +func spaceOn(path string) (free, total uint64) { var st syscall.Statfs_t - if err := syscall.Statfs(r.Mount, &st); err != nil { + if err := syscall.Statfs(path, &st); err != nil { return 0, 0 } return st.Bavail * uint64(st.Bsize), st.Blocks * uint64(st.Bsize) diff --git a/harnesses/hl-fills-reduce/cmd/script/reduce_test.go b/harnesses/hl-fills-reduce/cmd/script/reduce_test.go index f6c5dd2d3..460e9d67a 100644 --- a/harnesses/hl-fills-reduce/cmd/script/reduce_test.go +++ b/harnesses/hl-fills-reduce/cmd/script/reduce_test.go @@ -1,6 +1,8 @@ package main import ( + "compress/gzip" + "encoding/json" "os" "path/filepath" "testing" @@ -193,6 +195,192 @@ func TestSweepSkipsUnsettledDays(t *testing.T) { } } +// An hour file exists because the hour turned, not because the node wrote +// anything into it. A stalled or catching-up node leaves 0-byte files behind, +// and counting them inflates the day's coverage: on 2026-10-13 hour 12 was +// empty while the node was fetching state, and the day would have claimed an +// hour it held nothing for. Coverage is what the consumer uses to decide +// whether to trust a node day over the truncated public export, so an hour +// that holds nothing must not read as an hour that holds something. +func TestEmptyHourFileIsNotCountedAsAnHour(t *testing.T) { + r := newTestReducer(t) + const day = "20261012" + writeHour(t, r.FillsDir, day, "0", blockWithOneBuilderFill+"\n") + writeHour(t, r.FillsDir, day, "1", "") + writeHour(t, r.FillsDir, day, "2", blockSameTraderAgain+"\n") + + n, err := r.hoursPresent(day) + if err != nil { + t.Fatalf("hoursPresent: %v", err) + } + if n != 2 { + t.Fatalf("hoursPresent counted %d hours; the empty one must not count", n) + } + + if err := r.ReduceDay(day); err != nil { + t.Fatalf("reduce: %v", err) + } + d, err := r.LoadDay(day) + if err != nil { + t.Fatalf("load: %v", err) + } + if d.Hours != 2 { + t.Fatalf("reduced day reports %d hours; want 2, the empty file excluded", d.Hours) + } + // The two counts have to agree, or alreadyWhole compares a file count + // against a reduced count that never matches and re-reduces for ever. + whole, err := r.alreadyWhole(day) + if err != nil { + t.Fatalf("alreadyWhole: %v", err) + } + if !whole { + t.Fatal("alreadyWhole says the day changed; the empty hour is counted inconsistently between the two paths") + } +} + +// The root filesystem is measured separately from the data volume, because a +// healthy data volume is not evidence that the host is healthy. On 2026-10-08 +// this box's root filled to 100% while the data volume sat at 29%, hl-visor +// could not stage the node binary a hardfork required through /tmp, and the +// node froze at the fork height for three days with every disk signal on the +// box reading normal. +func TestRootSpaceIsMeasuredSeparatelyFromTheDataVolume(t *testing.T) { + r := &Reducer{Mount: t.TempDir()} + + if _, rootTotal := r.rootSpace(); rootTotal == 0 { + t.Fatal("rootSpace reported no total bytes for /, so the root filesystem would go unmonitored") + } + // Both mounts are readable here, so both must report. What the pair buys + // is that rootSpace reads / rather than r.Mount: one reading serving both + // is the blind spot these two methods replaced. + if _, dataTotal := r.freeSpace(); dataTotal == 0 { + t.Fatal("freeSpace reported no total bytes for the data mount") + } +} + +// An unreadable mount must report zeroes so callers can tell "not measured" +// from "empty". A 0% gauge on a mount nobody could stat is the reassuring +// lie this harness's alerts exist to avoid, which is why serve.go turns +// these zeroes into -1 rather than publishing 0%. +func TestSpaceOnUnreadablePathReportsZeroNotEmpty(t *testing.T) { + free, total := spaceOn(filepath.Join(t.TempDir(), "no", "such", "mount")) + if free != 0 || total != 0 { + t.Fatalf("spaceOn on a missing path gave free=%d total=%d, want 0 and 0", free, total) + } +} + +// writeRebuilt drops a rebuilt day straight into RebuiltDir, the way the +// rebuild tool does. +func writeRebuilt(t *testing.T, r *Reducer, day string, complete, flagged bool, fees float64) { + t.Helper() + if err := os.MkdirAll(r.RebuiltDir, 0o755); err != nil { + t.Fatal(err) + } + d := &Day{ + Schema: reduceSchema, Day: day, Hours: 24, Complete: complete, Rebuilt: flagged, + Builders: map[string]*Totals{"0xb": {FeesUSD: fees}}, + } + f, err := os.Create(filepath.Join(r.RebuiltDir, day+".json.gz")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + zw := gzip.NewWriter(f) + if err := json.NewEncoder(zw).Encode(d); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } +} + +// A measured day outranks a rebuilt one, a partial measurement loses to a +// rebuilt whole day, and an unflagged file in the rebuilt directory is not +// served at all. +// +// The listing and the fetch have to agree on all three. An earlier version of +// the fetch opened the reduced file whenever it existed, so a day the listing +// announced as a rebuilt whole day was served as the partial measurement +// instead: the consumer would have summed 11 hours while being told it held 24. +func TestRebuiltDaysOnlyFillGapsAndNeverOutrankAMeasuredDay(t *testing.T) { + r := newTestReducer(t) + r.RebuiltDir = filepath.Join(t.TempDir(), "rebuilt") + + // A whole measured day, and a rebuilt one for the same date. + writeHour(t, r.FillsDir, "20261001", "0", blockWithOneBuilderFill+"\n") + for h := 1; h < 24; h++ { + writeHour(t, r.FillsDir, "20261001", itoa(h), blockWithOneBuilderFill+"\n") + } + if err := r.ReduceDay("20261001"); err != nil { + t.Fatalf("reduce: %v", err) + } + writeRebuilt(t, r, "20261001", true, true, 999) + + // A partial measured day, with a rebuilt whole day to replace it. + writeHour(t, r.FillsDir, "20261002", "0", blockWithOneBuilderFill+"\n") + if err := r.ReduceDay("20261002"); err != nil { + t.Fatalf("reduce: %v", err) + } + writeRebuilt(t, r, "20261002", true, true, 777) + + // A rebuilt day with no measurement at all. + writeRebuilt(t, r, "20261003", true, true, 555) + + // A file in the rebuilt directory that forgot the flag. + writeRebuilt(t, r, "20261004", true, false, 111) + + got := map[string]dayInfo{} + for _, d := range r.availableDays() { + got[d.Day] = d + } + + if d := got["20261001"]; d.Rebuilt || !d.Complete { + t.Fatalf("20261001: measured whole day must win, got rebuilt=%v complete=%v", d.Rebuilt, d.Complete) + } + if d := got["20261002"]; !d.Rebuilt || !d.Complete || d.Hours != 24 { + t.Fatalf("20261002: rebuilt whole day must replace the 1-hour measurement, got %+v", d) + } + if d := got["20261003"]; !d.Rebuilt { + t.Fatalf("20261003: rebuilt day with no measurement must be offered, got %+v", d) + } + if _, ok := got["20261004"]; ok { + t.Fatal("20261004: an unflagged file in the rebuilt directory must not be served") + } + + // And what the fetch path would open has to match what the listing said. + if p := r.servePathFor("20261001"); p != r.outPath("20261001") { + t.Fatalf("20261001 should be served from the reduced dir, got %s", p) + } + if p := r.servePathFor("20261002"); p != filepath.Join(r.RebuiltDir, "20261002.json.gz") { + t.Fatalf("20261002 should be served from the rebuilt dir, got %s", p) + } +} + +// A rebuilt day states how far short it is expected to fall. A file without +// that field would otherwise report 0, which reads as a perfect reconstruction +// and is the most flattering possible lie: the figure would be published as if +// it were measured. Absent means "assume the worst case we have measured". +func TestRebuiltDayWithNoStatedErrorIsNotTreatedAsPerfect(t *testing.T) { + stated := rebuiltInfo("20261013", &Day{ + Hours: 24, Complete: true, Rebuilt: true, + RebuiltFrom: &RebuiltFrom{ExpectedErrorPct: -0.5, MorningSharePct: 68.7}, + }) + if stated.ExpectedErrorPct != -0.5 || stated.Extrapolated { + t.Fatalf("a stated error must carry through, got %+v", stated) + } + + silent := rebuiltInfo("20261014", &Day{Hours: 24, Complete: true, Rebuilt: true}) + if silent.ExpectedErrorPct == 0 { + t.Fatal("a rebuilt day with no stated error reported 0, which reads as measured") + } + if !silent.Extrapolated { + t.Fatal("a guessed error must be flagged as not measured") + } + if silent.ExpectedErrorPct > -28.0 { + t.Fatalf("the fallback should be the worst case measured, got %.1f", silent.ExpectedErrorPct) + } +} + func keys(m map[string]*Totals) []string { out := make([]string, 0, len(m)) for k := range m { diff --git a/harnesses/hl-fills-reduce/cmd/script/serve.go b/harnesses/hl-fills-reduce/cmd/script/serve.go index 2236ca549..d28fe714d 100644 --- a/harnesses/hl-fills-reduce/cmd/script/serve.go +++ b/harnesses/hl-fills-reduce/cmd/script/serve.go @@ -32,7 +32,7 @@ func serve(addr string, r *Reducer) error { http.Error(w, "bad day", http.StatusBadRequest) return } - f, err := os.Open(r.outPath(day)) + f, err := os.Open(r.servePathFor(day)) if err != nil { http.Error(w, "not reduced", http.StatusNotFound) return @@ -53,7 +53,8 @@ func serve(addr string, r *Reducer) error { if i > 0 { fmt.Fprint(w, ",") } - fmt.Fprintf(w, `{"day":%q,"hours":%d,"complete":%t}`, d.Day, d.Hours, d.Complete) + fmt.Fprintf(w, `{"day":%q,"hours":%d,"complete":%t,"rebuilt":%t,"expected_error_pct":%.1f,"expected_error_extrapolated":%t}`, + d.Day, d.Hours, d.Complete, d.Rebuilt, d.ExpectedErrorPct, d.Extrapolated) } fmt.Fprint(w, "]") }) @@ -70,37 +71,128 @@ func serve(addr string, r *Reducer) error { return srv.ListenAndServe() } +// servePathFor is which file /daily/ hands over, and it must follow the +// same rule availableDays lists by: a WHOLE measured day wins, a partial +// measured day loses to a rebuilt whole one, and with no rebuilt day the +// partial measurement is still the best there is. +// +// Shared rather than duplicated on purpose. The first version inlined this in +// the handler and opened the reduced file whenever it existed, so a day the +// listing announced as a rebuilt whole day was served as the partial +// measurement: the consumer would have summed 11 hours believing it held 24, +// which is the exact class of quiet wrong answer this harness exists to stop. +func (r *Reducer) servePathFor(day string) string { + reducedPath := r.outPath(day) + if r.RebuiltDir == "" { + return reducedPath + } + if d, err := r.LoadDay(day); err == nil && d.Complete { + return reducedPath + } + cand := filepath.Join(r.RebuiltDir, day+".json.gz") + if d, err := r.loadFrom(cand); err == nil && d.Rebuilt { + return cand + } + return reducedPath +} + type dayInfo struct { Day string Hours int Complete bool + // Rebuilt marks a day assembled from the public export and the per-wallet + // info API rather than measured by the node. Consumers must be able to + // tell the two apart: the figures are close (-0.43% on the 2026-10-09 + // validation) but they are a reconstruction, and a reconstruction that + // reads as a measurement is the failure this harness was written against. + Rebuilt bool + // ExpectedErrorPct is how far short a rebuilt day is expected to fall, and + // 0 for a measured one. Carried in the listing so a consumer can weigh the + // window without fetching every day. + ExpectedErrorPct float64 + Extrapolated bool } -func (r *Reducer) availableDays() []dayInfo { - ents, err := os.ReadDir(r.OutDir) - if err != nil { - return nil +// rebuiltInfo builds the listing entry for a rebuilt day, expected shortfall +// included. A rebuilt day with no stated error reads as a perfect one, so a +// file missing the field is reported as the worst case measured rather than as +// zero. +func rebuiltInfo(day string, d *Day) dayInfo { + info := dayInfo{Day: day, Hours: d.Hours, Complete: d.Complete, Rebuilt: true} + if d.RebuiltFrom != nil && d.RebuiltFrom.ExpectedErrorPct != 0 { + info.ExpectedErrorPct = d.RebuiltFrom.ExpectedErrorPct + info.Extrapolated = d.RebuiltFrom.Extrapolated + } else { + info.ExpectedErrorPct = -28.2 + info.Extrapolated = true } - var out []dayInfo - for _, e := range ents { - n := e.Name() - if !strings.HasSuffix(n, ".json.gz") { - continue + return info +} + +// availableDays lists the reduced days, then fills the gaps with rebuilt ones. +// +// A measured day always wins. A rebuilt day is offered only where the node has +// no whole day, so restoring the node never silently keeps a reconstruction in +// service, and a rebuilt day never displaces a real one. +func (r *Reducer) availableDays() []dayInfo { + out := make([]dayInfo, 0, 64) + whole := map[string]bool{} + + if ents, err := os.ReadDir(r.OutDir); err == nil { + for _, e := range ents { + n := e.Name() + if !strings.HasSuffix(n, ".json.gz") { + continue + } + d, err := r.LoadDay(strings.TrimSuffix(n, ".json.gz")) + if err != nil { + continue + } + out = append(out, dayInfo{Day: d.Day, Hours: d.Hours, Complete: d.Complete}) + if d.Complete { + whole[d.Day] = true + } } - day := strings.TrimSuffix(n, ".json.gz") - d, err := r.LoadDay(day) - if err != nil { - continue + } + + if r.RebuiltDir != "" { + if ents, err := os.ReadDir(r.RebuiltDir); err == nil { + for _, e := range ents { + n := e.Name() + if !strings.HasSuffix(n, ".json.gz") { + continue + } + day := strings.TrimSuffix(n, ".json.gz") + if whole[day] { + continue // the node measured this one; keep the measurement + } + d, err := r.loadFrom(filepath.Join(r.RebuiltDir, n)) + if err != nil || !d.Rebuilt { + continue // unflagged files in this directory are not trusted + } + // Replace the partial reduced entry for the same day, if any. + replaced := false + for i := range out { + if out[i].Day == day { + out[i] = rebuiltInfo(day, d) + replaced = true + break + } + } + if !replaced { + out = append(out, rebuiltInfo(day, d)) + } + } } - out = append(out, dayInfo{Day: d.Day, Hours: d.Hours, Complete: d.Complete}) } + sort.Slice(out, func(i, j int) bool { return out[i].Day < out[j].Day }) return out } func (r *Reducer) writeMetrics(w http.ResponseWriter) { days := r.availableDays() - whole, partial := 0, 0 + whole, partial, rebuilt := 0, 0, 0 newest := "" for _, d := range days { if d.Complete { @@ -108,6 +200,9 @@ func (r *Reducer) writeMetrics(w http.ResponseWriter) { } else { partial++ } + if d.Rebuilt { + rebuilt++ + } if d.Day > newest { newest = d.Day } @@ -129,6 +224,16 @@ func (r *Reducer) writeMetrics(w http.ResponseWriter) { usedPct = float64(total-free) / float64(total) * 100 } + // The root filesystem, separately. A healthy data volume says nothing + // about the one holding the binaries, the journal and /tmp, and that is + // the one that took this node down for three days in October 2026. + // -1 when unreadable, so "not measured" cannot be read as "empty". + rootFree, rootTotal := r.rootSpace() + rootUsedPct := -1.0 + if rootTotal > 0 { + rootUsedPct = float64(rootTotal-rootFree) / float64(rootTotal) * 100 + } + // The node's own liveness, read from the hourly tree rather than from // systemd: a node that is "active" but not writing fills is the failure // mode that matters here, and it looks healthy to systemctl. @@ -142,6 +247,9 @@ func (r *Reducer) writeMetrics(w http.ResponseWriter) { # TYPE hl_reduce_days_total gauge hl_reduce_days_total{complete="true"} %d hl_reduce_days_total{complete="false"} %d +# HELP hl_reduce_rebuilt_days_total Days served from the rebuild directory rather than measured by the node. Assembled from the public export plus the per-wallet info API, and close but not measurements: the 2026-10-09 validation came out 0.43 per cent under the node. A window leaning on these is a window to say so about. Note for editors: a literal percent sign here is a format verb to Go and silently eats the next argument. +# TYPE hl_reduce_rebuilt_days_total gauge +hl_reduce_rebuilt_days_total %d # HELP hl_reduce_newest_day_age_seconds Age of the newest reduced day, measured from the end of that UTC day. Negative when nothing is reduced. # TYPE hl_reduce_newest_day_age_seconds gauge hl_reduce_newest_day_age_seconds %.0f @@ -154,10 +262,16 @@ hl_reduce_disk_used_pct %.1f # HELP hl_reduce_disk_free_bytes Free space on the filesystem holding the node data. # TYPE hl_reduce_disk_free_bytes gauge hl_reduce_disk_free_bytes %d +# HELP hl_reduce_root_disk_used_pct Used space on the root filesystem, which holds the node binaries, the journal and /tmp. A full root stops the node while the data volume still looks healthy. -1 when unreadable. +# TYPE hl_reduce_root_disk_used_pct gauge +hl_reduce_root_disk_used_pct %.1f +# HELP hl_reduce_root_disk_free_bytes Free space on the root filesystem. +# TYPE hl_reduce_root_disk_free_bytes gauge +hl_reduce_root_disk_free_bytes %d # HELP hl_reduce_last_scrape_unix Unix time this endpoint was last read. # TYPE hl_reduce_last_scrape_unix gauge hl_reduce_last_scrape_unix %d -`, whole, partial, newestAge, lastHourAge, usedPct, free, time.Now().Unix()) +`, whole, partial, rebuilt, newestAge, lastHourAge, usedPct, free, rootUsedPct, rootFree, time.Now().Unix()) } // newestHourFile finds the most recently written hourly fill file. diff --git a/harnesses/hl-fills-reduce/deploy/ocb_hl_node_box.yml b/harnesses/hl-fills-reduce/deploy/ocb_hl_node_box.yml index 9f797549b..29dd5351c 100644 --- a/harnesses/hl-fills-reduce/deploy/ocb_hl_node_box.yml +++ b/harnesses/hl-fills-reduce/deploy/ocb_hl_node_box.yml @@ -30,6 +30,41 @@ groups: summary: "HL node box disk at {{ $value | printf \"%.0f\" }}%, above the cleanup ceiling" description: "hl-cleanup enforces 85% and is not succeeding. A full disk stops hl-node. Check `journalctl -u hl-cleanup.service` first: in the 2026-10-07 incident it had been exiting non-zero every 15 minutes." + # The root filesystem, which the two rules above do not cover: they read + # the data volume. On 2026-10-08 this box's root filled to 100% while + # /mnt/hyperliquid sat at 29%, held by a 185 GB deleted-but-open trace + # file a container had kept open since August. hl-visor stages new node + # binaries through /tmp, which lives on the root, so it could not write + # the 106 MB binary a hardfork required and the node froze at the fork + # height for three days. Every disk signal on this box pointed at the + # data volume, so none of them moved. + - alert: HlNodeRootDiskFilling + expr: hl_reduce_root_disk_used_pct > 85 + for: 30m + labels: { severity: warning } + annotations: + summary: "HL node box root filesystem at {{ $value | printf \"%.0f\" }}%" + description: "The root holds the node binaries, the journal and /tmp. Nothing prunes it, unlike the data volume. Deleted-but-open files are invisible to du and are the usual cause: `sudo lsof -nP +L1 | sort -k8 -rn | head`, then truncate the descriptor rather than restarting the holder." + + - alert: HlNodeRootDiskCritical + expr: hl_reduce_root_disk_used_pct > 93 + for: 10m + labels: { severity: critical } + annotations: + summary: "HL node box root filesystem at {{ $value | printf \"%.0f\" }}%, node upgrades will fail" + description: "At 100% hl-visor cannot stage a new node binary through /tmp and the node freezes at the next hardfork while systemd still calls it active. This is the 2026-10-08 failure." + + # -1 is the gauge's \"could not read the mount\" value. A disk that is + # not being measured must not look like a disk that is empty, which is + # the mistake every other rule in this file was written against. + - alert: HlNodeRootDiskUnmeasured + expr: hl_reduce_root_disk_used_pct < 0 + for: 1h + labels: { severity: warning } + annotations: + summary: "HL node box root filesystem is not being measured" + description: "statfs on / failed in hl-fills-reduce, so the root disk is unmonitored. The node can fill its root and freeze with no signal." + # systemd can report hyperliquid.service active while the node has # stopped applying blocks, so liveness is read from the data it writes. # Hours land on the hour, so 2h is one missed file plus slack. diff --git a/harnesses/hyperliquid-frontends/cmd/script/agg.go b/harnesses/hyperliquid-frontends/cmd/script/agg.go index 7e396fb09..518e2cb8e 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/agg.go +++ b/harnesses/hyperliquid-frontends/cmd/script/agg.go @@ -527,19 +527,31 @@ func (a *Aggregator) publish() { // truncation count. if a.node != nil { whole := a.node.WholeDays() + rebuilt := a.node.RebuiltDays() keep := map[string]bool{} - fromNode := 0 + fromNode, fromRebuilt := 0, 0 for d := windowStart; !d.After(D); d = d.AddDate(0, 0, 1) { k := dayKey(d) keep[k] = true if whole[k] { fromNode++ } + // Counted apart from fromNode, never added to it. A rebuilt day + // beats the truncated export by a wide margin but it is a + // reconstruction, and the node-days figure is what tells a reader + // how much of the window was actually measured. + if rebuilt[k] { + fromRebuilt++ + } } hlNodeDaysWindow.Set(float64(fromNode)) + hlRebuiltDaysWindow.Set(float64(fromRebuilt)) + hlRebuiltWorstError.Set(a.node.WorstRebuiltErrorPct(keep)) a.node.Evict(keep) } else { hlNodeDaysWindow.Set(0) + hlRebuiltDaysWindow.Set(0) + hlRebuiltWorstError.Set(0) } // Second pass: the biggest day needs to know which days were cut short, diff --git a/harnesses/hyperliquid-frontends/cmd/script/metrics.go b/harnesses/hyperliquid-frontends/cmd/script/metrics.go index e99cc46d0..3232daea6 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/metrics.go +++ b/harnesses/hyperliquid-frontends/cmd/script/metrics.go @@ -53,6 +53,14 @@ var ( Name: "hl_frontend_node_days_window_v2", Help: "Days in the 30-day window read from the Hyperliquid node's own fill stream rather than the public per-builder export. The export cuts most days off around 12:10 UTC; a day sourced here is whole. The window is only as good as this number is close to 30.", }) + hlRebuiltDaysWindow = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_frontend_rebuilt_days_window_v2", + Help: "Days in the 30-day window served as reconstructions rather than measured: the public export's own morning spliced to an afternoon rebuilt from the per-wallet info API, for outages the node cannot replay. Validated at 0.43 per cent under the node on 2026-10-09, so these are close, but they are not measurements and are never counted in hl_frontend_node_days_window_v2.", + }) + hlRebuiltWorstError = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_frontend_rebuilt_worst_error_pct_v2", + Help: "Largest expected shortfall among the rebuilt days in the window, as a negative percent, and 0 when none are rebuilt. A rebuilt day's accuracy follows how much of it the public export's morning covered: two complete days scored against the node gave -0.43 at a 68.8 percent morning and -28.2 at 27.2. The worst rather than the mean, because averaging a -0.5 day with a -35 one describes neither.", + }) hlCoverageDaysMeasured = promauto.NewGauge(prometheus.GaugeOpts{ Name: "hl_frontend_coverage_days_measured_v2", Help: "Days in the 30-day window whose feed coverage was actually measured. The truncated-day count is out of this, not out of 30: a day that left the mirror window before this harness first ran was never measured, and counting it as clean would understate the truncation.", diff --git a/harnesses/hyperliquid-frontends/cmd/script/nodesource.go b/harnesses/hyperliquid-frontends/cmd/script/nodesource.go index e2190f193..fa00f65bb 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/nodesource.go +++ b/harnesses/hyperliquid-frontends/cmd/script/nodesource.go @@ -43,6 +43,18 @@ type nodeDayIndex struct { Day string `json:"day"` Hours int `json:"hours"` Complete bool `json:"complete"` + // Rebuilt marks a day the reducer assembled from the public export plus + // the per-wallet info API instead of measuring it. Such a day is used, + // because it beats the export alone by a wide margin, but it is counted + // apart: "read from the node" and "reconstructed" are different claims + // and the page makes both. + Rebuilt bool `json:"rebuilt"` + // ExpectedErrorPct is how far short a rebuilt day is expected to fall, + // negative, and 0 on a measured day. Reported rather than averaged away: + // the shortfall ranges from -0.5 to -35 per cent across the rebuilt days, + // so a single figure for the window would be meaningless. + ExpectedErrorPct float64 `json:"expected_error_pct"` + Extrapolated bool `json:"expected_error_extrapolated"` } type nodeUserAgg struct { @@ -67,6 +79,7 @@ type nodeDay struct { Day string `json:"day"` Hours int `json:"hours"` Complete bool `json:"complete"` + Rebuilt bool `json:"rebuilt"` Builders map[string]*nodeTotals `json:"builders"` } @@ -168,12 +181,51 @@ func (n *NodeSource) Refresh() error { } // WholeDays is the set of days the node covered end to end. +// WorstRebuiltErrorPct is the largest expected shortfall among the rebuilt +// days in the window, as a negative percent, and 0 when none are rebuilt. The +// worst rather than the mean, because what a reader needs is how wrong the +// window could be, and averaging a -0.5 per cent day with a -35 per cent one +// describes neither. +func (n *NodeSource) WorstRebuiltErrorPct(window map[string]bool) float64 { + n.mu.Lock() + defer n.mu.Unlock() + worst := 0.0 + for d, e := range n.index { + if !e.Complete || !e.Rebuilt || !window[d] { + continue + } + if e.ExpectedErrorPct < worst { + worst = e.ExpectedErrorPct + } + } + return worst +} + +// RebuiltDays is the days served as reconstructions. Disjoint from +// WholeDays, so a caller can report each without the other. +func (n *NodeSource) RebuiltDays() map[string]bool { + n.mu.Lock() + defer n.mu.Unlock() + out := make(map[string]bool, len(n.index)) + for d, e := range n.index { + if e.Complete && e.Rebuilt { + out[d] = true + } + } + return out +} + +// WholeDays is the days the node measured in full. Rebuilt days are excluded +// deliberately: this feeds hl_frontend_node_days_window_v2, whose help text +// promises days "read from the Hyperliquid node's own fill stream", and a +// reconstruction counted there would quietly inflate the one number that says +// how much of the window is real. func (n *NodeSource) WholeDays() map[string]bool { n.mu.Lock() defer n.mu.Unlock() out := make(map[string]bool, len(n.index)) for d, e := range n.index { - if e.Complete { + if e.Complete && !e.Rebuilt { out[d] = true } } diff --git a/harnesses/perp-cohort-stats/cmd/script/adapter.go b/harnesses/perp-cohort-stats/cmd/script/adapter.go index fc252c165..a153a496d 100644 --- a/harnesses/perp-cohort-stats/cmd/script/adapter.go +++ b/harnesses/perp-cohort-stats/cmd/script/adapter.go @@ -134,6 +134,8 @@ const ( srcStandXNative = "standx_native" srcApexNative = "apex_native" srcJupiterNative = "jupiter_native" + srcPapertradeNative = "papertrade_native" + srcLeverageTerms = "leverage_terms" srcLighterRHNative = "lighter_rh_native" srcHLXyzNative = "hl_xyz_native" srcCexCoinGecko = "cex_coingecko" @@ -195,6 +197,11 @@ func priorityMap(venue, metric string) []string { return []string{srcOrderlyNative} case "backpack": return []string{srcBackpackNative} + case "papertrade": + // DefiLlama's derivatives volume endpoint is paid (402), and the + // venue publishes everything we need itself, so the native API is + // the only source rather than the preferred one. + return []string{srcPapertradeNative} case "kalshi": return []string{srcKalshiNative} case "vest": @@ -288,6 +295,10 @@ func priorityMap(venue, metric string) []string { case "vest": // The Vest API publishes no open interest. return nil + case "papertrade": + // Sum of the four side columns: every position faces the house, + // so long and short add rather than net off. + return []string{srcPapertradeNative} } case mFees30d: switch venue { @@ -358,6 +369,8 @@ func priorityMap(venue, metric string) []string { return []string{srcHLXyzNative} case "binance", "okx", "bybit", "gate", "coinbase", "bitget", "kraken", "mexc": return []string{srcCexCoinGecko} + case "papertrade": + return []string{srcPapertradeNative} } case mTopVol24h: switch venue { @@ -416,6 +429,10 @@ func priorityMap(venue, metric string) []string { "lighter", "paradex", "edgex", "aster", "nado", "grvt", "extended", "aevo", "pacifica": return []string{srcDefillama} + case "papertrade": + // Publishes its own TVL, and DefiLlama's derivatives endpoint is + // paid, so the native source is the only one. + return []string{srcPapertradeNative} } } return nil @@ -529,6 +546,8 @@ func NewRouter(cfg *Config) *Router { NewKiloExNativeSource(), NewOrderlyNativeSource(), NewBackpackNativeSource(), + NewPapertradeNativeSource(), + NewLeverageTermsSource(), NewOndoNativeSource(), NewKalshiNativeSource(), NewVestNativeSource(), diff --git a/harnesses/perp-cohort-stats/cmd/script/metrics.go b/harnesses/perp-cohort-stats/cmd/script/metrics.go index 48a9e6721..0c0f41b42 100644 --- a/harnesses/perp-cohort-stats/cmd/script/metrics.go +++ b/harnesses/perp-cohort-stats/cmd/script/metrics.go @@ -80,6 +80,54 @@ var ( }, []string{"venue"}, ) + // High-leverage terms ============================================== + // Governance parameters rather than sampled measurements, read fresh + // each tick. Keyed by market as well as venue because the comparison + // only means anything on the same asset, and the only assets two + // high-leverage venues both list are BTC and ETH. + // + // Two label names here are the site's, not ours. The site injects a + // fixed set of dimension labels into a bench's queries (chain, region, + // kind, venue, bucket, amount_usd, tier), so the market rides in `kind` + // and the leverage setting in `bucket`. Naming them `market` and + // `leverage` would mean teaching the spec schema two new dimensions for + // one bench, and a dimension the site cannot inject leaves every + // selector matching many series, which renders as an empty page with no + // error anywhere. + // + // `bucket="all"` is the venue's OWN maximum, not a pooled series. The + // site replaces a selector pinned to "all" with the chosen value, so the + // spec pins bucket="all" and the unfiltered page reads as the + // max-leverage view while every other tab is a setting shared across + // venues. + perpLeverageMax = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_leverage_max", + Help: "Maximum leverage a venue allows on a market, repeated at every bucket so the column reads the same on each leverage tab. Source: Gains pairMaxLeverage on the Arbitrum diamond, Papertrade /state/trading instrument tuple.", + }, + []string{"venue", "kind", "bucket"}, + ) + perpLiqPriceMovePct = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_liq_price_move_pct", + Help: "Adverse price move, in percent, that liquidates a position opened at the leverage in `bucket` (`all` = the venue maximum), measured at open and excluding carry. Computed as 1/leverage minus the venue maintenance requirement as a share of notional. Gains: liquidation threshold divided by leverage. Papertrade: the bust buffer, which is already a share of price and does not vary with leverage.", + }, + []string{"venue", "kind", "bucket"}, + ) + perpLiqCollateralLostPct = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_liq_collateral_lost_pct", + Help: "Share of the collateral, in percent, already lost when the position is liquidated at the leverage in `bucket`. The same measurement as perp_liq_price_move_pct in the unit venues quote among themselves: room times leverage.", + }, + []string{"venue", "kind", "bucket"}, + ) + perpLiqCollateralKeptPct = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_liq_collateral_kept_pct", + Help: "Share of collateral, in percent, returned to the trader when a position opened at the leverage in `bucket` is liquidated. Maintenance requirement times leverage. Gains: 100 minus the liquidation threshold. Papertrade: zero, crossing the bust price forfeits the whole margin.", + }, + []string{"venue", "kind", "bucket"}, + ) perpVenueHealth = prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "perp_venue_health", @@ -152,6 +200,7 @@ func init() { perpVenueFees30dUsd, perpVenueActiveMarkets, perpVenueTopMarketVolume24hUsd, perpVenueMarketsByClass, perpVenueNoncoreMarketsTotal, perpVenueTvlUsd, + perpLeverageMax, perpLiqPriceMovePct, perpLiqCollateralLostPct, perpLiqCollateralKeptPct, perpVenueHealth, perpVenueFunding24hBps, perpVenueFundingIntervalHours, perpVenueFundingRefreshUnix, perpVenueLastRefreshUnix, perpCohortFetchErrors, perpCohortSourceUsed, perpCohortDataDivergence, diff --git a/harnesses/perp-cohort-stats/cmd/script/registry.go b/harnesses/perp-cohort-stats/cmd/script/registry.go index 8477047e7..e05fdaf18 100644 --- a/harnesses/perp-cohort-stats/cmd/script/registry.go +++ b/harnesses/perp-cohort-stats/cmd/script/registry.go @@ -57,6 +57,9 @@ var Registry = []Venue{ {Slug: "standx", Name: "StandX", Type: "perp", Chain: "bnb"}, {Slug: "apex", Name: "ApeX Omni", Type: "perp", Chain: "omni"}, {Slug: "jupiter", Name: "Jupiter Perps", Type: "perp", Chain: "solana"}, + // Papertrade: synthetic perps on Hyperliquid, launched 2026-10-10, two + // markets (BTC, ETH) at up to 1000x against a single house LP. + {Slug: "papertrade", Name: "Papertrade", Type: "perp", Chain: "hyperliquid"}, // Lighter's Robinhood deployment: separate books and API host. {Slug: "lighter-rh", Name: "Lighter RH", Type: "perp", Chain: "lighter-rh"}, // trade.xyz is the operator of Hyperliquid's `xyz` HIP-3 dex. diff --git a/harnesses/perp-cohort-stats/cmd/script/source_leverage_terms.go b/harnesses/perp-cohort-stats/cmd/script/source_leverage_terms.go new file mode 100644 index 000000000..c75993798 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_leverage_terms.go @@ -0,0 +1,556 @@ +package main + +import ( + "encoding/json" + "fmt" + "math/big" + "net/http" + "strconv" + "strings" + "time" +) + +// LeverageTermsSource publishes what a trader actually faces at a chosen +// leverage on a given market: how far price has to move against the position +// before it is liquidated, and how much collateral comes back when it is. +// Four venues, two markets, seven leverage settings. +// +// THE MODEL. Every venue reduces to one number, the maintenance requirement m +// expressed as a share of NOTIONAL, and then: +// +// room to liquidation = 1/L - m (a share of price) +// collateral returned = m * L (a share of the margin posted) +// +// because a position at leverage L posts 1/L of notional as margin and is +// liquidated when its equity falls to m. The venues differ only in what sets +// m, and that difference is the whole point of the bench: +// +// dYdX m = maintenanceMarginFraction, published per market +// Hyperliquid m = 1/(2*maxLeverage), the documented halving rule +// Gains m = (1 - threshold)/L, so the threshold is a share of +// COLLATERAL and the room comes out as threshold/L +// Papertrade m = bustBuffer, a fixed share of PRICE +// +// Gains' m shrinks with leverage, which keeps the room proportional to it. +// Papertrade's m does not move at all, so it eats a fixed 5 basis points of +// room at every leverage: negligible at 10x, roughly half the margin at 1000x. +// +// THE PAPERTRADE EXCEPTION. Everywhere else m*L is what the trader gets back. +// On Papertrade it is not: their docs call crossing the bust price a "hard +// bust" which "forfeits the full isolated margin, even if a voluntary close +// immediately beforehand could return some equity". So the returned share is +// published as a real zero, and the m*L a voluntary close would have returned +// is a nuance the bench text carries rather than a column. +// +// Reading that buffer as the room itself, rather than as an offset before the +// zero-equity price, understates the room and is wrong: their own docs work +// the 1000x case and get "a move of about 0.052%", which is 1/1000 minus the +// buffer, not the buffer. +type LeverageTermsSource struct { + client *http.Client +} + +func NewLeverageTermsSource() *LeverageTermsSource { + return &LeverageTermsSource{client: &http.Client{Timeout: 25 * time.Second}} +} + +func (s *LeverageTermsSource) Name() string { return srcLeverageTerms } + +const ( + gainsArbRPC = "https://arbitrum-one-rpc.publicnode.com" + gainsDiamondArb = "0xFF162c694eAA571f685030649814282eA457f169" + // First four bytes of the keccak-256 of the signature, hardcoded so the + // harness needs no hashing dependency. + selPairMaxLev = "0x281b693c" // pairMaxLeverage(uint256), 1e3 + selPairLiqParam = "0x6633ced6" // getPairLiquidationParams(uint256) + selPairs = "0xb91ac788" // pairs(uint256), word 9 is the fee index + selFees = "0x4acc79ed" // fees(uint256), word 0 is the size fee at 1e10 + + leverageTermsUA = "OpenChainBench-PerpCohort/1.0 contact@openchainbench.com" +) + +// gainsDegenPairs maps the market label to its pair index on the diamond. These +// are the x500 cohort, deliberately not the standard BTC/USD and ETH/USD pairs +// which cap at 200x. +var gainsDegenPairs = map[string]int64{"BTC": 300, "ETH": 313} + +// leverageTermsMarkets are the only two assets all four venues list, which is +// what makes the rows comparable at all. +var leverageTermsMarkets = map[string]bool{"BTC": true, "ETH": true} + +// leverageBuckets are the settings the page's selector offers. The label value +// "all" is the venue's OWN maximum rather than a pooled series: the site +// replaces a selector pinned to "all" with the chosen value, so pinning +// bucket="all" in the spec's queries makes the unfiltered page the max-leverage +// view and every other tab a fixed setting shared across venues. +var leverageBuckets = []struct { + label string + lev float64 // 0 means "the venue's maximum" +}{ + {"all", 0}, + {"10x", 10}, + {"25x", 25}, + {"50x", 50}, + {"100x", 100}, + {"500x", 500}, + {"1000x", 1000}, +} + +// venueTerms is one venue's parameters on one market, reduced to the model +// above. maintenance returns the requirement as a share of notional at the +// given leverage; it takes the leverage because Gains' threshold interpolates +// with it while the other three are flat. +type venueTerms struct { + venue string + market string + maxLev float64 + // maintenance is the requirement as a share of notional at that leverage. + maintenance func(lev float64) float64 + // costPerLeg is the venue's fee as a share of notional, charged on the way + // in and again on the way out. It sits INSIDE the liquidation trigger: a + // position pays it out of the same collateral the threshold is measured + // against, so the price does not have to travel as far. Measured on Gains: + // 11 liquidations at 500x fired at a 59.4% price loss against an 80% + // threshold, and 0.02% x 500 x 2 is the 20 point gap. + costPerLeg float64 + // forfeitsAll: nothing comes back whatever the arithmetic leaves. + forfeitsAll bool +} + +func (s *LeverageTermsSource) Fetch() (*SourceResult, error) { + // Each venue is independent and publishes its own gauges, so a venue that + // is down costs its own rows and nothing else. The result is empty on + // purpose: the router's value map is keyed by (venue, metric) and cannot + // express a per-market, per-leverage figure, so these three gauges are + // written directly. Nothing here feeds a bench the router serves. + res := newSourceResult() + // Clear every series before republishing. A gauge keeps its last value + // forever, so a venue that lowered its maximum leverage would keep a row + // on the tabs it no longer offers, at 100% success, contradicting the + // bench's own rule that a venue below the selected setting is absent. + perpLeverageMax.Reset() + perpLiqPriceMovePct.Reset() + perpLiqCollateralLostPct.Reset() + perpLiqCollateralKeptPct.Reset() + + var all []venueTerms + all = append(all, s.fetchGains()...) + all = append(all, s.fetchPapertrade()...) + all = append(all, s.fetchHyperliquid()...) + all = append(all, s.fetchDydx()...) + for _, t := range all { + publishLeverageTerms(t) + } + return res, nil +} + +// publishLeverageTerms writes one venue's three gauges at every leverage the +// venue allows. A single write path, so the three are always set together: a +// venue that published a max leverage but no room would read as the safest row +// on the board. +func publishLeverageTerms(t venueTerms) { + for _, b := range leverageBuckets { + lev := b.lev + if lev == 0 { + lev = t.maxLev + } + if lev > t.maxLev { + continue // the venue does not offer this setting + } + room, lost, kept, ok := termsAt(t, lev) + if !ok { + perpCohortFetchErrors.WithLabelValues(t.venue, srcLeverageTerms, "parse").Inc() + continue + } + perpLeverageMax.WithLabelValues(t.venue, t.market, b.label).Set(t.maxLev) + perpLiqPriceMovePct.WithLabelValues(t.venue, t.market, b.label).Set(room) + perpLiqCollateralLostPct.WithLabelValues(t.venue, t.market, b.label).Set(lost) + perpLiqCollateralKeptPct.WithLabelValues(t.venue, t.market, b.label).Set(kept) + if b.label == "all" { + fmt.Printf("[perp-cohort][%s][%s] %s at max %.0fx: room=%.4f%% lost=%.1f%% kept=%.1f%%\n", + t.venue, srcLeverageTerms, t.market, t.maxLev, room, lost, kept) + } + } +} + +// termsAt is the arithmetic of the model, kept separate from the gauge writes +// so it can be checked against each venue's own worked example. +// +// A position at leverage L posts 1/L of notional and is liquidated when its +// equity reaches the maintenance requirement m. Both fee legs come out of that +// same collateral, so the price has that much less distance to travel: +// +// room = 1/L - m - 2*cost (a share of price) +// lost = room * L (a share of the collateral) +// kept = 1 - lost (zero where the venue forfeits all) +// +// Checked against Gains: threshold 80% at 500x with a 0.02% fee gives +// (0.80 - 2*0.0002*500)/500 = 0.1400% of price and 70% of collateral, against a +// measured median of 59.4% of collateral. The remaining gap is the borrowing +// fee, which accrues with holding time and is not a term at open. +func termsAt(t venueTerms, lev float64) (roomPct, lostPct, keptPct float64, ok bool) { + if lev <= 0 || lev > t.maxLev { + return 0, 0, 0, false + } + m := t.maintenance(lev) + if m <= 0 { + return 0, 0, 0, false + } + room := (1/lev - m - 2*t.costPerLeg/100) * 100 + if room <= 0 { + // The fees alone exhaust the margin, so the position cannot be opened + // at this leverage on these terms. Refuse rather than publish a + // negative or a zero that would read as "liquidated instantly". + return 0, 0, 0, false + } + lost := room * lev + if lost <= 0 || lost > 100 { + return 0, 0, 0, false + } + if t.forfeitsAll { + return room, lost, 0, true + } + return room, lost, 100 - lost, true +} + +// ---------------------------------------------------------------- Gains + +func (s *LeverageTermsSource) fetchGains() []venueTerms { + var out []venueTerms + for market, idx := range gainsDegenPairs { + lw, err := s.gainsCall(selPairMaxLev, idx) + if err != nil || len(lw) < 1 { + perpCohortFetchErrors.WithLabelValues("gains", srcLeverageTerms, classifyError(fmt.Sprint(err))).Inc() + fmt.Printf("[perp-cohort][gains][%s] maxLeverage %s: %v\n", srcLeverageTerms, market, err) + continue + } + maxLev := bigToFloat(lw[0], 1e3) + if maxLev <= 0 { + continue + } + p, err := s.gainsCall(selPairLiqParam, idx) + if err != nil || len(p) < 5 { + perpCohortFetchErrors.WithLabelValues("gains", srcLeverageTerms, "parse").Inc() + fmt.Printf("[perp-cohort][gains][%s] liqParams %s: %v\n", srcLeverageTerms, market, err) + continue + } + startTh := bigToFloat(p[1], 1e10) + endTh := bigToFloat(p[2], 1e10) + startLev := bigToFloat(p[3], 1e3) + endLev := bigToFloat(p[4], 1e3) + // A threshold at or past the whole collateral is a decode error, not + // a term: it would describe a position liquidated before it opens. + if startTh <= 0 || startTh >= 100 || endTh <= 0 || endTh >= 100 { + perpCohortFetchErrors.WithLabelValues("gains", srcLeverageTerms, "parse").Inc() + continue + } + fee, err := s.gainsPairFee(idx) + if err != nil { + perpCohortFetchErrors.WithLabelValues("gains", srcLeverageTerms, "parse").Inc() + fmt.Printf("[perp-cohort][gains][%s] fee %s: %v\n", srcLeverageTerms, market, err) + continue + } + out = append(out, venueTerms{ + venue: "gains", + market: market, + maxLev: maxLev, + // The threshold is a share of collateral, so as a share of + // notional it is the complement divided by the leverage. + maintenance: func(lev float64) float64 { + th := interpolateThreshold(startTh, endTh, startLev, endLev, lev) + return (100 - th) / 100 / lev + }, + costPerLeg: fee, + // Measured, not assumed: amountSentToTrader is 0 on all 27 + // liquidations found across every pair and every leverage from + // 7.5x to 500x. The threshold says when the position closes, not + // what comes back, and what comes back is nothing. + forfeitsAll: true, + }) + } + return out +} + +// gainsPairFee reads the pair's position-size fee as a percentage of notional. +// +// pairs(uint256) returns the pair struct; word 9 is its fee index, the same +// offset the perp-fees harness reads in production. fees(uint256) then returns +// the fee group, whose word 0 is the size fee at 1e10 precision: 350_000_000 +// is 0.035%, which is the worked example in that harness. +// +// The fee matters here because it sits inside the liquidation trigger. On the +// x500 degen pairs it reads 0.02%, and two legs of it at 500x are 20 points of +// collateral, which is exactly the gap between the 80% threshold and the 59.4% +// median that 11 real liquidations show. +func (s *LeverageTermsSource) gainsPairFee(pairIdx int64) (float64, error) { + pw, err := s.gainsCall(selPairs, pairIdx) + if err != nil { + return 0, err + } + if len(pw) < 10 { + return 0, fmt.Errorf("pairs(%d) returned %d words", pairIdx, len(pw)) + } + feeIdx := pw[9].Int64() + fw, err := s.gainsCall(selFees, feeIdx) + if err != nil { + return 0, err + } + if len(fw) < 1 { + return 0, fmt.Errorf("fees(%d) returned nothing", feeIdx) + } + fee := bigToFloat(fw[0], 1e10) + // A fee at or above one percent of notional would be an order of magnitude + // out and would swallow the trigger at high leverage; refuse rather than + // publish a room computed from a misread scale. + if fee < 0 || fee > 1 { + return 0, fmt.Errorf("fee index %d reads %.6f%%, outside a plausible range", feeIdx, fee) + } + return fee, nil +} + +// interpolateThreshold mirrors getLiqPnlThresholdP in the gTrade contracts: the +// liquidation threshold is flat below startLeverage, flat above endLeverage, +// and linear between the two. A degen pair sets both bounds to the same value, +// which makes the threshold constant. +func interpolateThreshold(startTh, endTh, startLev, endLev, lev float64) float64 { + if lev <= startLev { + return startTh + } + if lev >= endLev { + return endTh + } + return startTh - ((lev-startLev)*(startTh-endTh))/(endLev-startLev) +} + +func bigToFloat(i *big.Int, scale float64) float64 { + if i == nil { + return 0 + } + f, _ := new(big.Float).Quo(new(big.Float).SetInt(i), big.NewFloat(scale)).Float64() + return f +} + +func (s *LeverageTermsSource) gainsCall(selector string, arg int64) ([]*big.Int, error) { + data := fmt.Sprintf("%s%064x", selector, arg) + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "eth_call", + "params": []any{map[string]string{"to": gainsDiamondArb, "data": data}, "latest"}, + }) + req, err := http.NewRequest("POST", gainsArbRPC, strings.NewReader(string(body))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", leverageTermsUA) + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var out struct { + Result string `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + if out.Error != nil { + return nil, fmt.Errorf("rpc: %s", out.Error.Message) + } + return decodeWordsHex(out.Result) +} + +func decodeWordsHex(result string) ([]*big.Int, error) { + h := strings.TrimPrefix(result, "0x") + if len(h) < 64 || len(h)%64 != 0 { + return nil, fmt.Errorf("not a whole number of words: %q", result) + } + var words []*big.Int + for i := 0; i+64 <= len(h); i += 64 { + v, ok := new(big.Int).SetString(h[i:i+64], 16) + if !ok { + return nil, fmt.Errorf("bad word at %d", i) + } + words = append(words, v) + } + return words, nil +} + +// ------------------------------------------------------------ Papertrade + +func (s *LeverageTermsSource) fetchPapertrade() []venueTerms { + req, err := http.NewRequest("GET", "https://exchange.papertrade.xyz/state/trading", nil) + if err != nil { + return nil + } + req.Header.Set("User-Agent", leverageTermsUA) + resp, err := s.client.Do(req) + if err != nil { + perpCohortFetchErrors.WithLabelValues("papertrade", srcLeverageTerms, classifyError(err.Error())).Inc() + return nil + } + defer resp.Body.Close() + var st struct { + Instruments [][]json.RawMessage `json:"instruments"` + } + if err := json.NewDecoder(resp.Body).Decode(&st); err != nil { + perpCohortFetchErrors.WithLabelValues("papertrade", srcLeverageTerms, "parse").Inc() + return nil + } + var out []venueTerms + for _, inst := range st.Instruments { + symbol, lev, buffer, ok := papertradeTerms(inst) + if !ok { + perpCohortFetchErrors.WithLabelValues("papertrade", srcLeverageTerms, "parse").Inc() + continue + } + b := buffer + out = append(out, venueTerms{ + venue: "papertrade", + market: symbol, + maxLev: lev, + // Fixed in price, so it does not vary with leverage. That is the + // whole asymmetry against a collateral-share threshold. + maintenance: func(float64) float64 { return b }, + // Hard bust: crossing the trigger forfeits the full isolated + // margin even though a voluntary close a tick earlier would have + // returned buffer times leverage of it. + forfeitsAll: true, + }) + } + return out +} + +// papertradeTerms reads one instrument tuple. The payload is positional, not an +// object: index 1 is the symbol, 14 the max leverage as a plain integer, 16 the +// bust buffer at 1e18 as a decimal string, a share of price. The length guard +// matters more than usual here, because a tuple that grew a field in the middle +// would silently hand back some other number rather than fail. +func papertradeTerms(inst []json.RawMessage) (symbol string, lev, buffer float64, ok bool) { + if len(inst) < 17 { + return "", 0, 0, false + } + if err := json.Unmarshal(inst[1], &symbol); err != nil || symbol == "" { + return "", 0, 0, false + } + if err := json.Unmarshal(inst[14], &lev); err != nil || lev <= 0 { + return "", 0, 0, false + } + var bufRaw string + if err := json.Unmarshal(inst[16], &bufRaw); err != nil { + return "", 0, 0, false + } + bi, okBuf := new(big.Int).SetString(bufRaw, 10) + if !okBuf { + return "", 0, 0, false + } + buffer = bigToFloat(bi, 1e18) + if buffer <= 0 || buffer >= 1 { + return "", 0, 0, false + } + return symbol, lev, buffer, true +} + +// ----------------------------------------------------------- Hyperliquid + +// fetchHyperliquid reads maxLeverage per asset from the info `meta` endpoint. +// +// The maintenance requirement is NOT in the payload: Hyperliquid documents it +// as half the initial requirement at maximum leverage, so it is derived rather +// than read. That is a weaker provenance than the other three venues and the +// bench says so in its methodology; it is still the venue's own published rule +// rather than an estimate of ours. It also means the share returned reads 50 +// percent at the venue's maximum on every market, by construction. +func (s *LeverageTermsSource) fetchHyperliquid() []venueTerms { + req, err := http.NewRequest("POST", "https://api.hyperliquid.xyz/info", + strings.NewReader(`{"type":"meta"}`)) + if err != nil { + return nil + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", leverageTermsUA) + resp, err := s.client.Do(req) + if err != nil { + perpCohortFetchErrors.WithLabelValues("hyperliquid", srcLeverageTerms, classifyError(err.Error())).Inc() + return nil + } + defer resp.Body.Close() + var meta struct { + Universe []struct { + Name string `json:"name"` + MaxLeverage float64 `json:"maxLeverage"` + IsDelisted bool `json:"isDelisted"` + } `json:"universe"` + } + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { + perpCohortFetchErrors.WithLabelValues("hyperliquid", srcLeverageTerms, "parse").Inc() + return nil + } + var out []venueTerms + for _, u := range meta.Universe { + if u.IsDelisted || !leverageTermsMarkets[u.Name] || u.MaxLeverage <= 0 { + continue + } + m := 1 / (2 * u.MaxLeverage) + out = append(out, venueTerms{ + venue: "hyperliquid", + market: u.Name, + maxLev: u.MaxLeverage, + maintenance: func(float64) float64 { return m }, + }) + } + return out +} + +// ------------------------------------------------------------------ dYdX + +// fetchDydx reads initialMarginFraction and maintenanceMarginFraction straight +// off the indexer. Both are published per market, so nothing is derived. +func (s *LeverageTermsSource) fetchDydx() []venueTerms { + req, err := http.NewRequest("GET", "https://indexer.dydx.trade/v4/perpetualMarkets", nil) + if err != nil { + return nil + } + req.Header.Set("User-Agent", leverageTermsUA) + resp, err := s.client.Do(req) + if err != nil { + perpCohortFetchErrors.WithLabelValues("dydx", srcLeverageTerms, classifyError(err.Error())).Inc() + return nil + } + defer resp.Body.Close() + var payload struct { + Markets map[string]struct { + Ticker string `json:"ticker"` + Status string `json:"status"` + InitialMarginFraction string `json:"initialMarginFraction"` + MaintenanceMarginFraction string `json:"maintenanceMarginFraction"` + } `json:"markets"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + perpCohortFetchErrors.WithLabelValues("dydx", srcLeverageTerms, "parse").Inc() + return nil + } + var out []venueTerms + for _, mk := range payload.Markets { + base, _, found := strings.Cut(mk.Ticker, "-") + if !found || !leverageTermsMarkets[base] || mk.Status != "ACTIVE" { + continue + } + initial, err1 := strconv.ParseFloat(mk.InitialMarginFraction, 64) + maint, err2 := strconv.ParseFloat(mk.MaintenanceMarginFraction, 64) + if err1 != nil || err2 != nil || initial <= 0 || maint <= 0 || maint >= initial { + perpCohortFetchErrors.WithLabelValues("dydx", srcLeverageTerms, "parse").Inc() + continue + } + m := maint + out = append(out, venueTerms{ + venue: "dydx", + market: base, + maxLev: 1 / initial, + maintenance: func(float64) float64 { return m }, + }) + } + return out +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_leverage_terms_test.go b/harnesses/perp-cohort-stats/cmd/script/source_leverage_terms_test.go new file mode 100644 index 000000000..3f7b936de --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_leverage_terms_test.go @@ -0,0 +1,502 @@ +package main + +import ( + "encoding/json" + "math" + "math/big" + "os" + "testing" +) + +// Fixtures captured live on 2026-10-12. +// +// pair 0 BTC/USD getPairLiquidationParams -> stdLiqParams +// pair 300 BTCDEGEN getPairLiquidationParams -> degenLiqParams +// +// Pair 0 is the calibration fixture: Gains documents a 90 percent liquidation +// threshold on its standard pairs, so a scale that does not turn word 1 into 90 +// is the wrong scale. The hex is generated from the word integers rather than +// retyped, because retyping it is how a digit per word went missing once and +// an 80 percent threshold read as 0.3125. +const ( + stdLiqParams = "0x000000000000000000000000000000000000000000000000000000001dcd6500000000000000000000000000000000000000000000000000000000d18c2e2800000000000000000000000000000000000000000000000000000000ae9f7bcc0000000000000000000000000000000000000000000000000000000000000061a8000000000000000000000000000000000000000000000000000000000000ea60" + degenLiqParams = "0x000000000000000000000000000000000000000000000000000000001dcd6500000000000000000000000000000000000000000000000000000000ba43b74000000000000000000000000000000000000000000000000000000000ba43b74000000000000000000000000000000000000000000000000000000000000007a120000000000000000000000000000000000000000000000000000000000007a120" +) + +// Papertrade instrument tuples, captured live on 2026-10-12. +const ( + ptBTCInstrument = `[0, "BTC", 0, "10", true, false, false, false, false, null, null, null, "15000000000000000000000", "814598000000000000000", 1000, "10000000000000000000000000", "476041580087574", "100000000000000000"]` + ptETHInstrument = `[1, "ETH", 1, "100", true, false, false, false, false, null, null, null, "15000000000000000000000", "483979000000000000000", 1000, "10000000000000000000000000", "481304050180130", "100000000000000000"]` +) + +func approx(t *testing.T, label string, got, want, tol float64) { + t.Helper() + if math.Abs(got-want) > tol { + t.Errorf("%s = %.6f, want %.6f (tol %g)", label, got, want, tol) + } +} + +func words(t *testing.T, hexStr string) []*big.Int { + t.Helper() + w, err := decodeWordsHex(hexStr) + if err != nil { + t.Fatalf("fixture: %v", err) + } + return w +} + +func ptTuple(t *testing.T, raw string) []json.RawMessage { + t.Helper() + var out []json.RawMessage + if err := json.Unmarshal([]byte(raw), &out); err != nil { + t.Fatalf("fixture: %v", err) + } + return out +} + +// gainsVenue builds the venueTerms fetchGains would build from a decoded +// return, so the tests exercise the same closure the harness publishes from. +// The fee is the pair's position-size fee read from its fee group: 0.02% on the +// degen pairs, 0.035% on the standard ones. +func gainsVenue(t *testing.T, hexStr string, maxLev, feePerLeg float64) venueTerms { + t.Helper() + w := words(t, hexStr) + if len(w) != 5 { + t.Fatalf("got %d words, want 5", len(w)) + } + startTh := bigToFloat(w[1], 1e10) + endTh := bigToFloat(w[2], 1e10) + startLev := bigToFloat(w[3], 1e3) + endLev := bigToFloat(w[4], 1e3) + return venueTerms{ + venue: "gains", market: "BTC", maxLev: maxLev, + maintenance: func(lev float64) float64 { + return (100 - interpolateThreshold(startTh, endTh, startLev, endLev, lev)) / 100 / lev + }, + costPerLeg: feePerLeg, + forfeitsAll: true, + } +} + +// papertradeVenue mirrors fetchPapertrade: no fee at either leg, a buffer fixed +// in price, and a hard bust. +func papertradeVenue(t *testing.T, raw string) venueTerms { + t.Helper() + sym, lev, buf, ok := papertradeTerms(ptTuple(t, raw)) + if !ok { + t.Fatalf("papertrade fixture rejected") + } + return venueTerms{venue: "papertrade", market: sym, maxLev: lev, + maintenance: func(float64) float64 { return buf }, forfeitsAll: true} +} + +// TestGainsScaleCalibration is the anchor: the standard pair must read as the +// documented 90 percent, which fixes the 1e10 scale for every other pair. +func TestGainsScaleCalibration(t *testing.T) { + w := words(t, stdLiqParams) + if len(w) != 5 { + t.Fatalf("got %d words, want 5", len(w)) + } + approx(t, "maxLiqSpreadP", bigToFloat(w[0], 1e10), 0.05, 1e-9) + approx(t, "startLiqThresholdP", bigToFloat(w[1], 1e10), 90, 1e-9) + approx(t, "endLiqThresholdP", bigToFloat(w[2], 1e10), 75, 1e-9) + approx(t, "startLeverage", bigToFloat(w[3], 1e3), 25, 1e-9) + approx(t, "endLeverage", bigToFloat(w[4], 1e3), 60, 1e-9) +} + +// TestGainsThresholdInterpolates pins the piecewise shape of +// getLiqPnlThresholdP: flat below startLeverage, flat above endLeverage, linear +// between. A degen pair sets both bounds equal, which is the flat case, so +// without the standard pair this behaviour would never be exercised. +func TestGainsThresholdInterpolates(t *testing.T) { + const startTh, endTh, startLev, endLev = 90, 75, 25, 60 + for _, c := range []struct{ lev, want float64 }{ + {2, 90}, // below the floor + {25, 90}, // at the floor + {60, 75}, // at the ceiling + {150, 75}, // above the ceiling + {42.5, 82.5}, // the midpoint is the mean of the two thresholds + {40, 83.5714}, // an arbitrary interior point + } { + got := interpolateThreshold(startTh, endTh, startLev, endLev, c.lev) + approx(t, "threshold at leverage", got, c.want, 1e-4) + } +} + +// TestGainsRoomMatchesWhatLiquidationsShow is the test the measurement earned. +// +// The contract's threshold is 80% of collateral, which would put the room at +// 0.1600% of price at 500x. Eleven real liquidations on pairs 300 and 313 fired +// at a median 59.4% of collateral, which is 0.1189% of price. The gap is both +// fee legs: 0.02% x 500 x 2 is 20 points of collateral, and 80 - 20 is 60. The +// model therefore has to predict 60.0% and 0.1200%, within a point of the +// measurement, the remainder being borrowing which accrues with holding time +// and is not a term at open. +func TestGainsRoomMatchesWhatLiquidationsShow(t *testing.T) { + g := gainsVenue(t, degenLiqParams, 500, 0.02) + room, lost, kept, ok := termsAt(g, 500) + if !ok { + t.Fatal("rejected at 500x") + } + approx(t, "room at 500x", room, 0.12, 1e-9) + approx(t, "collateral lost at 500x", lost, 60, 1e-9) + approx(t, "collateral kept", kept, 0, 0) + + // Against the measurement, loosely: the model must land within a point of + // the median, and must NOT reproduce the fee-free 0.16%. + const measuredLostPct = 59.44 + if diff := lost - measuredLostPct; diff < 0 || diff > 1.5 { + t.Errorf("model says %.2f%% of collateral, measured median %.2f%%, gap %.2f points", + lost, measuredLostPct, diff) + } + if room > 0.15 { + t.Errorf("room %.4f%% is back at the fee-free figure, the fee legs are missing", room) + } +} + +// TestGainsRoomScalesWithLeverage: the threshold is a share of collateral and +// flat on a degen pair, so the room falls close to proportionally with +// leverage. The fee legs bend it slightly, and the bend grows with leverage +// because the fee is a share of notional. +func TestGainsRoomScalesWithLeverage(t *testing.T) { + g := gainsVenue(t, degenLiqParams, 500, 0.02) + for _, c := range []struct{ lev, room, lost float64 }{ + {500, 0.12, 60.0}, + {200, 0.36, 72.0}, + {100, 0.76, 76.0}, + {50, 1.56, 78.0}, + {10, 7.96, 79.6}, + } { + room, lost, kept, ok := termsAt(g, c.lev) + if !ok { + t.Fatalf("gains rejected at %.0fx", c.lev) + } + approx(t, "gains room", room, c.room, 1e-9) + approx(t, "gains collateral lost", lost, c.lost, 1e-9) + approx(t, "gains collateral kept", kept, 0, 0) + } +} + +// TestGainsReturnsNothing states the measurement plainly: amountSentToTrader +// was 0 on all 27 liquidations found, every pair, 7.5x to 500x. The 20 points +// the threshold leaves never reach the trader, so the column is a real zero at +// every leverage and not a figure derived from the threshold. +func TestGainsReturnsNothing(t *testing.T) { + g := gainsVenue(t, degenLiqParams, 500, 0.02) + for _, lev := range []float64{10, 50, 100, 500} { + _, _, kept, ok := termsAt(g, lev) + if !ok { + t.Fatalf("rejected at %.0fx", lev) + } + if kept != 0 { + t.Errorf("gains returned %.2f%% at %.0fx, measurement says nothing comes back", kept, lev) + } + } +} + +// TestPapertradeBufferIsAnOffsetNotTheRoom is the test that exists because the +// first version of this source got it backwards. Papertrade's docs work the +// 1000x case themselves: "at 1000x, where your full margin covers a 0.1% move, +// liquidation fires after a move of about 0.052%". That is 1/1000 minus the +// buffer. Reading the buffer AS the room would give 0.0476 and the published +// figure would have been wrong. +func TestPapertradeBufferIsAnOffsetNotTheRoom(t *testing.T) { + sym, lev, buffer, ok := papertradeTerms(ptTuple(t, ptBTCInstrument)) + if !ok { + t.Fatal("BTC tuple rejected") + } + if sym != "BTC" { + t.Errorf("symbol = %q, want BTC", sym) + } + approx(t, "max leverage", lev, 1000, 1e-9) + // About 5 basis points of price, which is what the docs call it. + approx(t, "buffer in percent", buffer*100, 0.0476041580087574, 1e-12) + + pt := papertradeVenue(t, ptBTCInstrument) + + room, lost, kept, ok := termsAt(pt, 1000) + if !ok { + t.Fatal("rejected at 1000x") + } + // The docs' own worked figure, to their stated precision. + approx(t, "room at 1000x", room, 0.052, 5e-4) + // And in the unit venues quote among themselves: Sacha at Gains read this + // as "almost 50% of collat", which is this number. + approx(t, "collateral lost at 1000x", lost, 52.4, 0.1) + // And not the buffer itself, which is the mistake this test guards. + if math.Abs(room-buffer*100) < 1e-6 { + t.Errorf("room %.6f equals the buffer, the offset was read as the room", room) + } + // Hard bust forfeits everything, whatever the arithmetic would return. + approx(t, "kept at 1000x", kept, 0, 0) +} + +// TestPapertradeBufferCostsMoreAsLeverageGrows: the buffer is fixed in price, +// so the share of margin it represents grows with leverage. The docs state the +// 100x case ("at 100x the buffer is about 5% of your margin") and the 1000x one +// ("nearly half your margin"), so both are checkable. +func TestPapertradeBufferCostsMoreAsLeverageGrows(t *testing.T) { + _, lev, buffer, ok := papertradeTerms(ptTuple(t, ptBTCInstrument)) + if !ok { + t.Fatal("rejected") + } + // What a voluntary close would return, which is the buffer's share of the + // margin. Published as zero on the liquidation path, but it is the number + // the docs quote, so it is the one to check the model against. + approx(t, "buffer share of margin at 100x", buffer*100*100, 4.76, 1e-2) + approx(t, "buffer share of margin at 1000x", buffer*1000*100, 47.6, 1e-1) + if !(buffer*lev*100 > 40) { + t.Errorf("at max leverage the buffer should eat most of the margin, got %.1f%%", buffer*lev*100) + } + + pt := papertradeVenue(t, ptBTCInstrument) + // Room shrinks with leverage like everywhere else; it is the buffer that + // is constant, not the room. The first version of this source had that + // backwards and claimed the room was identical at 1x and 1000x. + r10, _, _, _ := termsAt(pt, 10) + r1000, _, _, _ := termsAt(pt, 1000) + if !(r10 > r1000) { + t.Errorf("room should shrink with leverage: 10x=%.4f 1000x=%.4f", r10, r1000) + } +} + +func TestPapertradeTermsGuardsTupleShape(t *testing.T) { + full := ptTuple(t, ptBTCInstrument) + if _, _, _, ok := papertradeTerms(full[:16]); ok { + t.Error("accepted a tuple too short to hold the bust buffer") + } + zeroBuf := append([]json.RawMessage{}, full...) + zeroBuf[16] = json.RawMessage(`"0"`) + if _, _, _, ok := papertradeTerms(zeroBuf); ok { + t.Error("accepted a zero bust buffer") + } + hugeBuf := append([]json.RawMessage{}, full...) + hugeBuf[16] = json.RawMessage(`"2000000000000000000"`) // 2.0, not a share + if _, _, _, ok := papertradeTerms(hugeBuf); ok { + t.Error("accepted a buffer above 100 percent of price") + } + noSym := append([]json.RawMessage{}, full...) + noSym[1] = json.RawMessage(`""`) + if _, _, _, ok := papertradeTerms(noSym); ok { + t.Error("accepted an empty symbol") + } + // ETH too, so the fixture pair is not just one lucky tuple. + sym, lev, buf, ok := papertradeTerms(ptTuple(t, ptETHInstrument)) + if !ok || sym != "ETH" { + t.Fatalf("ETH tuple: ok=%v sym=%q", ok, sym) + } + approx(t, "ETH max leverage", lev, 1000, 1e-9) + approx(t, "ETH buffer percent", buf*100, 0.048130405018013, 1e-12) +} + +// TestMarginFractionVenues covers dYdX, whose two fractions are published, and +// Hyperliquid, whose maintenance requirement comes from the documented halving +// rule. Neither publishes a fee in the payload we read, so their rows carry no +// fee legs and the bench says so. +// +// The trap is that the share returned is a ratio of the two fractions and not +// their difference, because the collateral posted is the initial one. +func TestMarginFractionVenues(t *testing.T) { + // dYdX BTC-USD, read live 2026-10-12: IMF 0.02, MMF 0.012. + dydx := venueTerms{venue: "dydx", maxLev: 1 / 0.02, + maintenance: func(float64) float64 { return 0.012 }} + approx(t, "dydx max leverage", dydx.maxLev, 50, 1e-9) + for _, c := range []struct{ lev, room, lost, kept float64 }{ + {50, 0.8, 40, 60}, + {25, 2.8, 70, 30}, + {10, 8.8, 88, 12}, + } { + room, lost, kept, ok := termsAt(dydx, c.lev) + if !ok { + t.Fatalf("dydx rejected at %.0fx", c.lev) + } + approx(t, "dydx room", room, c.room, 1e-9) + approx(t, "dydx lost", lost, c.lost, 1e-9) + approx(t, "dydx kept", kept, c.kept, 1e-9) + } + + // Hyperliquid BTC: maxLeverage 40, maintenance at half the initial + // requirement, so the share returned is 50 percent at the maximum + // whatever the asset. + hl := venueTerms{venue: "hyperliquid", maxLev: 40, + maintenance: func(float64) float64 { return 1.0 / 80 }} + for _, c := range []struct{ lev, room, lost, kept float64 }{ + {40, 1.25, 50, 50}, + {10, 8.75, 87.5, 12.5}, + } { + room, lost, kept, ok := termsAt(hl, c.lev) + if !ok { + t.Fatalf("hyperliquid rejected at %.0fx", c.lev) + } + approx(t, "hl room", room, c.room, 1e-9) + approx(t, "hl lost", lost, c.lost, 1e-9) + approx(t, "hl kept", kept, c.kept, 1e-9) + } +} + +// TestTermsAtRefusesImpossible: a leverage the venue does not offer, or costs +// that swallow the whole margin, is refused rather than published. A venue that +// published a leverage but no room would read as the safest row on the board. +func TestTermsAtRefusesImpossible(t *testing.T) { + v := venueTerms{maxLev: 40, maintenance: func(float64) float64 { return 1.0 / 80 }} + for _, lev := range []float64{0, -10, 41, 1000} { + if _, _, _, ok := termsAt(v, lev); ok { + t.Errorf("accepted leverage %.0f on a 40x venue", lev) + } + } + tight := venueTerms{maxLev: 100, maintenance: func(float64) float64 { return 0.02 }} + if _, _, _, ok := termsAt(tight, 100); ok { + t.Error("accepted a maintenance requirement above the margin posted") + } + if _, _, _, ok := termsAt(venueTerms{maxLev: 10, maintenance: func(float64) float64 { return 0 }}, 10); ok { + t.Error("accepted a zero maintenance requirement") + } + // Fees alone can exhaust the margin at high leverage: a 0.2% fee a leg at + // 250x is the whole collateral twice over. + feeHeavy := venueTerms{maxLev: 500, costPerLeg: 0.2, + maintenance: func(lev float64) float64 { return 0.2 / 100 / lev }} + if _, _, _, ok := termsAt(feeHeavy, 250); ok { + t.Error("accepted a position whose fees exceed its margin") + } +} + +// TestRankingInvertsWithLeverage is the finding the leverage selector exists +// for, and the reason the bench publishes no single ranking. +// +// At each venue's OWN maximum the room shrinks as leverage grows, which reads +// as a ranking but compares four different leverages. At a leverage they all +// offer the order is different, and Papertrade leads because its maintenance +// requirement is five basis points of price where the others hold back more +// than a percent of notional. +// +// Measured, not modelled, on the Gains side: its room at 500x is the one 11 +// real liquidations show, and Papertrade's is a ceiling 384 adverse closes +// never crossed. +func TestRankingInvertsWithLeverage(t *testing.T) { + pt := papertradeVenue(t, ptBTCInstrument) + gains := gainsVenue(t, degenLiqParams, 500, 0.02) + dydx := venueTerms{venue: "dydx", maxLev: 50, maintenance: func(float64) float64 { return 0.012 }} + hl := venueTerms{venue: "hyperliquid", maxLev: 40, maintenance: func(float64) float64 { return 1.0 / 80 }} + + // At each venue's own maximum: more leverage, less room, monotonically. + ptMax, _, ptKeptMax, _ := termsAt(pt, pt.maxLev) + gMax, _, gKeptMax, _ := termsAt(gains, gains.maxLev) + dMax, _, dKeptMax, _ := termsAt(dydx, dydx.maxLev) + hMax, _, hKeptMax, _ := termsAt(hl, hl.maxLev) + if !(ptMax < gMax && gMax < dMax && dMax < hMax) { + t.Errorf("at max leverage the room should shrink as leverage grows: pt=%.4f gains=%.4f dydx=%.4f hl=%.4f", + ptMax, gMax, dMax, hMax) + } + + // At 500x, the only setting Gains and Papertrade both offer, Papertrade + // leads once Gains' fee legs are counted. Before the fees were counted the + // bench published 0.1600 for Gains and had this the other way round. + g500, _, _, _ := termsAt(gains, 500) + p500, _, _, _ := termsAt(pt, 500) + approx(t, "gains at 500x", g500, 0.12, 1e-9) + approx(t, "papertrade at 500x", p500, 0.1523958419912426, 1e-9) + if !(p500 > g500) { + t.Errorf("at 500x papertrade should leave more room: pt=%.4f gains=%.4f", p500, g500) + } + + // At 10x, which all four offer, Gains is last: it liquidates at a share of + // collateral whatever the leverage, which is harsh when leverage is low. + pt10, _, _, _ := termsAt(pt, 10) + g10, _, _, _ := termsAt(gains, 10) + d10, _, _, _ := termsAt(dydx, 10) + h10, _, _, _ := termsAt(hl, 10) + if !(pt10 > d10 && d10 > h10 && h10 > g10) { + t.Errorf("at 10x expected pt > dydx > hl > gains, got pt=%.4f dydx=%.4f hl=%.4f gains=%.4f", + pt10, d10, h10, g10) + } + + // The collateral column does not invert, and it is the one where the two + // high-leverage venues are alike: neither returns anything, measured on + // both sides. The two margin-fraction venues do return something. + approx(t, "papertrade kept at its max", ptKeptMax, 0, 0) + approx(t, "gains kept at its max", gKeptMax, 0, 0) + for name, kept := range map[string]float64{"dydx": dKeptMax, "hyperliquid": hKeptMax} { + if kept <= 0 { + t.Errorf("%s should return collateral, got %.2f", name, kept) + } + } +} + +// TestLeverageBucketsCoverTheVenues: the selector's values must each be +// offered by at least one venue, and "all" must be first so the unfiltered +// page is the max-leverage view. +func TestLeverageBucketsCoverTheVenues(t *testing.T) { + if leverageBuckets[0].label != "all" || leverageBuckets[0].lev != 0 { + t.Fatalf("first bucket must be the venue maximum, got %+v", leverageBuckets[0]) + } + maxOffered := 1000.0 // Papertrade + for _, b := range leverageBuckets[1:] { + if b.lev <= 0 { + t.Errorf("bucket %q has no leverage", b.label) + } + if b.lev > maxOffered { + t.Errorf("bucket %q is above every venue's maximum", b.label) + } + } +} + +// TestLeverageTermsLive hits all four venues for real and asserts the published +// numbers, not just that a call succeeded. Off by default so the suite stays +// hermetic; run it with OCB_LIVE=1 when an upstream shape may have moved, which +// is the one failure the fixtures cannot see. +func TestLeverageTermsLive(t *testing.T) { + if os.Getenv("OCB_LIVE") == "" { + t.Skip("set OCB_LIVE=1 to hit Arbitrum, Papertrade, Hyperliquid and dYdX") + } + s := NewLeverageTermsSource() + groups := map[string][]venueTerms{ + "gains": s.fetchGains(), + "papertrade": s.fetchPapertrade(), + "hyperliquid": s.fetchHyperliquid(), + "dydx": s.fetchDydx(), + } + for venue, ts := range groups { + if len(ts) == 0 { + t.Errorf("%s returned no markets, its payload shape may have moved", venue) + continue + } + for _, v := range ts { + if !leverageTermsMarkets[v.market] { + t.Errorf("%s returned an unexpected market %q", venue, v.market) + } + if v.maxLev < 10 { + t.Errorf("%s/%s max leverage = %.0fx, implausibly low", venue, v.market, v.maxLev) + } + room, lost, kept, ok := termsAt(v, v.maxLev) + if !ok { + t.Errorf("%s/%s produced no terms at its own maximum", venue, v.market) + continue + } + if room <= 0 || room > 15 { + t.Errorf("%s/%s room = %.4f%%, outside a plausible range", venue, v.market, room) + } + if lost <= 0 || lost > 100 { + t.Errorf("%s/%s collateral lost = %.2f%%, outside a plausible range", venue, v.market, lost) + } + if kept < 0 || kept >= 100 { + t.Errorf("%s/%s kept = %.2f%%, outside a plausible range", venue, v.market, kept) + } + // The two units must agree by construction: lost is room times + // leverage. A mismatch means one of them was computed elsewhere. + if diff := lost - room*v.maxLev; diff > 1e-9 || diff < -1e-9 { + t.Errorf("%s/%s units disagree: lost=%.4f, room*lev=%.4f", venue, v.market, lost, room*v.maxLev) + } + // Gains is measured to return nothing; so is Papertrade by its + // hard bust rule. + if (venue == "gains" || venue == "papertrade") && kept != 0 { + t.Errorf("%s/%s returned %.2f%%, measurement says nothing comes back", venue, v.market, kept) + } + t.Logf("%-12s %-4s %5.0fx room=%.4f%% lost=%.1f%% kept=%.1f%%", + venue, v.market, v.maxLev, room, lost, kept) + } + } + // The source must reach every venue over the network, not just parse. + if _, err := s.Fetch(); err != nil { + t.Fatalf("full fetch: %v", err) + } +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_papertrade.go b/harnesses/perp-cohort-stats/cmd/script/source_papertrade.go new file mode 100644 index 000000000..c2778a601 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_papertrade.go @@ -0,0 +1,191 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// PapertradeNativeSource reads Papertrade, a synthetic perp venue on Hyperliquid +// that launched 2026-10-10 and lists exactly two markets, BTC and ETH. +// +// The API is undocumented but public and keyless, and it is the same source +// DefiLlama's own adapter uses. Three routes matter. Note the namespaces are +// /state and /query: the root and /api/* are both 404. +// +// GET /query/protocol/history?interval=1d volume, trades, users, tvl, +// traderPnl, liquidation +// GET /query/house/history?interval=1d bl/bs/el/es (open interest by +// side), lp, q (payout queue) +// GET /state/trading live per-instrument parameters +// +// Both history payloads are COLUMNAR and CUMULATIVE, and the two use different +// time encodings: protocol carries startMs plus a fixed intervalMs, house +// carries baseTimeMs plus an explicit timeOffsetsMs array. Daily figures are +// therefore deltas between buckets, and the final bucket is partial, so the +// last COMPLETE bucket is used for anything labelled 24h. +// +// Open interest: the four side columns ADD. Every position faces the house +// rather than another trader, so halving the sum the way one would on an order +// book venue would understate it by half. +// +// Volume is deliberately NOT published to the volume-share benches. The figure +// is real but it is notional at up to 1000x leverage against a $10 minimum +// margin: the average trade is about $2.6M of notional, the venue turned over +// several thousand times its own TVL on day one, and a raw column would rank a +// three-day-old venue above Hyperliquid on leverage rather than on activity. +// It is emitted here so the ratio benches (volume/OI) can use it, because there +// the leverage appears on both sides of the fraction and the ratio is the point. +type PapertradeNativeSource struct { + client *http.Client +} + +func NewPapertradeNativeSource() *PapertradeNativeSource { + return &PapertradeNativeSource{ + client: &http.Client{Timeout: 20 * time.Second}, + } +} + +func (s *PapertradeNativeSource) Name() string { return srcPapertradeNative } + +// papertradeHistory covers both payload shapes. StartMs plus IntervalMs is the +// protocol form; BaseTimeMs plus TimeOffsetsMs is the house form. +type papertradeHistory struct { + StartMs int64 `json:"startMs"` + IntervalMs int64 `json:"intervalMs"` + BaseTimeMs int64 `json:"baseTimeMs"` + TimeOffsetsMs []int64 `json:"timeOffsetsMs"` + Columns map[string][]float64 `json:"columns"` +} + +// lastComplete returns the value of a column at the last complete bucket and at +// the one before it, so a daily delta can be taken without counting the partial +// bucket that is still filling. +func (h *papertradeHistory) lastComplete(col string) (cur, prev float64, ok bool) { + v, exists := h.Columns[col] + if !exists || len(v) < 3 { + return 0, 0, false + } + // len-1 is partial, so the last complete bucket is len-2. + return v[len(v)-2], v[len(v)-3], true +} + +func (h *papertradeHistory) latest(col string) (float64, bool) { + v, exists := h.Columns[col] + if !exists || len(v) == 0 { + return 0, false + } + return v[len(v)-1], true +} + +type papertradeState struct { + Instruments [][]json.RawMessage `json:"instruments"` +} + +func (s *PapertradeNativeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + venue := "papertrade" + + proto, err := s.history("https://exchange.papertrade.xyz/query/protocol/history?interval=1d") + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcPapertradeNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] protocol err: %v\n", venue, srcPapertradeNative, err) + return res, nil + } + + // Volume over the last complete day, from the cumulative counter. + if cur, prev, ok := proto.lastComplete("volume"); ok && cur > prev { + res.SetIfPositive(venue, mVolume24h, cur-prev) + } + if tvl, ok := proto.latest("tvl"); ok && tvl > 0 { + res.SetIfPositive(venue, mTVL, tvl) + } + + // Open interest: the four sides add, see the type comment. + house, err := s.history("https://exchange.papertrade.xyz/query/house/history?interval=1d") + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcPapertradeNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] house err: %v\n", venue, srcPapertradeNative, err) + } else { + var oi float64 + for _, side := range []string{"bl", "bs", "el", "es"} { + if v, ok := house.latest(side); ok { + oi += v + } + } + if oi > 0 { + res.SetIfPositive(venue, mOI, oi) + } + } + + // Active markets, read from the live instrument list rather than hardcoded, + // so the count follows the venue if it ever lists a third market. + if n, err := s.marketCount(); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcPapertradeNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] state err: %v\n", venue, srcPapertradeNative, err) + } else if n > 0 { + res.SetIfPositive(venue, mActiveMarkets, float64(n)) + // Both markets are crypto and the venue lists nothing else, so the + // breadth classifier can be told directly instead of inferring. Use + // the helpers rather than writing the maps: newSourceResult only + // initialises Values and Funding, so a direct assignment to Breadth + // or CryptoSymbols writes to a nil map and panics. + res.SetBreadth(venue, breadthCounter{classCrypto: n}) + res.AddCryptoSymbol("BTC") + res.AddCryptoSymbol("ETH") + } + + fmt.Printf("[perp-cohort][%s][%s] ok: %v\n", venue, srcPapertradeNative, res.Values[venue]) + return res, nil +} + +func (s *PapertradeNativeSource) marketCount() (int, error) { + body, err := s.get("https://exchange.papertrade.xyz/state/trading") + if err != nil { + return 0, err + } + var st papertradeState + if err := json.Unmarshal(body, &st); err != nil { + return 0, fmt.Errorf("parse state: %w", err) + } + return len(st.Instruments), nil +} + +func (s *PapertradeNativeSource) history(url string) (*papertradeHistory, error) { + body, err := s.get(url) + if err != nil { + return nil, err + } + var h papertradeHistory + if err := json.Unmarshal(body, &h); err != nil { + return nil, fmt.Errorf("parse history: %w", err) + } + if len(h.Columns) == 0 { + return nil, fmt.Errorf("history has no columns") + } + return &h, nil +} + +func (s *PapertradeNativeSource) get(url string) ([]byte, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@openchainbench.com") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + snippet := string(body) + if len(snippet) > 100 { + snippet = snippet[:100] + } + return nil, fmt.Errorf("http %d: %s", resp.StatusCode, snippet) + } + return body, nil +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_papertrade_test.go b/harnesses/perp-cohort-stats/cmd/script/source_papertrade_test.go new file mode 100644 index 000000000..c0b3c4a19 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_papertrade_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "encoding/json" + "math" + "testing" +) + +// Real shape and real magnitudes from the live endpoint on 2026-10-12. The +// counters are cumulative and the final bucket is still filling, which is the +// trap these tests exist to pin: taking the last two elements would publish a +// part-day as a full day and understate the venue. +const papertradeProtocolFixture = `{ + "startMs": 1791244800000, + "intervalMs": 86400000, + "columns": { + "volume": [0, 0, 0, 0, 564230133196.0, 741490668334.7, 831948472533.0], + "tvl": [0, 10553.0, 8068098.3, 51862154.1, 104885161.0, 64910434.9, 58716051.0], + "trades": [0, 0, 0, 0, 87211, 235390, 322644] + } +}` + +// The house payload uses a different time encoding in the same API: baseTimeMs +// plus an explicit offsets array rather than startMs plus a fixed interval. +const papertradeHouseFixture = `{ + "intervalMs": 86400000, + "baseTimeMs": 1791331200000, + "timeOffsetsMs": [0, 86400000, 172800000, 259200000, 345600000], + "columns": { + "bl": [0, 0, 0, 1044421705.5, 181469913.9], + "bs": [0, 0, 0, 942540852.7, 107941992.6], + "el": [0, 0, 0, 1127792210.2, 91664310.6], + "es": [0, 0, 0, 1236100455.6, 66687413.8], + "lp": [0, 0, 0, 3118460.6, 4282099.4], + "q": [0, 0, 0, 0, 0] + } +}` + +func parseFixture(t *testing.T, raw string) *papertradeHistory { + t.Helper() + var h papertradeHistory + if err := json.Unmarshal([]byte(raw), &h); err != nil { + t.Fatalf("fixture does not parse: %v", err) + } + return &h +} + +func TestPapertradeLastCompleteSkipsThePartialBucket(t *testing.T) { + h := parseFixture(t, papertradeProtocolFixture) + + cur, prev, ok := h.lastComplete("volume") + if !ok { + t.Fatal("volume column should be usable") + } + // The last complete bucket is index 5, not 6: index 6 is still filling. + wantCur, wantPrev := 741490668334.7, 564230133196.0 + if math.Abs(cur-wantCur) > 1 || math.Abs(prev-wantPrev) > 1 { + t.Fatalf("got (%.1f, %.1f), want (%.1f, %.1f): the partial bucket was used", + cur, prev, wantCur, wantPrev) + } + + // And the delta those give is the last FULL day, which is what a 24h + // metric must carry. + day := cur - prev + if math.Abs(day-177260535138.7) > 10 { + t.Fatalf("daily delta %.0f is not the last full day", day) + } + // The naive reading (last two elements) is materially smaller, which is + // precisely the understatement being guarded against. + naive := 831948472533.0 - 741490668334.7 + if naive >= day { + t.Fatalf("fixture no longer demonstrates the partial-bucket trap") + } +} + +func TestPapertradeLatestIsTheCurrentValue(t *testing.T) { + h := parseFixture(t, papertradeProtocolFixture) + // TVL is a level, not a counter, so the current value is the right read + // even though the bucket is partial. + tvl, ok := h.latest("tvl") + if !ok || math.Abs(tvl-58716051.0) > 1 { + t.Fatalf("latest tvl = %.1f, want 58716051", tvl) + } +} + +func TestPapertradeOpenInterestAddsAllFourSides(t *testing.T) { + h := parseFixture(t, papertradeHouseFixture) + + var oi float64 + for _, side := range []string{"bl", "bs", "el", "es"} { + v, ok := h.latest(side) + if !ok { + t.Fatalf("missing side %s", side) + } + oi += v + } + want := 181469913.9 + 107941992.6 + 91664310.6 + 66687413.8 + if math.Abs(oi-want) > 1 { + t.Fatalf("oi = %.1f, want %.1f", oi, want) + } + // Every position faces the house rather than another trader, so halving + // the sum the way an order book venue would is wrong here. + if math.Abs(oi/2-want) < 1 { + t.Fatal("open interest must not be halved on a house-counterparty venue") + } +} + +func TestPapertradeHouseEncodingParsesToo(t *testing.T) { + h := parseFixture(t, papertradeHouseFixture) + if h.BaseTimeMs == 0 || len(h.TimeOffsetsMs) != 5 { + t.Fatalf("house time encoding not read: base=%d offsets=%d", + h.BaseTimeMs, len(h.TimeOffsetsMs)) + } + if h.StartMs != 0 { + t.Fatal("house payload should not carry startMs") + } +} + +func TestPapertradeRejectsSeriesTooShortToHaveACompleteBucket(t *testing.T) { + // A venue two buckets old cannot yield a full day, and publishing one + // would be a part-day labelled 24h. + h := &papertradeHistory{Columns: map[string][]float64{"volume": {0, 100}}} + if _, _, ok := h.lastComplete("volume"); ok { + t.Fatal("two buckets must not produce a 24h figure") + } + if _, _, ok := h.lastComplete("missing"); ok { + t.Fatal("absent column must not be usable") + } +} + +func TestPapertradeStateGivesTheMarketCount(t *testing.T) { + // Trimmed to the shape that matters: a list of per-instrument tuples. + const raw = `{"instruments": [[0,"BTC",0,"10",true], [1,"ETH",1,"100",true]]}` + var st papertradeState + if err := json.Unmarshal([]byte(raw), &st); err != nil { + t.Fatalf("state does not parse: %v", err) + } + if len(st.Instruments) != 2 { + t.Fatalf("market count = %d, want 2", len(st.Instruments)) + } +} diff --git a/harnesses/perp-mark-price-lag/go.mod b/harnesses/perp-mark-price-lag/go.mod index ca92e54e4..10c2d75b9 100644 --- a/harnesses/perp-mark-price-lag/go.mod +++ b/harnesses/perp-mark-price-lag/go.mod @@ -3,3 +3,16 @@ module perp-mark-price-lag 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/perp-mark-price-lag/go.sum b/harnesses/perp-mark-price-lag/go.sum new file mode 100644 index 000000000..d6b8ca98b --- /dev/null +++ b/harnesses/perp-mark-price-lag/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/protocol-valuation/cmd/script/sources.go b/harnesses/protocol-valuation/cmd/script/sources.go index df904a547..7f070a8c1 100644 --- a/harnesses/protocol-valuation/cmd/script/sources.go +++ b/harnesses/protocol-valuation/cmd/script/sources.go @@ -455,13 +455,32 @@ type cgMarket struct { PriceChg30d *float64 } -// fetchMarkets pages through CoinGecko 250 ids at a time. The free tier -// takes the whole cohort in one page today; the loop is there so a cohort -// that grows past 250 does not silently lose its tail. +// cgMarketsBatch is how many ids go into one /coins/markets call. +// +// Not a rate-limit knob: it is a URL-length one. CoinGecko answers 403 +// (not 429) when the query string gets long, and the handler only retries +// on 429, so the sweep failed on its first call and gave up, once an hour, +// for 5.6 days. Measured from the probe host on 2026-10-04, same +// User-Agent, calls seconds apart: 3 ids / 136 chars -> 200, 50 ids / +// 547 chars -> 200, 250 ids / 2,307 chars -> 403. The old batch of 250 put +// the whole 152-protocol cohort in one request and never came back. +// +// The exact threshold is unmeasured: 547 characters is the longest query +// observed to succeed and 2,307 the shortest to fail, so anything in +// between is a guess. 40 is sized against the safe end rather than the +// unknown middle. it keeps a worst-case batch of 24-character slugs near +// 1,150 characters and a realistic one near 650, and costs 4 calls an +// hour for the current 152-protocol cohort, far under any tier's limit. +// Raise it only against a new measurement, not against the arithmetic. +const cgMarketsBatch = 40 + +// fetchMarkets pages through CoinGecko cgMarketsBatch ids at a time. The +// free tier caps a page at 250 rows, and paging here means a cohort +// that grows does not silently lose its tail. func fetchMarkets(ids []string) (map[string]cgMarket, error) { out := map[string]cgMarket{} - for i := 0; i < len(ids); i += 250 { - end := i + 250 + for i := 0; i < len(ids); i += cgMarketsBatch { + end := i + cgMarketsBatch if end > len(ids) { end = len(ids) } @@ -480,7 +499,7 @@ func fetchMarkets(ids []string) (map[string]cgMarket, error) { Chg30d *float64 `json:"price_change_percentage_30d_in_currency"` } if err := getCoinGecko(cgMarkets+"?"+q.Encode(), &page); err != nil { - return out, fmt.Errorf("coingecko page %d: %w", i/250, err) + return out, fmt.Errorf("coingecko page %d: %w", i/cgMarketsBatch, err) } for _, c := range page { out[c.ID] = cgMarket{ diff --git a/harnesses/protocol-valuation/cmd/script/sources_test.go b/harnesses/protocol-valuation/cmd/script/sources_test.go new file mode 100644 index 000000000..a3e01a3de --- /dev/null +++ b/harnesses/protocol-valuation/cmd/script/sources_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "net/url" + "strings" + "testing" +) + +// marketsURLLen rebuilds the request fetchMarkets sends for one batch and +// returns its full length, the quantity CoinGecko actually refuses on. +func marketsURLLen(ids []string) int { + q := url.Values{} + q.Set("vs_currency", "usd") + q.Set("ids", strings.Join(ids, ",")) + q.Set("per_page", "250") + q.Set("price_change_percentage", "30d") + return len(cgMarkets + "?" + q.Encode()) +} + +func slugs(n, width int) []string { + out := make([]string, n) + for i := range out { + out[i] = strings.Repeat("x", width) + } + return out +} + +// The bug this guards: CoinGecko answers 403 (not 429) once the query +// string gets long, and the retry path only covers 429, so one oversized +// call killed the sweep for 5.6 days. Measured from the probe host on +// 2026-10-04: 136 chars -> 200, 547 -> 200, 2,307 -> 403. The true limit +// is somewhere between 547 and 2,307 and was not narrowed, so the ceiling +// is set near the safe end rather than the unknown middle. +const marketsURLCeiling = 1200 + +func TestMarketsBatchKeepsURLShort(t *testing.T) { + // A generous slug width: CoinGecko ids are usually under 20 characters + // ("curve-dao-token" is 15, "pancakeswap-token" 17), so 20 is already + // above the cohort and leaves the batch real headroom. + if got := marketsURLLen(slugs(cgMarketsBatch, 20)); got > marketsURLCeiling { + t.Errorf("a full batch builds a %d-character URL, over the %d ceiling: CoinGecko 403s long queries and the handler does not retry those", got, marketsURLCeiling) + } +} + +func TestOldBatchSizeWouldHaveBeenRefused(t *testing.T) { + // Pins the regression itself rather than only its fix: the previous + // batch of 250 is exactly what produced the 2,307-character URL the + // probe host got a 403 on. + if got := marketsURLLen(slugs(250, 24)); got <= marketsURLCeiling { + t.Fatalf("expected the old batch of 250 to exceed the ceiling, got %d: the ceiling no longer describes the failure", got) + } +} + +func TestBatchCoversTheCohortWithoutLosingItsTail(t *testing.T) { + // 152 protocols on 2026-10-04. Walk the same loop fetchMarkets walks + // and check every id is requested exactly once. + ids := make([]string, 152) + for i := range ids { + ids[i] = string(rune('a'+i%26)) + strings.Repeat("y", i%7) + } + seen := map[string]int{} + calls := 0 + for i := 0; i < len(ids); i += cgMarketsBatch { + end := i + cgMarketsBatch + if end > len(ids) { + end = len(ids) + } + calls++ + for _, id := range ids[i:end] { + seen[id]++ + } + } + if calls != 4 { + t.Errorf("152 ids at a batch of %d = %d calls, want 4", cgMarketsBatch, calls) + } + for _, id := range ids { + if seen[id] != 1 { + t.Fatalf("id %q requested %d times, want exactly 1", id, seen[id]) + } + } +} diff --git a/harnesses/rpc-capabilities/cmd/script/config.go b/harnesses/rpc-capabilities/cmd/script/config.go index 84b510a52..6fee14c9f 100644 --- a/harnesses/rpc-capabilities/cmd/script/config.go +++ b/harnesses/rpc-capabilities/cmd/script/config.go @@ -163,7 +163,7 @@ func chains() []Chain { {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_COSMOSHUB_PUBLICNODE", "https://cosmos-rpc.publicnode.com")}, {Slug: "polkachu", Name: "Polkachu", URL: envDefault("RPC_URL_COSMOSHUB_POLKACHU", "https://cosmos-rpc.polkachu.com")}, {Slug: "lavenderfive", Name: "LavenderFive", URL: envDefault("RPC_URL_COSMOSHUB_LAVENDERFIVE", "https://rpc.lavenderfive.com:443/cosmoshub")}, - {Slug: "lava", Name: "Lava Network", URL: envDefault("RPC_URL_COSMOSHUB_LAVA", "https://cosmoshub.tendermintrpc.lava.build")}, + {Slug: "ecostake", Name: "ecostake", URL: envDefault("RPC_URL_COSMOSHUB_ECOSTAKE", "https://rpc-cosmoshub.ecostake.com")}, }, }, // ─── Injective (bench 095) — added 2026-07-24. Cosmos SDK L1 @@ -306,7 +306,15 @@ func chains() []Chain { {Slug: "nownodes", Name: "NOWNodes", URL: envDefault("RPC_URL_ETHEREUM_NOWNODES", "https://public-eth.nownodes.io")}, }, }, - // ─── Polygon PoS (5 providers) ────────────────────────────── + // ─── Polygon PoS (3 providers) ────────────────────────────── + // + // Nodies removed 2026-10-11: polygon-pokt.nodies.app now answers HTTP + // 403 "The network of this endpoint requires a paid subscription plan", + // on every call from all three regions, so it cannot produce a sample. + // Excluded after probing: polygon-rpc.com (the chain's own endpoint, + // now key-gated), blockpi (521), blastapi (jsonrpc error), llamarpc and + // blxrbdn (no DNS), ankr (key required), 1rpc (4 of 5, and 1RPC has + // twice been removed from this cluster for not sustaining probing). { Slug: "polygon", Name: "Polygon", @@ -314,7 +322,6 @@ func chains() []Chain { {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_POLYGON_PUBLICNODE", "https://polygon-bor-rpc.publicnode.com")}, {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_POLYGON_DRPC", "https://polygon.drpc.org")}, {Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_POLYGON_TENDERLY", "https://gateway.tenderly.co/public/polygon")}, - {Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_POLYGON_NODIES", "https://polygon-pokt.nodies.app")}, }, }, // ─── Arbitrum One (8 providers) ───────────────────────────── @@ -356,14 +363,19 @@ func chains() []Chain { {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_BASE_BLOXROUTE", "https://base.rpc.blxrbdn.com")}, }, }, - // ─── BNB Chain (7 providers) ──────────────────────────────── + // ─── BNB Chain (5 providers) ──────────────────────────────── + // + // Removed 2026-10-11. Nodies: bsc-pokt.nodies.app answers HTTP 403, + // paid plan required, 0 percent ok over 24h. dRPC: bsc.drpc.org + // rate-limits the probe cadence ("You reached Public endpoint rate + // limit"), 0 of 10 live tries and 30 percent ok over 24h, so its + // published p50 came from the 30 percent that got through. dRPC is + // healthy on every other chain here and stays in those cohorts. { Slug: "bnb", Name: "BNB Chain", Providers: []Provider{ {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_BNB_PUBLICNODE", "https://bsc-rpc.publicnode.com")}, - {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_BNB_DRPC", "https://bsc.drpc.org")}, - {Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_BNB_NODIES", "https://bsc-pokt.nodies.app")}, {Slug: "binance", Name: "Binance Official", URL: envDefault("RPC_URL_BNB_OFFICIAL", "https://bsc-dataseed1.binance.org")}, {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_BNB_BLOXROUTE", "https://bsc.rpc.blxrbdn.com")}, {Slug: "nownodes", Name: "NOWNodes", URL: envDefault("RPC_URL_BNB_NOWNODES", "https://public-bsc.nownodes.io")}, @@ -465,10 +477,9 @@ func chains() []Chain { Slug: "moonbeam", Name: "Moonbeam", Providers: []Provider{ - {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_MOONBEAM_PUBLICNODE", "https://moonbeam-rpc.publicnode.com")}, {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_MOONBEAM_DRPC", "https://moonbeam.drpc.org")}, - {Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_MOONBEAM_TENDERLY", "https://gateway.tenderly.co/public/moonbeam")}, - {Slug: "moonbeam-official", Name: "Moonbeam Official", URL: envDefault("RPC_URL_MOONBEAM_OFFICIAL", "https://rpc.api.moonbeam.network")}, + {Slug: "onfinality", Name: "OnFinality", URL: envDefault("RPC_URL_MOONBEAM_ONFINALITY", "https://moonbeam.api.onfinality.io/public")}, + {Slug: "unitedbloc", Name: "UnitedBloc", URL: envDefault("RPC_URL_MOONBEAM_UNITEDBLOC", "https://moonbeam.unitedbloc.com")}, }, }, // ─── Berachain (4 providers) ──────────────────────────────── @@ -910,9 +921,9 @@ func chains() []Chain { Slug: "moonriver", Name: "Moonriver", Providers: []Provider{ - {Slug: "moonriver-official", Name: "Moonbeam Foundation", URL: envDefault("RPC_URL_MOONRIVER_OFFICIAL", "https://rpc.api.moonriver.moonbeam.network")}, - {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_MOONRIVER_PUBLICNODE", "https://moonriver-rpc.publicnode.com")}, {Slug: "onfinality", Name: "OnFinality", URL: envDefault("RPC_URL_MOONRIVER_ONFINALITY", "https://moonriver.api.onfinality.io/public")}, + {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_MOONRIVER_DRPC", "https://moonriver.drpc.org")}, + {Slug: "unitedbloc", Name: "UnitedBloc", URL: envDefault("RPC_URL_MOONRIVER_UNITEDBLOC", "https://moonriver.unitedbloc.com")}, }, }, // 2026-08-03 audit. Hemi BTC+ETH hybrid OP Stack L2 (chain 43111). 3 clean @@ -1117,7 +1128,8 @@ func chains() []Chain { Name: "Astar", Providers: []Provider{ {Slug: "onfinality", Name: "OnFinality", URL: envDefault("RPC_URL_ASTAR_ONFINALITY", "https://astar.api.onfinality.io/public")}, - {Slug: "1rpc", Name: "1RPC", URL: envDefault("RPC_URL_ASTAR_1RPC", "https://1rpc.io/astr")}, + {Slug: "astar-official", Name: "Astar", URL: envDefault("RPC_URL_ASTAR_OFFICIAL", "https://evm.astar.network")}, + {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_ASTAR_DRPC", "https://astar.drpc.org")}, }, }, // Oasis Sapphire — Confidential EVM paratime (chain 23294). 1 keyless provider. @@ -1189,7 +1201,7 @@ func chains() []Chain { Name: "Gravity", Providers: []Provider{ {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_GRAVITY_PUBLICNODE", "https://rpc.gravity.xyz")}, - {Slug: "ankr", Name: "Ankr", URL: envDefault("RPC_URL_GRAVITY_ANKR", "https://rpc.ankr.com/gravity")}, + {Slug: "thirdweb", Name: "Thirdweb", URL: envDefault("RPC_URL_GRAVITY_THIRDWEB", "https://1625.rpc.thirdweb.com")}, }, }, // Reya Network — Trading EVM L2 (chain 1729). 2 keyless providers. @@ -1930,6 +1942,7 @@ func chains() []Chain { {Slug: "tenderly", Name: "Tenderly", URL: envDefault("RPC_URL_KATANA_TENDERLY", "https://katana.gateway.tenderly.co")}, {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_KATANA_DRPC", "https://katana.drpc.org")}, {Slug: "thirdweb", Name: "Thirdweb", URL: envDefault("RPC_URL_KATANA_THIRDWEB", "https://747474.rpc.thirdweb.com")}, + {Slug: "conduit", Name: "Conduit", URL: envDefault("RPC_URL_KATANA_CONDUIT", "https://rpc.katanarpc.com")}, }, }, // 2026-10-07 chain-hunt sweep. Superseed (chain 5330), OP Stack L2. 3 clean keyless providers; publicnode has no superseed route. @@ -2028,6 +2041,7 @@ func chains() []Chain { {Slug: "xrplevm-official", Name: "Peersyst", URL: envDefault("RPC_URL_XRPLEVM_XRPLEVM_OFFICIAL", "https://cosmos-rpc.xrplevm.org")}, {Slug: "cumulo", Name: "Cumulo", URL: envDefault("RPC_URL_XRPLEVM_CUMULO", "https://rpc.xrpl.cumulo.org.es")}, {Slug: "polkachu", Name: "Polkachu", URL: envDefault("RPC_URL_XRPLEVM_POLKACHU", "https://xrp-rpc.polkachu.com")}, + {Slug: "itrocket", Name: "ITRocket", URL: envDefault("RPC_URL_XRPLEVM_ITROCKET", "https://xrplevm-mainnet-rpc.itrocket.net")}, }, }, } diff --git a/harnesses/rpc-keyed-latency/cmd/script/config.go b/harnesses/rpc-keyed-latency/cmd/script/config.go index 4948d78f9..977f685d0 100644 --- a/harnesses/rpc-keyed-latency/cmd/script/config.go +++ b/harnesses/rpc-keyed-latency/cmd/script/config.go @@ -23,7 +23,7 @@ type Endpoint struct { // 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", "getblock", "dwellir"} +var providers = []string{"infura", "alchemy", "chainstack", "ankr", "helius", "quicknode", "getblock", "dwellir", "validationcloud"} // Per-provider probe-interval multiplier. Infura's free tier 402'd daily // at the old 60 s cadence (its real daily credit budget is below the @@ -135,6 +135,12 @@ var defaultBudgets = map[string]int64{ // Dwellir does not serve Solana, and a cell with no URL is simply // skipped, so that chain stays at four providers with no special casing. "dwellir": 1_000_000, + // validationcloud: plan not yet disclosed by the account holder, so this + // is a runaway guard rather than a quota. Six chains at the 120 s cadence + // cost 130k per region per month; raise or lower it once the plan is + // known, and disclose the tier in each page's methodology the way every + // other keyed provider is disclosed. + "validationcloud": 600_000, } func budgetFor(provider string) int64 { diff --git a/harnesses/rpc-keyed-latency/cmd/script/egress.go b/harnesses/rpc-keyed-latency/cmd/script/egress.go new file mode 100644 index 000000000..509d2d904 --- /dev/null +++ b/harnesses/rpc-keyed-latency/cmd/script/egress.go @@ -0,0 +1,315 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// Does this probe actually sit where its label claims? +// +// Every sample is published under region="sgp" or "us-east" or "eu-west", but +// that label is our claim about the container, not a measurement of where the +// traffic comes from. On 2026-10-14 the Singapore container egressed through a +// Railway IP registered in San Francisco (152.55.188.14). Four of the six +// providers pick a point of presence from the client IP, so they answered from +// the United States — correctly, from their side — and we published ~200 ms +// under the label "Singapore" for about ninety minutes. It happened twice that +// day, both times after a deploy, because each new container drew a new egress +// IP and only some of Railway's blocks are registered in Asia. +// +// The two providers that did not move say why this matters and why it will +// recur: GetBlock puts the region in the hostname and QuickNode uses anycast, +// so neither consults the client IP. Everyone else must, and will keep +// trusting the registry over our label. +// +// Static outbound IPs now pin the Singapore egress, which removes the draw but +// not the class of bug: the pinned addresses are shared rather than dedicated, +// the other two regions are not pinned, and a registry entry can change under +// us at any time. Nothing in the pipeline would notice. This does. +// +// Two rules shape the implementation. Fail open: a third-party geolocation +// service being unreachable must never stop a measurement, so an inconclusive +// lookup changes nothing. Fail loud, and only on agreement: suppression needs +// two independent sources naming the same country, that country on the wrong +// continent, twice in a row. Blanking a region on one bad lookup would be a +// worse failure than the one this guards against. + +var ( + probeEgressMatch = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "rpc_probe_egress_match", + Help: "1 when this probe's egress IP geolocates to its declared region's continent, 0 on a confirmed mismatch, -1 when the lookup was inconclusive.", + }, + []string{"region"}, + ) + + // Info-style series: the value is always 1, the labels carry the reading. + // Deliberately low cardinality per probe (one series per region, replaced + // when the address changes) so it can be read straight off a dashboard + // when a region looks wrong. + probeEgressInfo = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "rpc_probe_egress_info", + Help: "Always 1. Labels carry where this probe's egress IP says it is.", + }, + []string{"region", "continent", "country", "city", "ip"}, + ) + + probeEgressSuppressed = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "rpc_probe_egress_suppressed", + Help: "1 while this probe is withholding latency samples because its egress is on the wrong continent.", + }, + []string{"region"}, + ) + + // Its own counter rather than a result value on rpc_call_total: that one is + // the providers' reliability record and feeds the published success rate, + // so our own misrouting must not land in it. + probeEgressDropped = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "rpc_probe_egress_dropped_total", + Help: "Successful probe responses discarded because this probe's egress was on the wrong continent.", + }, + []string{"region"}, + ) +) + +// The continent each probe region must egress from. Continent rather than +// country on purpose: the failure worth suppressing is "Singapore served from +// North America", a whole ocean. A Singapore probe egressing from Tokyo is +// mildly wrong and still far more useful than no data, and pinning a country +// would also false-positive the day Railway moves eu-west from Amsterdam to +// Frankfurt. +var regionContinent = map[string]string{ + "us-east": "NA", + "eu-west": "EU", + "sgp": "AS", +} + +func expectedContinent() string { + if v := strings.ToUpper(strings.TrimSpace(os.Getenv("RPC_KEYED_EXPECT_CONTINENT"))); v != "" { + return v + } + return regionContinent[currentRegion] +} + +func egressEnforced() bool { + // Enforcement on by default: a detector that only watches still leaves the + // wrong number published under the right label, which is the whole defect. + switch strings.ToLower(strings.TrimSpace(os.Getenv("RPC_KEYED_EGRESS_ENFORCE"))) { + case "0", "false", "no", "off": + return false + } + return true +} + +func egressInterval() time.Duration { + if v := strings.TrimSpace(os.Getenv("RPC_KEYED_EGRESS_SECONDS")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 60 { + return time.Duration(n) * time.Second + } + } + return 10 * time.Minute +} + +// Suppression needs this many consecutive confirmed mismatches. Two at a ten +// minute cadence means a region goes dark twenty minutes after a real move, +// and never on a single odd reading. +const egressStrikesToSuppress = 2 + +type egressGuard struct { + mu sync.RWMutex + suppressed bool + strikes int + lastIP string +} + +var egress = &egressGuard{} + +// egressPublishable reports whether latency samples may be recorded. It starts +// true and only ever goes false after confirmation, so a probe that cannot +// reach a geolocation service keeps measuring. +func egressPublishable() bool { + egress.mu.RLock() + defer egress.mu.RUnlock() + return !egress.suppressed +} + +type egressReading struct { + ip string + country string + continent string + city string +} + +// Its own client and transport: the probe's pools are the thing under +// measurement and must not carry unrelated traffic. +var egressHTTP = &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext, + DisableKeepAlives: true, + }, +} + +func lookupIPAPI(ctx context.Context) (egressReading, error) { + var out egressReading + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + "http://ip-api.com/json/?fields=status,continentCode,countryCode,city,query", nil) + if err != nil { + return out, err + } + resp, err := egressHTTP.Do(req) + if err != nil { + return out, err + } + defer func() { _ = resp.Body.Close() }() + var body struct { + Status string `json:"status"` + ContinentCode string `json:"continentCode"` + CountryCode string `json:"countryCode"` + City string `json:"city"` + Query string `json:"query"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return out, err + } + if body.Status != "success" || body.CountryCode == "" { + return out, fmt.Errorf("ip-api status %q", body.Status) + } + return egressReading{ + ip: body.Query, + country: strings.ToUpper(body.CountryCode), + continent: strings.ToUpper(body.ContinentCode), + city: body.City, + }, nil +} + +// Second opinion. Only its country is used, to corroborate the first source; +// the continent always comes from ip-api so there is no country-to-continent +// table to keep correct. +func lookupIPInfo(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://ipinfo.io/json", nil) + if err != nil { + return "", err + } + resp, err := egressHTTP.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + var body struct { + Country string `json:"country"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", err + } + if body.Country == "" { + return "", fmt.Errorf("ipinfo returned no country") + } + return strings.ToUpper(body.Country), nil +} + +// checkEgress runs one round and updates the metrics and the guard. +func checkEgress(ctx context.Context) { + want := expectedContinent() + if want == "" { + // An unmapped region cannot be judged, and guessing would suppress a + // region for having an unfamiliar name. + probeEgressMatch.WithLabelValues(currentRegion).Set(-1) + return + } + + primary, err := lookupIPAPI(ctx) + if err != nil { + fmt.Printf("[egress] lookup failed, keeping previous verdict: %v\n", err) + probeEgressMatch.WithLabelValues(currentRegion).Set(-1) + return + } + second, err := lookupIPInfo(ctx) + if err != nil { + fmt.Printf("[egress] second opinion unavailable, not acting: %v\n", err) + probeEgressMatch.WithLabelValues(currentRegion).Set(-1) + return + } + if second != primary.country { + fmt.Printf("[egress] sources disagree (%s vs %s), not acting\n", primary.country, second) + probeEgressMatch.WithLabelValues(currentRegion).Set(-1) + return + } + + egress.mu.Lock() + if primary.ip != egress.lastIP { + // One series per address: the old labels would otherwise sit at 1 + // forever and a dashboard would show two places at once. + probeEgressInfo.Reset() + egress.lastIP = primary.ip + } + egress.mu.Unlock() + probeEgressInfo.WithLabelValues(currentRegion, primary.continent, primary.country, + primary.city, primary.ip).Set(1) + + if primary.continent == want { + egress.mu.Lock() + was := egress.suppressed + egress.strikes = 0 + egress.suppressed = false + egress.mu.Unlock() + probeEgressMatch.WithLabelValues(currentRegion).Set(1) + probeEgressSuppressed.WithLabelValues(currentRegion).Set(0) + if was { + fmt.Printf("[egress] back on %s (%s, %s via %s), publishing again\n", + want, primary.city, primary.country, primary.ip) + } + return + } + + egress.mu.Lock() + egress.strikes++ + strikes := egress.strikes + if egressEnforced() && strikes >= egressStrikesToSuppress { + egress.suppressed = true + } + suppressed := egress.suppressed + egress.mu.Unlock() + + probeEgressMatch.WithLabelValues(currentRegion).Set(0) + if suppressed { + probeEgressSuppressed.WithLabelValues(currentRegion).Set(1) + } + fmt.Printf("[egress] MISMATCH region=%s expected=%s got=%s (%s, %s via %s) strike=%d suppressed=%v\n", + currentRegion, want, primary.continent, primary.city, primary.country, + primary.ip, strikes, suppressed) +} + +// StartEgressWatch checks immediately, then on a ticker. The first check runs +// in the background rather than blocking startup: the probe loop may publish a +// cycle or two before the first verdict, which is the right trade against a +// probe that will not start because a geolocation service is slow. +func StartEgressWatch(ctx context.Context) { + go func() { + checkEgress(ctx) + t := time.NewTicker(egressInterval()) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + checkEgress(ctx) + } + } + }() +} diff --git a/harnesses/rpc-keyed-latency/cmd/script/main.go b/harnesses/rpc-keyed-latency/cmd/script/main.go index beb6ccc89..7f3db94ab 100644 --- a/harnesses/rpc-keyed-latency/cmd/script/main.go +++ b/harnesses/rpc-keyed-latency/cmd/script/main.go @@ -72,7 +72,15 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Before trusting the region label on anything this probe publishes, check + // that the traffic really leaves from that part of the world. See egress.go + // for the ninety minutes of Singapore numbers served from California. + StartEgressWatch(ctx) StartProbeLoop(ctx) + // The WebSocket arm shares this process on purpose: its headline metric is + // a race between providers on the same block, and arrival times from + // separate processes would compare clocks. See ws.go. + StartWSLoop(ctx) sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) diff --git a/harnesses/rpc-keyed-latency/cmd/script/probe.go b/harnesses/rpc-keyed-latency/cmd/script/probe.go index 94b6f1162..902474fce 100644 --- a/harnesses/rpc-keyed-latency/cmd/script/probe.go +++ b/harnesses/rpc-keyed-latency/cmd/script/probe.go @@ -433,6 +433,25 @@ func probeOne(ctx context.Context, e Endpoint) { result = "stale" } } + // A sample is only publishable if its region label is true. When this + // probe's egress has been confirmed to sit on the wrong continent, the + // call succeeded but its geography is a lie, so it is dropped rather + // than recorded under a region it did not come from. Same treatment as + // a quota pause: delete the series instead of leaving it frozen at its + // last good number. + // + // Deliberately NOT counted in rpc_call_total. That counter is the + // provider's reliability record and feeds the published success rate; + // filing our own misrouting there would dent every provider in the + // region for a fault on our side. The drop is counted on its own + // series instead. + if result == "ok" && !egressPublishable() { + probeEgressDropped.WithLabelValues(currentRegion).Inc() + rpcLatency.DeleteLabelValues(e.Provider, e.Chain, currentRegion, "keyed") + rpcLatencyCold.DeleteLabelValues(e.Provider, e.Chain, currentRegion, "keyed") + rpcHealth.WithLabelValues(e.Provider, e.Chain, currentRegion, "keyed").Set(0) + return + } rpcCallTotal.WithLabelValues(e.Provider, e.Chain, currentRegion, result, "keyed").Inc() if result == "ok" { // Same rule as the no-key harness: latency is recorded ONLY diff --git a/harnesses/rpc-keyed-latency/cmd/script/ws.go b/harnesses/rpc-keyed-latency/cmd/script/ws.go new file mode 100644 index 000000000..1850f8548 --- /dev/null +++ b/harnesses/rpc-keyed-latency/cmd/script/ws.go @@ -0,0 +1,564 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// The WebSocket arm of the keyed cohort. +// +// Why it lives in this binary rather than its own: the headline metric is a +// race. For one block, which provider pushed it first? Arrival times from +// separate processes would compare clocks as much as providers, and the +// differences we are measuring are tens of milliseconds. One process, one +// clock, every provider on the same chain. +// +// What it deliberately does NOT publish is time-to-first-head. A discovery run +// measured 7 to 11 seconds on Ethereum and 240 to 490 ms on Robinhood Chain +// for every provider alike, because that number is the chain's block time with +// a provider's name attached. The race cancels block time out: all providers +// wait for the same block, so only the spread between them is theirs. +// +// Four things are published instead: +// subscribe round trip the provider answering eth_subscribe, their latency +// head lead ms behind the first provider to push that block +// first-to-push count how often they won, over how many blocks they saw +// socket round trip a call on the already-open socket, the WS analogue +// of the HTTP warm arm +// plus connection stability, which nobody publishes and which is what actually +// breaks a production bot. + +var ( + wsSubscribeMs = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "rpc_ws_subscribe_milliseconds", + Help: "Round trip for eth_subscribe(newHeads) / slotSubscribe on a freshly opened keyed WebSocket.", + }, + []string{"provider", "chain", "region", "tier"}, + ) + + // Buckets copied from bench 081, which solved this first and for a reason + // worth repeating: the winner reads 0 by construction, so a coarse first + // bucket collapses every leader's p50 into an interpolation artifact + // inside it. A 1 ms floor is not enough either, hence 0.5. The range runs + // to 10 s to cover a straggler on a 12 s Ethereum block. + // + // The HTTP arm's buckets start at 50 ms and cannot resolve any of this. + wsHeadLeadHist = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "rpc_ws_head_lead_milliseconds_histogram", + Help: "Milliseconds behind the first provider to push a given block. 0 means it won the race.", + Buckets: []float64{ + 0.5, 1, 2, 3, 5, 8, 12, 20, 35, 60, + 100, 160, 260, 420, 680, 1100, 1800, 3000, 5000, 10000, + }, + }, + []string{"provider", "chain", "region", "tier"}, + ) + + wsHeadLeadMs = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "rpc_ws_head_lead_milliseconds", + Help: "Latest observed milliseconds behind the first provider to push a block.", + }, + []string{"provider", "chain", "region", "tier"}, + ) + + wsHeadFirstTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "rpc_ws_head_first_total", + Help: "Blocks this provider pushed before every other provider on the same chain.", + }, + []string{"provider", "chain", "region", "tier"}, + ) + + wsHeadSeenTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "rpc_ws_head_seen_total", + Help: "Blocks this provider pushed at all. Denominator for the first-to-push rate.", + }, + []string{"provider", "chain", "region", "tier"}, + ) + + wsCallMs = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "rpc_ws_call_milliseconds", + Help: "Round trip for eth_blockNumber / getSlot sent on an already-open keyed WebSocket.", + }, + []string{"provider", "chain", "region", "tier"}, + ) + + wsCallHist = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "rpc_ws_call_milliseconds_histogram", + Help: "Histogram of calls on an open keyed WebSocket, for p50/p90/p99.", + Buckets: []float64{1, 2, 5, 10, 20, 35, 50, 75, 100, 150, 250, 500, 1000, 2500}, + }, + []string{"provider", "chain", "region", "tier"}, + ) + + wsConnected = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "rpc_ws_connected", + Help: "1 while the keyed WebSocket is open and subscribed, 0 otherwise.", + }, + []string{"provider", "chain", "region", "tier"}, + ) + + wsDisconnectTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "rpc_ws_disconnect_total", + Help: "Times the keyed WebSocket dropped and had to be reopened.", + }, + []string{"provider", "chain", "region", "tier"}, + ) + + // Message volume, because a subscription is billed per push on some plans + // and a quiet cost overrun is the failure mode nobody watches for. Nine + // chains at 12 s blocks is roughly 7k messages a day per endpoint. + wsMessagesTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "rpc_ws_messages_total", + Help: "Frames received on keyed WebSockets, for cost accounting.", + }, + []string{"provider", "chain", "region", "tier"}, + ) +) + +// wsEndpoint is one (provider × chain) WebSocket cell, declared the same way +// the HTTP ones are: explicitly, one env var per cell. Deriving wss:// from the +// HTTP variable would be convenient and is exactly how a probe ends up +// measuring something other than what the page claims, so the URL is never +// guessed here. +type wsEndpoint struct { + Provider string + Chain string + Kind string + URL string +} + +func wsEnvURL(provider, chain string) string { + key := fmt.Sprintf("RPC_KEYED_WS_%s_%s", strings.ToUpper(provider), strings.ToUpper(chain)) + v := strings.TrimSpace(os.Getenv(key)) + switch strings.ToLower(v) { + case "", "disabled", "off", "-": + return "" + } + if !strings.HasPrefix(v, "ws://") && !strings.HasPrefix(v, "wss://") { + fmt.Printf("[ws][config] %s ignored: not a ws(s) URL\n", key) + return "" + } + return v +} + +// Chains the WebSocket arm does not race, because their block rate makes +// them most of the bill. Measured on 2026-10-17 over 24h: robinhood 40% of +// all pushes, solana 19%, arbitrum 14%, against ethereum's 0.4%. A chain +// producing a block every 100 ms pushes 120 times what a 12 s chain does, +// for a race that answers the same question. +// +// Compiled in, not read from the environment by default: this list exists +// because a provider's monthly limit was reached, and a lost variable must +// not be able to restore that bill in silence. RPC_KEYED_WS_SKIP_CHAINS +// overrides it, and the value "none" empties it. +var wsSkipChainsDefault = []string{"robinhood", "solana", "arbitrum"} + +func wsSkipChains() map[string]bool { + list := wsSkipChainsDefault + if v := strings.TrimSpace(os.Getenv("RPC_KEYED_WS_SKIP_CHAINS")); v != "" { + if strings.EqualFold(v, "none") { + list = nil + } else { + list = strings.Split(v, ",") + } + } + out := map[string]bool{} + for _, c := range list { + if c = strings.ToLower(strings.TrimSpace(c)); c != "" { + out[c] = true + } + } + return out +} + +func wsEndpoints() []wsEndpoint { + var out []wsEndpoint + skip := wsSkipChains() + for _, p := range providers { + for _, c := range chainsEVM { + if !chainAllowed(c) || skip[c] { + continue + } + if url := wsEnvURL(p, c); url != "" { + out = append(out, wsEndpoint{Provider: p, Chain: c, Kind: "evm", URL: url}) + } + } + if url := wsEnvURL(p, "solana"); url != "" && chainAllowed("solana") && !skip["solana"] { + out = append(out, wsEndpoint{Provider: p, Chain: "solana", Kind: "solana", URL: url}) + } + } + return out +} + +func wsEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("RPC_KEYED_WS_ENABLE"))) { + case "0", "false", "no", "off": + return false + } + return true +} + +// The listening window, and how often it comes round. Ten minutes in every +// hour keeps about a sixth of the races and a sixth of the messages. +// +// Both are read from the clock, not from each cell's connect time: every +// provider in a region has to be listening to the SAME blocks or the race +// compares nothing, and the only way to guarantee that without coordination +// between goroutines is to derive the boundary from the wall clock. +func wsDuty() (window, period time.Duration) { + window, period = 10*time.Minute, time.Hour + if v := strings.TrimSpace(os.Getenv("RPC_KEYED_WS_DUTY_SECONDS")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 30 { + window = time.Duration(n) * time.Second + } + } + if v := strings.TrimSpace(os.Getenv("RPC_KEYED_WS_PERIOD_SECONDS")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 60 { + period = time.Duration(n) * time.Second + } + } + if window > period { + window = period // continuous, the old behaviour + } + return window, period +} + +// When the current or next window ends, and how long until it opens. +func wsWindowAt(now time.Time) (wait, remaining time.Duration) { + window, period := wsDuty() + if window >= period { + return 0, period + } + into := time.Duration(now.UnixNano()) % period + if into < window { + return 0, window - into + } + return period - into, window +} + +// How long a block stays in the race table. Long enough that a straggler on a +// slow connection still scores, short enough that the map cannot grow. +func raceWindow() time.Duration { + if v := strings.TrimSpace(os.Getenv("RPC_KEYED_WS_RACE_WINDOW_SECONDS")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 5 { + return time.Duration(n) * time.Second + } + } + return 60 * time.Second +} + +// headRace holds, per chain and block, when the first provider pushed it. +// +// The entry is created by whoever arrives first, so the winner always scores 0 +// and everyone else scores their own distance from it. That makes the metric +// relative and therefore immune to block time, which is the whole point. +type headRace struct { + mu sync.Mutex + first map[string]time.Time // "chain/blocknumber" -> first arrival + order []raceKey // insertion order, for eviction +} + +type raceKey struct { + key string + at time.Time +} + +var race = &headRace{first: map[string]time.Time{}} + +// observe records an arrival and returns how far behind the leader it was. +func (h *headRace) observe(chain string, number *big.Int, at time.Time) time.Duration { + k := chain + "/" + number.String() + h.mu.Lock() + defer h.mu.Unlock() + + cutoff := at.Add(-raceWindow()) + for len(h.order) > 0 && h.order[0].at.Before(cutoff) { + delete(h.first, h.order[0].key) + h.order = h.order[1:] + } + + if t0, ok := h.first[k]; ok { + return at.Sub(t0) + } + h.first[k] = at + h.order = append(h.order, raceKey{key: k, at: at}) + return 0 +} + +func (e wsEndpoint) labels() []string { + return []string{e.Provider, e.Chain, currentRegion, "keyed"} +} + +func (e wsEndpoint) subscribeMsg() []byte { + if e.Kind == "solana" { + return []byte(`{"jsonrpc":"2.0","id":1,"method":"slotSubscribe","params":[]}`) + } + return []byte(`{"jsonrpc":"2.0","id":1,"method":"eth_subscribe","params":["newHeads"]}`) +} + +func (e wsEndpoint) callMsg(id int) []byte { + if e.Kind == "solana" { + return []byte(fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"getSlot","params":[{"commitment":"processed"}]}`, id)) + } + return []byte(fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"eth_blockNumber","params":[]}`, id)) +} + +// headNumber pulls the sequence number out of a push, whichever convention the +// chain uses. Both are needed and catching only one is how a discovery run +// reported "subscribed but nothing pushed" on every EVM endpoint: Solana sends +// slotNotification with a plain integer, EVM sends eth_subscription with a hex +// block number. +func headNumber(raw []byte) (*big.Int, bool) { + var msg struct { + Method string `json:"method"` + Params struct { + Result json.RawMessage `json:"result"` + } `json:"params"` + } + if err := json.Unmarshal(raw, &msg); err != nil { + return nil, false + } + switch { + case msg.Method == "eth_subscription": + var head struct { + Number string `json:"number"` + } + if err := json.Unmarshal(msg.Params.Result, &head); err != nil { + return nil, false + } + n := new(big.Int) + if _, ok := n.SetString(strings.TrimPrefix(head.Number, "0x"), 16); !ok { + return nil, false + } + return n, true + case strings.HasSuffix(msg.Method, "Notification"): + var slot struct { + Slot uint64 `json:"slot"` + } + if err := json.Unmarshal(msg.Params.Result, &slot); err != nil { + return nil, false + } + return new(big.Int).SetUint64(slot.Slot), true + } + return nil, false +} + +// runWS keeps one endpoint connected for the life of the process, reopening it +// with backoff after a drop and counting every drop, because connection +// stability is a published result here and not just an implementation detail. +func runWS(ctx context.Context, e wsEndpoint) { + backoff := 2 * time.Second + for ctx.Err() == nil { + wait, remaining := wsWindowAt(time.Now()) + if wait > 0 { + wsConnected.WithLabelValues(e.labels()...).Set(0) + select { + case <-ctx.Done(): + return + case <-time.After(wait): + } + _, remaining = wsWindowAt(time.Now()) + } + // The session ends with the window. A close on that deadline is a + // scheduled stop, so it must not be logged as a failure nor counted + // as a disconnect: that counter is a published result about the + // provider, not about our scheduler. + sessCtx, cancel := context.WithTimeout(ctx, remaining) + err := wsSession(sessCtx, e) + cancel() + scheduled := errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil + if err != nil && !scheduled && ctx.Err() == nil { + // Redact: a dial error embeds the full URL, which carries the key. + fmt.Printf("[ws][%s/%s] session ended: %s\n", e.Chain, e.Provider, + strings.ReplaceAll(err.Error(), e.URL, hostOf(e.URL))) + } + wsConnected.WithLabelValues(e.labels()...).Set(0) + if scheduled { + backoff = 2 * time.Second // a clean stop is not a failure + continue + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 60*time.Second { + backoff *= 2 + } + } +} + +func wsSession(ctx context.Context, e wsEndpoint) error { + dialer := websocket.Dialer{ + HandshakeTimeout: 15 * time.Second, + Proxy: http.ProxyFromEnvironment, + } + t0 := time.Now() + conn, _, err := dialer.DialContext(ctx, e.URL, nil) + if err != nil { + wsDisconnectTotal.WithLabelValues(e.labels()...).Inc() + return err + } + defer func() { _ = conn.Close() }() + + if err := conn.WriteMessage(websocket.TextMessage, e.subscribeMsg()); err != nil { + wsDisconnectTotal.WithLabelValues(e.labels()...).Inc() + return err + } + + // A call id per session, above the subscribe id, so a late reply from a + // previous call cannot be mistaken for the current one. + callID := 100 + pending := map[int]time.Time{} + subscribed := false + + callTicker := time.NewTicker(60 * time.Second) + defer callTicker.Stop() + + // Drive reads from a goroutine so the call ticker is not blocked behind a + // chain that pushes nothing for a minute. + type frame struct { + raw []byte + at time.Time + err error + } + frames := make(chan frame, 16) + go func() { + for { + _, raw, err := conn.ReadMessage() + at := time.Now() + select { + case frames <- frame{raw: raw, at: at, err: err}: + case <-ctx.Done(): + return + } + if err != nil { + return + } + } + }() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + + case <-callTicker.C: + if !subscribed { + continue + } + callID++ + pending[callID] = time.Now() + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := conn.WriteMessage(websocket.TextMessage, e.callMsg(callID)); err != nil { + wsDisconnectTotal.WithLabelValues(e.labels()...).Inc() + return err + } + + case f := <-frames: + if f.err != nil { + // Our own window closing races the read goroutine. Only + // count a drop the provider actually caused. + if ctx.Err() != nil { + return ctx.Err() + } + wsDisconnectTotal.WithLabelValues(e.labels()...).Inc() + return f.err + } + wsMessagesTotal.WithLabelValues(e.labels()...).Inc() + + // The subscribe acknowledgement, and the only moment the dial + // timing means anything. + var ack struct { + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(f.raw, &ack); err == nil && ack.ID != 0 { + if ack.Error != nil { + return fmt.Errorf("rpc error: %s", ack.Error.Message) + } + if ack.ID == 1 && !subscribed { + subscribed = true + wsConnected.WithLabelValues(e.labels()...).Set(1) + if egressPublishable() { + wsSubscribeMs.WithLabelValues(e.labels()...).Set( + float64(f.at.Sub(t0).Milliseconds())) + } + continue + } + if sentAt, ok := pending[ack.ID]; ok { + delete(pending, ack.ID) + if egressPublishable() { + ms := float64(f.at.Sub(sentAt).Microseconds()) / 1000 + wsCallMs.WithLabelValues(e.labels()...).Set(ms) + wsCallHist.WithLabelValues(e.labels()...).Observe(ms) + } + } + continue + } + + n, ok := headNumber(f.raw) + if !ok { + continue + } + lead := race.observe(e.Chain, n, f.at) + // Same rule as the HTTP arm: a sample published under a region + // label that is not true is worse than no sample. + if !egressPublishable() { + continue + } + ms := float64(lead.Microseconds()) / 1000 + wsHeadSeenTotal.WithLabelValues(e.labels()...).Inc() + wsHeadLeadMs.WithLabelValues(e.labels()...).Set(ms) + wsHeadLeadHist.WithLabelValues(e.labels()...).Observe(ms) + if lead == 0 { + wsHeadFirstTotal.WithLabelValues(e.labels()...).Inc() + } + } + } +} + +// StartWSLoop opens one long-lived connection per declared cell. +func StartWSLoop(ctx context.Context) { + if !wsEnabled() { + fmt.Println("[ws] disabled by RPC_KEYED_WS_ENABLE") + return + } + eps := wsEndpoints() + if len(eps) == 0 { + fmt.Println("[ws] no RPC_KEYED_WS_* env vars set — WebSocket arm idle") + return + } + window, period := wsDuty() + fmt.Printf("[ws] %d endpoints, race window %s, listening %s every %s, skipping %v\n", + len(eps), raceWindow(), window, period, wsSkipChainsDefault) + for _, e := range eps { + go runWS(ctx, e) + } +} diff --git a/harnesses/rpc-keyed-latency/cmd/script/ws_window_test.go b/harnesses/rpc-keyed-latency/cmd/script/ws_window_test.go new file mode 100644 index 000000000..4d11ef3cc --- /dev/null +++ b/harnesses/rpc-keyed-latency/cmd/script/ws_window_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "testing" + "time" +) + +// The duty cycle's arithmetic is the one part of it that can fail silently. +// +// If two providers compute different window boundaries they listen to +// different blocks, and the race still produces numbers: each one is its own +// leader, every lead reads zero, and the board says every provider is +// fastest. That is worse than an outage, so the boundary is derived from the +// wall clock and pinned here. +func TestWindowIsAlignedToTheClock(t *testing.T) { + t.Setenv("RPC_KEYED_WS_DUTY_SECONDS", "600") + t.Setenv("RPC_KEYED_WS_PERIOD_SECONDS", "3600") + + cases := []struct { + at string + wait time.Duration + remaining time.Duration + }{ + // Inside the window: listen now, until it closes. + {"2026-10-17T09:00:00Z", 0, 10 * time.Minute}, + {"2026-10-17T09:03:00Z", 0, 7 * time.Minute}, + {"2026-10-17T09:09:59Z", 0, time.Second}, + // Outside it: wait for the top of the next hour. + {"2026-10-17T09:10:00Z", 50 * time.Minute, 10 * time.Minute}, + {"2026-10-17T09:30:00Z", 30 * time.Minute, 10 * time.Minute}, + {"2026-10-17T09:59:30Z", 30 * time.Second, 10 * time.Minute}, + } + for _, c := range cases { + now, err := time.Parse(time.RFC3339, c.at) + if err != nil { + t.Fatal(err) + } + wait, remaining := wsWindowAt(now) + if wait != c.wait || remaining != c.remaining { + t.Errorf("at %s: got wait=%s remaining=%s, want wait=%s remaining=%s", + c.at, wait, remaining, c.wait, c.remaining) + } + } +} + +// Two cells in different goroutines, asked at slightly different instants, +// must agree on when the window opens. This is the property the race needs. +func TestEveryCellAgreesOnTheSameWindow(t *testing.T) { + t.Setenv("RPC_KEYED_WS_DUTY_SECONDS", "600") + t.Setenv("RPC_KEYED_WS_PERIOD_SECONDS", "3600") + + base, _ := time.Parse(time.RFC3339, "2026-10-17T09:42:00Z") + first, _ := wsWindowAt(base) + openAt := base.Add(first) + for _, skew := range []time.Duration{0, 7 * time.Millisecond, 400 * time.Millisecond, 3 * time.Second} { + at := base.Add(skew) + wait, _ := wsWindowAt(at) + if got := at.Add(wait); !got.Equal(openAt) { + t.Errorf("skew %s opens at %s, want %s", skew, got, openAt) + } + } +} + +// A window as long as its period is the old continuous behaviour, which is +// how the arm is restored without touching code. +func TestAWindowAsLongAsThePeriodNeverCloses(t *testing.T) { + t.Setenv("RPC_KEYED_WS_DUTY_SECONDS", "3600") + t.Setenv("RPC_KEYED_WS_PERIOD_SECONDS", "3600") + now := time.Now() + wait, remaining := wsWindowAt(now) + if wait != 0 { + t.Errorf("wait = %s, want 0", wait) + } + if remaining != time.Hour { + t.Errorf("remaining = %s, want 1h", remaining) + } +} + +// The skip list is compiled in so a lost variable cannot restore the bill, +// and "none" is the deliberate way to empty it. +func TestSkipListDefaultsToTheThreeFastestChains(t *testing.T) { + t.Setenv("RPC_KEYED_WS_SKIP_CHAINS", "") + skip := wsSkipChains() + for _, c := range []string{"robinhood", "solana", "arbitrum"} { + if !skip[c] { + t.Errorf("%s should be skipped by default", c) + } + } + if skip["ethereum"] { + t.Error("ethereum must not be skipped by default") + } + + t.Setenv("RPC_KEYED_WS_SKIP_CHAINS", "none") + if len(wsSkipChains()) != 0 { + t.Error(`"none" should empty the skip list`) + } + + t.Setenv("RPC_KEYED_WS_SKIP_CHAINS", " Base , POLYGON ") + skip = wsSkipChains() + if !skip["base"] || !skip["polygon"] || skip["robinhood"] { + t.Errorf("an explicit list should replace the default, got %v", skip) + } +} diff --git a/harnesses/rpc-keyed-latency/go.mod b/harnesses/rpc-keyed-latency/go.mod index 0b461bb6b..a51b2a45b 100644 --- a/harnesses/rpc-keyed-latency/go.mod +++ b/harnesses/rpc-keyed-latency/go.mod @@ -2,7 +2,10 @@ module rpc-keyed-latency go 1.24.0 -require github.com/prometheus/client_golang v1.23.2 +require ( + github.com/gorilla/websocket v1.5.3 + github.com/prometheus/client_golang v1.23.2 +) require ( github.com/beorn7/perks v1.0.1 // indirect diff --git a/harnesses/rpc-keyed-latency/go.sum b/harnesses/rpc-keyed-latency/go.sum index d6b8ca98b..02807ba4c 100644 --- a/harnesses/rpc-keyed-latency/go.sum +++ b/harnesses/rpc-keyed-latency/go.sum @@ -7,6 +7,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/harnesses/terminal-activity/cmd/script/metrics.go b/harnesses/terminal-activity/cmd/script/metrics.go index 0f797efe0..594879953 100644 --- a/harnesses/terminal-activity/cmd/script/metrics.go +++ b/harnesses/terminal-activity/cmd/script/metrics.go @@ -51,6 +51,32 @@ const headlineChain = "solana" // series; bench 283 spent a day with a blank headline column for exactly that. const aggregateChain = "all" +// The two gates that decide whether a take rate is publishable. Both apply to +// the daily cells in this file and to the 1d, 7d and 30d windows in +// windows.go, which feed a different board off the same source and were the +// half of the problem the first fix missed. +const ( + // Ceiling above which a take rate is a misattribution rather than a fee. + // + // A statement about trading, not a tuned threshold: a terminal that kept a + // tenth of every ticket would have no users. Measured across the cohort on + // 2026-10-17, every honest cell sits between 0.39% and 2.20%, the single + // highest being maestro on HyperEVM at 4.87% on $1,039 of volume. Ten + // percent clears the real spread by more than double and still catches the + // broken cell by a factor of six. + maxPlausibleFeeRate = 10.0 + + // Minimum share of a platform's volume that must sit on fee-reporting + // chains or days before an all-chains take rate is published. + // + // Measured on 2026-10-17: seven of the eight platforms report fees on + // chains carrying 95.9% to 100% of their volume. pump.fun reports them on + // 0.003% of its, and the resulting 63.9% reached a public compare page and + // a Community Note. Half is far below every honest platform and far above + // the broken one, so it separates them without being tuned to either. + minFeeCoverage = 0.5 +) + // aliasesFor returns the labels a per-chain sample is published under. Only // the headline chain doubles as `all`, and only for the wallet-denominated // metrics; the summable ones get their `all` written once, from the totals. @@ -142,6 +168,15 @@ var ( Help: "1 when this platform and chain reported exactly zero fees on material volume while the same platform reports fees on another chain, so the zero is an unexplained gap rather than a price. The take rate is not published for that cell.", }, []string{"platform", "chain"}) + // What share of a platform's volume the all-chains take rate was actually + // measured on. Published for every platform so a page can show the + // denominator, and gated on below: a rate over 0.003% of the volume + // beside it is not that platform's take rate. + feeCoveragePct = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "terminal_fee_coverage_pct", + Help: "percent of all-chains volume covered by the chains whose fees the take rate uses", + }, []string{"platform"}) + feeColumnOK = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "terminal_fee_column_ok", Help: "1 when at least one platform on this chain reported non-zero fees on the data day, 0 when the whole column was zero. A zero column is a source failure, not a market where every platform is free.", @@ -153,6 +188,7 @@ func init() { volumeUSD, txns, feesUSD, wallets, avgTradeUSD, feeRatePct, tradesPerWallet, volumePerWalletUSD, chainBreadth, dataDayUnix, health, lastSuccessUnix, feeColumnOK, feeWithheld, feeRateHealth, + feeCoveragePct, ) } @@ -276,7 +312,16 @@ func publish(samples []sample, roster []string, maxAgeDays int, now time.Time) ( chainSet[s.Chain] = true unexplainedZero := s.Fees <= 0 && platformFeesAnywhere[s.Platform] - feeUsable := feesSeen[s.Chain] && !unexplainedZero + // A cell whose fees exceed maxPlausibleFeeRate of its own volume is not + // reporting a take rate, it is reporting a misattribution: nobody trades + // through a terminal that keeps a tenth of the ticket. pump.fun's Base + // cell read 63.9% on 2026-10-17, $910.86 of fees against $1,424.96 of + // volume, and that cell was also the platform's only fee-bearing chain, + // so it set the all-chains headline too and reached a public compare + // page. The coverage gate below catches the headline; this catches the + // per-chain cell, which the coverage gate cannot see. + implausible := s.Volume > 0 && s.Fees/s.Volume*100 > maxPlausibleFeeRate + feeUsable := feesSeen[s.Chain] && !unexplainedZero && !implausible // Summable metrics: written under the real chain only. Their `all` is // computed from the totals after this loop, never copied from one chain. @@ -295,7 +340,11 @@ func publish(samples []sample, roster []string, maxAgeDays int, now time.Time) ( feesUSD.DeleteLabelValues(s.Platform, s.Chain) feeRatePct.DeleteLabelValues(s.Platform, s.Chain) feeRateHealth.WithLabelValues(s.Platform, s.Chain).Set(0) - if unexplainedZero { + // An implausible cell is withheld for the same reason as an + // unexplained zero: the platform does charge, the source just did + // not hand us a figure we can divide. Both are "not measured + // here", and neither is "charges nothing". + if unexplainedZero || implausible { feeWithheld.WithLabelValues(s.Platform, s.Chain).Set(1) } else { feeWithheld.DeleteLabelValues(s.Platform, s.Chain) @@ -350,14 +399,32 @@ func publish(samples []sample, roster []string, maxAgeDays int, now time.Time) ( volumeUSD.WithLabelValues(p, aggregateChain).Set(t.vol) txns.WithLabelValues(p, aggregateChain).Set(t.tx) avgTradeUSD.WithLabelValues(p, aggregateChain).Set(t.vol / t.tx) - if t.feeOK && t.feeVol > 0 { + coverage := 0.0 + if t.vol > 0 { + coverage = t.feeVol / t.vol + } + feeCoveragePct.WithLabelValues(p).Set(coverage * 100) + if t.feeOK && t.feeVol > 0 && coverage >= minFeeCoverage { feesUSD.WithLabelValues(p, aggregateChain).Set(t.fee) feeRatePct.WithLabelValues(p, aggregateChain).Set(t.fee / t.feeVol * 100) feeRateHealth.WithLabelValues(p, aggregateChain).Set(1) + feeWithheld.DeleteLabelValues(p, aggregateChain) } else { + // Withheld, not zero: the platform may well charge a fee, we just + // cannot measure it over a denominator worth printing. + // + // The flag keys off whether the source reported a fee for this + // platform at all, not off whether that fee survived the gates. A + // platform whose only fee-bearing cell was rejected as implausible + // has no usable totals left, and keying off those totals would let + // it go silent, which reads as "no fee charged" on the page. It + // demonstrably charges something; we just cannot say what. feesUSD.DeleteLabelValues(p, aggregateChain) feeRatePct.DeleteLabelValues(p, aggregateChain) feeRateHealth.WithLabelValues(p, aggregateChain).Set(0) + if platformFeesAnywhere[p] { + feeWithheld.WithLabelValues(p, aggregateChain).Set(1) + } } health.WithLabelValues(p, aggregateChain).Set(1) } diff --git a/harnesses/terminal-activity/cmd/script/metrics_test.go b/harnesses/terminal-activity/cmd/script/metrics_test.go index 4d925c99c..1ea906efe 100644 --- a/harnesses/terminal-activity/cmd/script/metrics_test.go +++ b/harnesses/terminal-activity/cmd/script/metrics_test.go @@ -340,3 +340,90 @@ func TestWithheldCellIsUnhealthyOnTheFeeGateOnly(t *testing.T) { t.Errorf("axiom has a real take rate on solana, fee gate must read 1, got %v", got) } } + +// A take rate measured on a sliver of a platform's volume is not that +// platform's take rate. +// +// The live case on 2026-10-17: pump.fun reported fees on Base alone, $910.86 +// on $1,424.96, a real 63.9%. Its all-chains volume was $41,089,797, so the +// rate covered 0.003% of it. The compare page printed all three side by side +// and declared a winner on the fee column, and a Community Note seen 11.4k +// times cited the page. Every other platform that day sat between 95.9% and +// 100% coverage. +func TestTakeRateIsWithheldWhenItCoversAlmostNoVolume(t *testing.T) { + resetAll() + now := time.Now() + d := day(now, 1) + publish([]sample{ + // The sliver that reports fees, and the volume that does not. + mk("pumpfun", "base", d, 1425, 10, 911, 5), + mk("pumpfun", "solana", d, 41_000_000, 10000, 0, 500), + // Another platform keeps the solana fee column alive, so the zero + // above is read as "unexplained" rather than "this chain has no fees". + mk("gmgn", "solana", d, 1000, 10, 10, 5), + }, []string{"pumpfun", "gmgn"}, 3, now) + + if got := testutil.ToFloat64(feeCoveragePct.WithLabelValues("pumpfun")); got > 1 { + t.Fatalf("precondition: coverage should be a fraction of a percent, got %v", got) + } + if got := testutil.ToFloat64(feeRateHealth.WithLabelValues("pumpfun", "all")); got != 0 { + t.Errorf("all-chains take rate must read unhealthy, got %v", got) + } + if got := testutil.ToFloat64(feeWithheld.WithLabelValues("pumpfun", "all")); got != 1 { + t.Errorf("the row must say withheld rather than go silent, got %v", got) + } + // The volume is a total and stays published; only the ratio is withheld. + if v := testutil.ToFloat64(volumeUSD.WithLabelValues("pumpfun", "all")); v != 41_001_425 { + t.Errorf("all-chains volume must still total both chains, got %v", v) + } + + // A platform whose fees cover its volume keeps its rate. + if got := testutil.ToFloat64(feeCoveragePct.WithLabelValues("gmgn")); got != 100 { + t.Errorf("gmgn coverage must be 100, got %v", got) + } + if got := testutil.ToFloat64(feeRatePct.WithLabelValues("gmgn", "all")); math.Abs(got-1) > 1e-9 { + t.Errorf("gmgn must keep its 1%% take rate, got %v", got) + } +} + +func TestImplausibleTakeRateIsWithheldOnThePerChainCellToo(t *testing.T) { + resetAll() + now := time.Now() + d := day(now, 1) + publish([]sample{ + // The cell that reached the compare page: $910.86 of fees against + // $1,424.96 of volume on Base, read and published as a 63.9% take rate. + mk("pumpfun", "base", d, 1424.96, 514, 910.86, 100), + mk("pumpfun", "solana", d, 40_640_441, 1_177_256, 0, 50000), + // Keeps the base and solana fee columns alive so the zero above is read + // as unexplained rather than as a chain that has no fees at all. + mk("gmgn", "base", d, 439_202, 4000, 4377, 500), + mk("gmgn", "solana", d, 16_368_442, 90000, 174_934, 9000), + }, []string{"pumpfun", "gmgn"}, 3, now) + + // The per-chain cell is the one the coverage gate cannot see. + for _, chain := range []string{"base", "all"} { + if got := testutil.ToFloat64(feeRateHealth.WithLabelValues("pumpfun", chain)); got != 0 { + t.Errorf("pumpfun/%s take rate must read unhealthy, got %v", chain, got) + } + if got := testutil.ToFloat64(feeWithheld.WithLabelValues("pumpfun", chain)); got != 1 { + t.Errorf("pumpfun/%s must say withheld rather than go silent, got %v", chain, got) + } + } + + // Volume and transactions are measurements and stay published; only the + // ratio built on an unusable numerator goes away. + if v := testutil.ToFloat64(volumeUSD.WithLabelValues("pumpfun", "base")); v != 1424.96 { + t.Errorf("base volume must survive the withheld rate, got %v", v) + } + + // The plausible platform is untouched on every chain. + for _, chain := range []string{"base", "solana"} { + if got := testutil.ToFloat64(feeRateHealth.WithLabelValues("gmgn", chain)); got != 1 { + t.Errorf("gmgn/%s must keep its rate, got health %v", chain, got) + } + } + if got := testutil.ToFloat64(feeRatePct.WithLabelValues("gmgn", "base")); math.Abs(got-0.9966) > 1e-3 { + t.Errorf("gmgn base rate must be about 1%%, got %v", got) + } +} diff --git a/harnesses/terminal-activity/cmd/script/windows.go b/harnesses/terminal-activity/cmd/script/windows.go index ee5e2dc00..4615cc22d 100644 --- a/harnesses/terminal-activity/cmd/script/windows.go +++ b/harnesses/terminal-activity/cmd/script/windows.go @@ -93,7 +93,16 @@ func publishWindows(agg map[string]*windowAgg, roster []string, chains []string, if t := totals[chain+"|"+win]; t > 0 { windowSharePct.WithLabelValues(platform, chain, win).Set(a.vol / t * 100) } - if a.feeOK && a.feeVol > 0 { + // The all-chains window carries the same coverage floor as the daily + // all-chains cell: a rate measured on a sliver of the volume it is + // printed against is not that platform's take rate. Per-chain windows + // are already whole by construction, so the floor would always pass + // and is not applied there. + covered := true + if chain == aggregateChain && a.vol > 0 { + covered = a.feeVol/a.vol >= minFeeCoverage + } + if a.feeOK && a.feeVol > 0 && covered { windowFeesUSD.WithLabelValues(platform, chain, win).Set(a.fees) windowTakeRatePct.WithLabelValues(platform, chain, win).Set(a.fees / a.feeVol * 100) } else { @@ -212,7 +221,19 @@ func buildWindows(details []*botDetail) (map[string]*windowAgg, map[string]float a.txns += t // A zero fee day counts only when the platform charges // nothing anywhere; otherwise it is a gap. - if f > 0 || !feesAnywhere[p] { + // + // A day whose fee is too large a share of its own + // volume to be a trading fee is dropped the same way + // the daily cells drop it, and for the same reason: it + // is the source attributing a fee to the wrong + // denominator. pump.fun's 2026-10-17 Base day, $910.86 + // on $1,424.96, is the case, and left in it set the + // 1d, 7d and 30d take rates on the leaderboard board. + usable := f > 0 || !feesAnywhere[p] + if v > 0 && f/v*100 > maxPlausibleFeeRate { + usable = false + } + if usable { a.fees += f a.feeVol += v a.feeOK = true diff --git a/harnesses/terminal-activity/cmd/script/windows_test.go b/harnesses/terminal-activity/cmd/script/windows_test.go index 1f3f4b380..a246ef132 100644 --- a/harnesses/terminal-activity/cmd/script/windows_test.go +++ b/harnesses/terminal-activity/cmd/script/windows_test.go @@ -94,3 +94,82 @@ func TestShareIsOfTheCohortTotal(t *testing.T) { t.Errorf("gmgn share: got %v want 25", got) } } + +// The leaderboard board reads these windows, not the daily cells, so the +// ceiling has to apply here too. pump.fun's real shape on 2026-10-17: one +// Base day carrying $910.86 of fees on $1,424.96 of volume, and a Solana leg +// forty thousand times larger reporting nothing. +func TestImplausibleDayNeverReachesAWindow(t *testing.T) { + resetAll() + pf := &botDetail{Bot: "pumpfun", FirstDay: "2026-10-16", Through: "2026-10-17", + Days: []string{"d1", "d2"}, Series: []chainSeries{ + {Name: "base", VolumeUSD: []float64{1424.96, 1424.96}, + Txns: []float64{514, 514}, FeesUSD: []float64{910.86, 910.86}}, + {Name: "solana", VolumeUSD: []float64{40_640_441, 40_640_441}, + Txns: []float64{1_177_256, 1_177_256}, FeesUSD: []float64{0, 0}}, + }} + // A second platform keeps feesAnywhere honest and gives the cohort a + // plausible row to compare against. + gm := mkDetail("gmgn", []string{"d1", "d2"}, "solana", + []float64{1_000_000, 1_000_000}, []float64{1000, 1000}, []float64{10_000, 10_000}) + + agg, _, chains := buildWindows([]*botDetail{pf, gm}) + + for _, win := range []string{"1d", "7d", "30d"} { + if a := agg["pumpfun|base|"+win]; a != nil && a.feeOK { + t.Errorf("%s: the implausible base day must not count as measurable fees", win) + } + if a := agg["pumpfun|all|"+win]; a != nil && a.feeOK { + t.Errorf("%s: it must not reach the all-chains window either", win) + } + // Volume is a measurement and survives. + if a := agg["pumpfun|all|"+win]; a == nil || a.vol <= 0 { + t.Errorf("%s: all-chains volume must still be published", win) + } + } + + publishWindows(agg, []string{"pumpfun", "gmgn"}, chains, map[string]float64{}) + + for _, win := range []string{"1d", "7d", "30d"} { + for _, chain := range []string{"base", "all"} { + n := testutil.CollectAndCount(windowTakeRatePct) + _ = n + if v := testutil.ToFloat64(windowTakeRatePct.WithLabelValues("pumpfun", chain, win)); v != 0 { + t.Errorf("pumpfun/%s/%s must publish no take rate, got %v", chain, win, v) + } + } + // The plausible platform keeps its 1% on every window. + if v := testutil.ToFloat64(windowTakeRatePct.WithLabelValues("gmgn", "solana", win)); math.Abs(v-1) > 1e-9 { + t.Errorf("gmgn %s take rate must stay 1%%, got %v", win, v) + } + } +} + +// Coverage floor on the windows, independent of the ceiling: fees that are +// plausible on their own chain but sit on a sliver of the platform's volume +// still cannot stand in for the whole. +func TestWindowAllChainsRateNeedsCoverage(t *testing.T) { + resetAll() + thin := &botDetail{Bot: "thinbot", FirstDay: "2026-10-16", Through: "2026-10-17", + Days: []string{"d1", "d2"}, Series: []chainSeries{ + // 1% on Base, entirely believable, but Base is a rounding error. + {Name: "base", VolumeUSD: []float64{1000, 1000}, + Txns: []float64{10, 10}, FeesUSD: []float64{10, 10}}, + {Name: "solana", VolumeUSD: []float64{10_000_000, 10_000_000}, + Txns: []float64{1000, 1000}, FeesUSD: []float64{0, 0}}, + }} + other := mkDetail("gmgn", []string{"d1", "d2"}, "solana", + []float64{1_000_000, 1_000_000}, []float64{1000, 1000}, []float64{10_000, 10_000}) + agg, _, chains := buildWindows([]*botDetail{thin, other}) + publishWindows(agg, []string{"thinbot", "gmgn"}, chains, map[string]float64{}) + + for _, win := range []string{"1d", "7d", "30d"} { + if v := testutil.ToFloat64(windowTakeRatePct.WithLabelValues("thinbot", "all", win)); v != 0 { + t.Errorf("all-chains %s must be withheld on 0.01%% coverage, got %v", win, v) + } + // The chain that was actually measured keeps its own honest rate. + if v := testutil.ToFloat64(windowTakeRatePct.WithLabelValues("thinbot", "base", win)); math.Abs(v-1) > 1e-9 { + t.Errorf("base %s must keep its measured 1%%, got %v", win, v) + } + } +} diff --git a/harnesses/terminal-fill-quality/cmd/script/main.go b/harnesses/terminal-fill-quality/cmd/script/main.go index bd2fcbd6e..39d9b35ed 100644 --- a/harnesses/terminal-fill-quality/cmd/script/main.go +++ b/harnesses/terminal-fill-quality/cmd/script/main.go @@ -1441,7 +1441,7 @@ func productOf(slug string) (string, string) { return slug, "solana" } -var productNames = map[string]string{"fomo": "FOMO", "gmgn": "GMGN", "axiom": "Axiom", "banana-gun": "Banana Gun", "binance": "Binance", "basedbot": "BasedBot", "pump-fun": "pump.fun app", "phantom": "Phantom", "maestro": "Maestro", "bloom": "Bloom"} +var productNames = map[string]string{"fomo": "FOMO", "gmgn": "GMGN", "axiom": "Axiom", "banana-gun": "Banana Gun", "binance": "Binance Wallet", "basedbot": "BasedBot", "pump-fun": "pump.fun app", "phantom": "Phantom", "maestro": "Maestro", "bloom": "Bloom"} // productAlias: the product a row belongs to when the bench names it // differently from the row prefix (Binance Wallet's rows are the Binance diff --git a/infrastructure/monitoring/prometheus/prometheus.yml b/infrastructure/monitoring/prometheus/prometheus.yml index 62fa72a1c..76df64f65 100644 --- a/infrastructure/monitoring/prometheus/prometheus.yml +++ b/infrastructure/monitoring/prometheus/prometheus.yml @@ -90,6 +90,22 @@ scrape_configs: # publishes perp_venue_* gauges read by the /perps hub leaderboard, # PerpVenueKpiStrip and per-product PerpVenueSection. Tier 1 cohort: # hyperliquid, lighter, gmx-v2, gains. Internal-only Railway service. + # OpenChainBench bench №284 trading-agent-alpha. Reads Recall Labs' public + # competition API hourly and publishes each frontier model's alpha net of the + # asset it was pointed at. The arena is WEEKLY, so a series that has not moved + # in days is current, not stale: judge freshness on + # trading_agent_last_round_end_timestamp_seconds, never on scrape age. + - job_name: 'trading-agent-alpha' + static_configs: + - targets: + - 'ocb-trading-agent-alpha:2115' + labels: + benchmark: trading-agent-alpha + metrics_path: /metrics + # One pass an hour against a third party's free API. A tighter scrape buys + # nothing because the gauge only changes when a round ends. + scrape_interval: 300s + - job_name: 'perp-cohort-stats' static_configs: - targets: diff --git a/infrastructure/monitoring/prometheus/recording_rules/ocb_rpc_capabilities.yml b/infrastructure/monitoring/prometheus/recording_rules/ocb_rpc_capabilities.yml index a47e1d97a..dbd7f513d 100644 --- a/infrastructure/monitoring/prometheus/recording_rules/ocb_rpc_capabilities.yml +++ b/infrastructure/monitoring/prometheus/recording_rules/ocb_rpc_capabilities.yml @@ -20,16 +20,32 @@ groups: expr: quantile_over_time(0.50, rpc_latency_milliseconds[24h]) - record: ocb:rpc_latency_milliseconds:p90_24h expr: quantile_over_time(0.90, rpc_latency_milliseconds[24h]) + # p95 next to p90 and p99, added 2026-10-16 on a provider's request. + # It was missing for no reason beyond the conventional 50/90/99 triple: + # 0.95 appeared nowhere in this repo. With a 120 s probe cadence a cell + # holds about 720 measurements per 24 h, so the p95 rests on the 36th + # worst sample where the p99 we already publish rests on the 7th. The + # one that was skipped is the better supported of the two. + - record: ocb:rpc_latency_milliseconds:p95_24h + expr: quantile_over_time(0.95, rpc_latency_milliseconds[24h]) - record: ocb:rpc_latency_milliseconds:p99_24h expr: quantile_over_time(0.99, rpc_latency_milliseconds[24h]) - record: ocb:rpc_latency_milliseconds:mean_24h expr: avg_over_time(rpc_latency_milliseconds[24h]) + # `tier` is in the by-clause since 2026-10-16, and its absence was not + # cosmetic: the keyed harness emits rpc_call_total with tier="keyed", + # these rules summed it away, and so every keyed spec's success and + # sample_size query matched nothing. The loader defaults a missing + # success to 100, so the private cohort published a success rate of + # exactly 100% on every row as an assumption. Public series carry no + # tier label, and an absent label equals an empty one, so selectors + # without `tier` keep matching them unchanged. - record: ocb:rpc_call:ok_rate_24h - expr: sum by (provider, chain, region) (rate(rpc_call_total{result="ok"}[24h])) + expr: sum by (provider, chain, region, tier) (rate(rpc_call_total{result="ok"}[24h])) - record: ocb:rpc_call:rate_24h - expr: sum by (provider, chain, region) (rate(rpc_call_total[24h])) + expr: sum by (provider, chain, region, tier) (rate(rpc_call_total[24h])) - record: ocb:rpc_call:increase_24h - expr: sum by (provider, chain, region) (increase(rpc_call_total[24h])) + expr: sum by (provider, chain, region, tier) (increase(rpc_call_total[24h])) # Cold-connection arm, added 2026-10-08 with the keyed probe fix. # @@ -53,7 +69,68 @@ groups: expr: quantile_over_time(0.50, rpc_latency_cold_milliseconds[24h]) - record: ocb:rpc_latency_cold_milliseconds:p90_24h expr: quantile_over_time(0.90, rpc_latency_cold_milliseconds[24h]) + - record: ocb:rpc_latency_cold_milliseconds:p95_24h + expr: quantile_over_time(0.95, rpc_latency_cold_milliseconds[24h]) - record: ocb:rpc_latency_cold_milliseconds:p99_24h expr: quantile_over_time(0.99, rpc_latency_cold_milliseconds[24h]) - record: ocb:rpc_latency_cold_milliseconds:mean_24h expr: avg_over_time(rpc_latency_cold_milliseconds[24h]) + + # WebSocket arm, added 2026-10-15. Same keyed endpoints, subscription + # instead of request/response. + # + # The headline is a race rather than a latency: every provider on a chain + # waits for the same block, so head_lead is the distance from whoever + # pushed it first and the chain's block time cancels out. Do NOT build a + # column on time-to-first-head: that is block time with a provider's name + # on it (7 to 11 s on Ethereum, 240 to 490 ms on Robinhood, for everyone + # alike). + # + # head_lead's p50 is the honest headline. Its mean is not, because one + # reconnection drags it: a provider that drops a connection misses pushes + # and then scores a large lead on the block it rejoins. + # + # Recorded as bucket rates rather than finished quantiles, following + # histogram_recording_rules.yml: one rule then serves the headline and + # every region tab, and the heavy 24h bucket rate is evaluated once a + # minute here instead of on every page render. Nine new pages each firing + # a raw rate(..._bucket[24h]) is precisely the outbound-call cost these + # rules exist to avoid. + # + # Keeping the dimensions puts an obligation on the SPEC: it must + # aggregate with sum by (le) over whatever it does not pin, or + # prom.scalar takes result[0] and the headline becomes a random cell. + # That is how a live page once showed a p99 below its own p50. + - record: ocb:rpc_ws_head_lead_milliseconds_bucket:rate24h + expr: sum by (provider, chain, region, tier, le) (rate(rpc_ws_head_lead_milliseconds_histogram_bucket[24h])) + - record: ocb:rpc_ws_head_lead_milliseconds_sum:rate24h + expr: sum by (provider, chain, region, tier) (rate(rpc_ws_head_lead_milliseconds_histogram_sum[24h])) + - record: ocb:rpc_ws_head_lead_milliseconds_count:rate24h + expr: sum by (provider, chain, region, tier) (rate(rpc_ws_head_lead_milliseconds_histogram_count[24h])) + + - record: ocb:rpc_ws_call_milliseconds_bucket:rate24h + expr: sum by (provider, chain, region, tier, le) (rate(rpc_ws_call_milliseconds_histogram_bucket[24h])) + + # The subscribe round trip is a gauge set once per connection rather than + # a stream of observations, so a quantile over time is its right shape. + - record: ocb:rpc_ws_subscribe_milliseconds:p50_24h + expr: quantile_over_time(0.50, rpc_ws_subscribe_milliseconds[24h]) + - record: ocb:rpc_ws_subscribe_milliseconds:p90_24h + expr: quantile_over_time(0.90, rpc_ws_subscribe_milliseconds[24h]) + + # Win rate as a numerator/denominator pair, the same convention as + # ocb:rpc_call above, so the site keeps ratio-of-sums semantics when it + # aggregates across regions. A ratio recorded here would average + # percentages of unequal block counts instead. + - record: ocb:rpc_ws_head_first:increase_24h + expr: sum by (provider, chain, region, tier) (increase(rpc_ws_head_first_total[24h])) + - record: ocb:rpc_ws_head_seen:increase_24h + expr: sum by (provider, chain, region, tier) (increase(rpc_ws_head_seen_total[24h])) + + # Stability. Published as a result, not kept as an implementation + # detail: a provider that reconnects twice in two minutes is unusable for + # a subscriber however good its median is, and nobody publishes this. + - record: ocb:rpc_ws_disconnect:increase_24h + expr: sum by (provider, chain, region, tier) (increase(rpc_ws_disconnect_total[24h])) + - record: ocb:rpc_ws_messages:increase_24h + expr: sum by (provider, chain, region, tier) (increase(rpc_ws_messages_total[24h])) diff --git a/next.config.ts b/next.config.ts index f9f90b804..ce50fc74d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -207,6 +207,34 @@ const nextConfig: NextConfig = { }, ], }, + { + // Provider marks are static files under `public/`, which Next + // serves as `max-age=0, must-revalidate` unless told otherwise. + // That means a browser holding every logo in its disk cache still + // asks the server about each one before it may paint it. + // + // It was harmless while a page carried a few dozen. /capital now + // references 158 distinct marks, so an ordinary visit opens 158 + // conditional requests; measured from a machine next to the cdg1 + // edge each 304 costs about 95 ms, and the marks render as empty + // circles until they land. 72 of those files arrived in September + // alone, which is why this turned slow recently rather than + // gradually. + // + // A day of silence, then a month of serving the cached copy while + // a fresh one is fetched behind it. Not `immutable`: these + // filenames are stable and their CONTENTS get replaced (Chainstack + // sent a new mark in #2910), so a year-long immutable cache would + // pin an old logo on every returning reader. A day is short enough + // that a swapped mark appears on its own. + source: "/logos/:path*", + headers: [ + { + key: "Cache-Control", + value: "public, max-age=86400, stale-while-revalidate=2592000", + }, + ], + }, ]; }, async rewrites() { @@ -342,7 +370,11 @@ const nextConfig: NextConfig = { // as views behind the pill bar; the hash opens the matching view. // The /hyperliquid and /perps hubs (no slug) are untouched. // The hub's own image routes (/hyperliquid/opengraph-image) must - // not match: a bare :slug would 308 them to a 404. + // not match: a bare :slug would 308 them to a 404. Same for the + // hub's own sub-pages: /hyperliquid/traders is the leaderboard + // audit, not a builder slug, and without the exclusion it 308s to + // /products/traders#hl, which does not exist. Any future sub-page + // of the hub has to be added here too. // Unlabeled builder addresses have no product page (hex slugs 404 // by design); their old detail URLs land on the bench that lists them. { @@ -351,7 +383,8 @@ const nextConfig: NextConfig = { permanent: true, }, { - source: "/hyperliquid/:slug((?!opengraph-image|twitter-image|icon|apple-icon|0x).*)", + source: + "/hyperliquid/:slug((?!opengraph-image|twitter-image|icon|apple-icon|traders|0x).*)", destination: "/products/:slug#hl", permanent: true, }, diff --git a/public/logos/0g.webp b/public/logos/0g.webp new file mode 100644 index 000000000..3db1ae768 Binary files /dev/null and b/public/logos/0g.webp differ diff --git a/public/logos/abstract.webp b/public/logos/abstract.webp index d0dbf5191..3cb5c0531 100644 Binary files a/public/logos/abstract.webp and b/public/logos/abstract.webp differ diff --git a/public/logos/aerodrome.webp b/public/logos/aerodrome.webp index eb4685f81..f7e9766de 100644 Binary files a/public/logos/aerodrome.webp and b/public/logos/aerodrome.webp differ diff --git a/public/logos/agoric.ico b/public/logos/agoric.ico new file mode 100644 index 000000000..43c8e9556 Binary files /dev/null and b/public/logos/agoric.ico differ diff --git a/public/logos/alphafi.webp b/public/logos/alphafi.webp index ab4de1199..263513c28 100644 Binary files a/public/logos/alphafi.webp and b/public/logos/alphafi.webp differ diff --git a/public/logos/ansem-io.webp b/public/logos/ansem-io.webp index c90f1768b..23c749813 100644 Binary files a/public/logos/ansem-io.webp and b/public/logos/ansem-io.webp differ diff --git a/public/logos/apex-protocol.webp b/public/logos/apex-protocol.webp index c64f8678b..e39a16138 100644 Binary files a/public/logos/apex-protocol.webp and b/public/logos/apex-protocol.webp differ diff --git a/public/logos/astar.png b/public/logos/astar.png index 5b7156c71..0c0e4c128 100644 Binary files a/public/logos/astar.png and b/public/logos/astar.png differ diff --git a/public/logos/beaverbuild.jpg b/public/logos/beaverbuild.jpg index 2f6794a6f..b38fc9f10 100644 Binary files a/public/logos/beaverbuild.jpg and b/public/logos/beaverbuild.jpg differ diff --git a/public/logos/binance-us.jpg b/public/logos/binance-us.jpg index 6b24fa2bb..7a337ad8c 100644 Binary files a/public/logos/binance-us.jpg and b/public/logos/binance-us.jpg differ diff --git a/public/logos/bitcoin.png b/public/logos/bitcoin.png index 1d14bc61d..22a9eca8d 100644 Binary files a/public/logos/bitcoin.png and b/public/logos/bitcoin.png differ diff --git a/public/logos/blink.png b/public/logos/blink.png index 8fe6139ba..7806f63dd 100644 Binary files a/public/logos/blink.png and b/public/logos/blink.png differ diff --git a/public/logos/bob.png b/public/logos/bob.png index ac7c74d5f..c7f066d26 100644 Binary files a/public/logos/bob.png and b/public/logos/bob.png differ diff --git a/public/logos/bobthebuilder.jpg b/public/logos/bobthebuilder.jpg index b47283ba6..93b7def49 100644 Binary files a/public/logos/bobthebuilder.jpg and b/public/logos/bobthebuilder.jpg differ diff --git a/public/logos/bonk-fun.webp b/public/logos/bonk-fun.webp index ce6159e0b..3a7e6fc1f 100644 Binary files a/public/logos/bonk-fun.webp and b/public/logos/bonk-fun.webp differ diff --git a/public/logos/cap.webp b/public/logos/cap.webp index 3d5538ef7..75fb17130 100644 Binary files a/public/logos/cap.webp and b/public/logos/cap.webp differ diff --git a/public/logos/chiliz.png b/public/logos/chiliz.png index 8d6a7f9de..0d788b134 100644 Binary files a/public/logos/chiliz.png and b/public/logos/chiliz.png differ diff --git a/public/logos/comdex.png b/public/logos/comdex.png index 7d449bbdd..6b57f883c 100644 Binary files a/public/logos/comdex.png and b/public/logos/comdex.png differ diff --git a/public/logos/conduit.svg b/public/logos/conduit.svg new file mode 100644 index 000000000..532d185a3 --- /dev/null +++ b/public/logos/conduit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/logos/core.png b/public/logos/core.png index ae0e3022c..c1cb2001a 100644 Binary files a/public/logos/core.png and b/public/logos/core.png differ diff --git a/public/logos/corn.png b/public/logos/corn.png index 8b94d156e..c3c8f5b86 100644 Binary files a/public/logos/corn.png and b/public/logos/corn.png differ diff --git a/public/logos/cryptocom.jpg b/public/logos/cryptocom.jpg index 9212c9ff4..dd105531a 100644 Binary files a/public/logos/cryptocom.jpg and b/public/logos/cryptocom.jpg differ diff --git a/public/logos/cyber.webp b/public/logos/cyber.webp index b7a2e3dc7..68a87a475 100644 Binary files a/public/logos/cyber.webp and b/public/logos/cyber.webp differ diff --git a/public/logos/dolomite.webp b/public/logos/dolomite.webp index 107f24143..bc2ede00f 100644 Binary files a/public/logos/dolomite.webp and b/public/logos/dolomite.webp differ diff --git a/public/logos/drift-perp.png b/public/logos/drift-perp.png index dc0fa1211..057d6ed36 100644 Binary files a/public/logos/drift-perp.png and b/public/logos/drift-perp.png differ diff --git a/public/logos/drift.png b/public/logos/drift.png index a9c57c055..75827e229 100644 Binary files a/public/logos/drift.png and b/public/logos/drift.png differ diff --git a/public/logos/drpc.png b/public/logos/drpc.png index 8708259a4..4c1ec8351 100644 Binary files a/public/logos/drpc.png and b/public/logos/drpc.png differ diff --git a/public/logos/ekubo-protocol.webp b/public/logos/ekubo-protocol.webp index a3f9cfe19..9cd1aef35 100644 Binary files a/public/logos/ekubo-protocol.webp and b/public/logos/ekubo-protocol.webp differ diff --git a/public/logos/enso.png b/public/logos/enso.png index 2ae5e0a03..a1ee6dfbc 100644 Binary files a/public/logos/enso.png and b/public/logos/enso.png differ diff --git a/public/logos/ether-fi.webp b/public/logos/ether-fi.webp index aeaac63c8..2d67a6d4a 100644 Binary files a/public/logos/ether-fi.webp and b/public/logos/ether-fi.webp differ diff --git a/public/logos/etherlink.png b/public/logos/etherlink.png index 05c8785e5..a363306bf 100644 Binary files a/public/logos/etherlink.png and b/public/logos/etherlink.png differ diff --git a/public/logos/eureka.png b/public/logos/eureka.png index 833b3f5e2..b7a516495 100644 Binary files a/public/logos/eureka.png and b/public/logos/eureka.png differ diff --git a/public/logos/gains-network-perp.png b/public/logos/gains-network-perp.png index 04505e730..3a3007156 100644 Binary files a/public/logos/gains-network-perp.png and b/public/logos/gains-network-perp.png differ diff --git a/public/logos/gains-network.webp b/public/logos/gains-network.webp index e4dba8816..d2a063b51 100644 Binary files a/public/logos/gains-network.webp and b/public/logos/gains-network.webp differ diff --git a/public/logos/geckoterminal.png b/public/logos/geckoterminal.png index 80fd94ee8..51a4e135e 100644 Binary files a/public/logos/geckoterminal.png and b/public/logos/geckoterminal.png differ diff --git a/public/logos/hydration.png b/public/logos/hydration.png index 7b08bd64d..183089c74 100644 Binary files a/public/logos/hydration.png and b/public/logos/hydration.png differ diff --git a/public/logos/hyperx.png b/public/logos/hyperx.png index d8464bf18..aed799bf3 100644 Binary files a/public/logos/hyperx.png and b/public/logos/hyperx.png differ diff --git a/public/logos/hypurrscan.png b/public/logos/hypurrscan.png index 082df1b7c..287e32f82 100644 Binary files a/public/logos/hypurrscan.png and b/public/logos/hypurrscan.png differ diff --git a/public/logos/immutable.png b/public/logos/immutable.png index 70cd7de79..92db84806 100644 Binary files a/public/logos/immutable.png and b/public/logos/immutable.png differ diff --git a/public/logos/invo.png b/public/logos/invo.png index 507477341..731da90c6 100644 Binary files a/public/logos/invo.png and b/public/logos/invo.png differ diff --git a/public/logos/iotex.png b/public/logos/iotex.png index 67009d13e..3eec73cc2 100644 Binary files a/public/logos/iotex.png and b/public/logos/iotex.png differ diff --git a/public/logos/jupiter.png b/public/logos/jupiter.png index 85d080d76..f5cfb7ce3 100644 Binary files a/public/logos/jupiter.png and b/public/logos/jupiter.png differ diff --git a/public/logos/katana.png b/public/logos/katana.png new file mode 100644 index 000000000..1ea6265bd Binary files /dev/null and b/public/logos/katana.png differ diff --git a/public/logos/kinetiq.webp b/public/logos/kinetiq.webp index 90a2f0dba..0d6f7a6e5 100644 Binary files a/public/logos/kinetiq.webp and b/public/logos/kinetiq.webp differ diff --git a/public/logos/kujira.png b/public/logos/kujira.png index 2e76e487d..0267bc129 100644 Binary files a/public/logos/kujira.png and b/public/logos/kujira.png differ diff --git a/public/logos/lava.png b/public/logos/lava.png index 9dfe996cc..4f662a3d7 100644 Binary files a/public/logos/lava.png and b/public/logos/lava.png differ diff --git a/public/logos/leorpc.png b/public/logos/leorpc.png index 25e0d9dbf..abd7f5a14 100644 Binary files a/public/logos/leorpc.png and b/public/logos/leorpc.png differ diff --git a/public/logos/lido.webp b/public/logos/lido.webp index 3bb36425f..928d3fe1a 100644 Binary files a/public/logos/lido.webp and b/public/logos/lido.webp differ diff --git a/public/logos/liminal.jpg b/public/logos/liminal.jpg index 894c77781..65ecead7e 100644 Binary files a/public/logos/liminal.jpg and b/public/logos/liminal.jpg differ diff --git a/public/logos/lit-trade.png b/public/logos/lit-trade.png index cf60cc4e8..4149d3810 100644 Binary files a/public/logos/lit-trade.png and b/public/logos/lit-trade.png differ diff --git a/public/logos/litecoin.png b/public/logos/litecoin.png index 0c7de0407..95e8b7dcf 100644 Binary files a/public/logos/litecoin.png and b/public/logos/litecoin.png differ diff --git a/public/logos/lootbase.png b/public/logos/lootbase.png index 41257e3bd..d09fb2fc3 100644 Binary files a/public/logos/lootbase.png and b/public/logos/lootbase.png differ diff --git a/public/logos/megaeth.png b/public/logos/megaeth.png index 7311b2955..30df81cf4 100644 Binary files a/public/logos/megaeth.png and b/public/logos/megaeth.png differ diff --git a/public/logos/meowrpc.jpg b/public/logos/meowrpc.jpg index 2cac94a78..745ab8119 100644 Binary files a/public/logos/meowrpc.jpg and b/public/logos/meowrpc.jpg differ diff --git a/public/logos/meteora.webp b/public/logos/meteora.webp index f9e02d1db..49dea278b 100644 Binary files a/public/logos/meteora.webp and b/public/logos/meteora.webp differ diff --git a/public/logos/neox.ico b/public/logos/neox.ico new file mode 100644 index 000000000..b6f15051f Binary files /dev/null and b/public/logos/neox.ico differ diff --git a/public/logos/nibiru.png b/public/logos/nibiru.png index 46dbd6488..59c017c06 100644 Binary files a/public/logos/nibiru.png and b/public/logos/nibiru.png differ diff --git a/public/logos/oasis-emerald.png b/public/logos/oasis-emerald.png index 150054e5d..3e13d9bfe 100644 Binary files a/public/logos/oasis-emerald.png and b/public/logos/oasis-emerald.png differ diff --git a/public/logos/oasis-sapphire.png b/public/logos/oasis-sapphire.png index 150054e5d..3e13d9bfe 100644 Binary files a/public/logos/oasis-sapphire.png and b/public/logos/oasis-sapphire.png differ diff --git a/public/logos/onfinality.png b/public/logos/onfinality.png index 2a1ccf25b..78170d1ff 100644 Binary files a/public/logos/onfinality.png and b/public/logos/onfinality.png differ diff --git a/public/logos/optimism.png b/public/logos/optimism.png index b7d4ee019..1efeb503d 100644 Binary files a/public/logos/optimism.png and b/public/logos/optimism.png differ diff --git a/public/logos/owlracle.png b/public/logos/owlracle.png index 6655495dd..62cb4d415 100644 Binary files a/public/logos/owlracle.png and b/public/logos/owlracle.png differ diff --git a/public/logos/owlracle.webp b/public/logos/owlracle.webp index 4e9e2ea33..696127ab0 100644 Binary files a/public/logos/owlracle.webp and b/public/logos/owlracle.webp differ diff --git a/public/logos/pair.webp b/public/logos/pair.webp index cd9c8f867..18ea95165 100644 Binary files a/public/logos/pair.webp and b/public/logos/pair.webp differ diff --git a/public/logos/papertrade.svg b/public/logos/papertrade.svg new file mode 100644 index 000000000..b0e2c43b8 --- /dev/null +++ b/public/logos/papertrade.svg @@ -0,0 +1,7 @@ + + + p_ + diff --git a/public/logos/peaq.png b/public/logos/peaq.png index be461db52..9d784ba19 100644 Binary files a/public/logos/peaq.png and b/public/logos/peaq.png differ diff --git a/public/logos/pulsechain.png b/public/logos/pulsechain.png index dcbc98281..b4724a3a1 100644 Binary files a/public/logos/pulsechain.png and b/public/logos/pulsechain.png differ diff --git a/public/logos/pump-fun.jpg b/public/logos/pump-fun.jpg index 710d65cce..7aa231714 100644 Binary files a/public/logos/pump-fun.jpg and b/public/logos/pump-fun.jpg differ diff --git a/public/logos/pump.webp b/public/logos/pump.webp index b8772423a..61951ef90 100644 Binary files a/public/logos/pump.webp and b/public/logos/pump.webp differ diff --git a/public/logos/purroofgroup.png b/public/logos/purroofgroup.png index a6be0335a..30fecbd2f 100644 Binary files a/public/logos/purroofgroup.png and b/public/logos/purroofgroup.png differ diff --git a/public/logos/pvp-trade.png b/public/logos/pvp-trade.png index 62af58dd3..e885cbaac 100644 Binary files a/public/logos/pvp-trade.png and b/public/logos/pvp-trade.png differ diff --git a/public/logos/quickswap.webp b/public/logos/quickswap.webp index cfd2da10d..ecc5af9b5 100644 Binary files a/public/logos/quickswap.webp and b/public/logos/quickswap.webp differ diff --git a/public/logos/ramsesx.webp b/public/logos/ramsesx.webp index 263059876..ef6975931 100644 Binary files a/public/logos/ramsesx.webp and b/public/logos/ramsesx.webp differ diff --git a/public/logos/rhea-finance.webp b/public/logos/rhea-finance.webp index ff8a28b2d..b3cf93843 100644 Binary files a/public/logos/rhea-finance.webp and b/public/logos/rhea-finance.webp differ diff --git a/public/logos/rocket-pool.webp b/public/logos/rocket-pool.webp index 7a9b0976f..bcebd33ee 100644 Binary files a/public/logos/rocket-pool.webp and b/public/logos/rocket-pool.webp differ diff --git a/public/logos/ronin.png b/public/logos/ronin.png index d75ff82c7..c1ecfe572 100644 Binary files a/public/logos/ronin.png and b/public/logos/ronin.png differ diff --git a/public/logos/route.png b/public/logos/route.png new file mode 100644 index 000000000..70fb40aeb Binary files /dev/null and b/public/logos/route.png differ diff --git a/public/logos/sanko.png b/public/logos/sanko.png index b2e404e79..f037f17cb 100644 Binary files a/public/logos/sanko.png and b/public/logos/sanko.png differ diff --git a/public/logos/sei.png b/public/logos/sei.png index 2545cced1..d068c41c0 100644 Binary files a/public/logos/sei.png and b/public/logos/sei.png differ diff --git a/public/logos/smarkets.jpg b/public/logos/smarkets.jpg index 81b828781..12afe1cfa 100644 Binary files a/public/logos/smarkets.jpg and b/public/logos/smarkets.jpg differ diff --git a/public/logos/socket.webp b/public/logos/socket.webp index a96661eaf..8235904b7 100644 Binary files a/public/logos/socket.webp and b/public/logos/socket.webp differ diff --git a/public/logos/solanavibestation.png b/public/logos/solanavibestation.png index 5a3a19429..1c28eb3b8 100644 Binary files a/public/logos/solanavibestation.png and b/public/logos/solanavibestation.png differ diff --git a/public/logos/stakeangle.png b/public/logos/stakeangle.png index 7fcb8f633..d4ab85954 100644 Binary files a/public/logos/stakeangle.png and b/public/logos/stakeangle.png differ diff --git a/public/logos/stellar.png b/public/logos/stellar.png index 93ca9dcc8..5b5bc5597 100644 Binary files a/public/logos/stellar.png and b/public/logos/stellar.png differ diff --git a/public/logos/sun.webp b/public/logos/sun.webp index 67a6978b2..5ba59260d 100644 Binary files a/public/logos/sun.webp and b/public/logos/sun.webp differ diff --git a/public/logos/superseed.ico b/public/logos/superseed.ico new file mode 100644 index 000000000..f17b9964e Binary files /dev/null and b/public/logos/superseed.ico differ diff --git a/public/logos/superx.png b/public/logos/superx.png index 80936e3d7..c296c8838 100644 Binary files a/public/logos/superx.png and b/public/logos/superx.png differ diff --git a/public/logos/swellchain.webp b/public/logos/swellchain.webp index 637001ad8..5e85332e6 100644 Binary files a/public/logos/swellchain.webp and b/public/logos/swellchain.webp differ diff --git a/public/logos/tac.webp b/public/logos/tac.webp new file mode 100644 index 000000000..c8fad078d Binary files /dev/null and b/public/logos/tac.webp differ diff --git a/public/logos/telos.png b/public/logos/telos.png index 7c46f73cd..b7d194303 100644 Binary files a/public/logos/telos.png and b/public/logos/telos.png differ diff --git a/public/logos/tempo.ico b/public/logos/tempo.ico new file mode 100644 index 000000000..5b20ee070 Binary files /dev/null and b/public/logos/tempo.ico differ diff --git a/public/logos/tradoor.png b/public/logos/tradoor.png index 045d486ae..03c45bd8e 100644 Binary files a/public/logos/tradoor.png and b/public/logos/tradoor.png differ diff --git a/public/logos/tread-fi.jpg b/public/logos/tread-fi.jpg index d1e799ada..273bb7963 100644 Binary files a/public/logos/tread-fi.jpg and b/public/logos/tread-fi.jpg differ diff --git a/public/logos/triton.png b/public/logos/triton.png index 9b8f99140..841d9674e 100644 Binary files a/public/logos/triton.png and b/public/logos/triton.png differ diff --git a/public/logos/tron.png b/public/logos/tron.png index 8bd7214e3..6bd9a49c5 100644 Binary files a/public/logos/tron.png and b/public/logos/tron.png differ diff --git a/public/logos/usdc.png b/public/logos/usdc.png index 9938b4478..5ed07dcef 100644 Binary files a/public/logos/usdc.png and b/public/logos/usdc.png differ diff --git a/public/logos/velora.webp b/public/logos/velora.webp index 5cd160418..ecda1300c 100644 Binary files a/public/logos/velora.webp and b/public/logos/velora.webp differ diff --git a/public/logos/venus.webp b/public/logos/venus.webp index 291c38cc1..5353a078b 100644 Binary files a/public/logos/venus.webp and b/public/logos/venus.webp differ diff --git a/public/logos/vntl.png b/public/logos/vntl.png index f3ab2e10a..d05e37b09 100644 Binary files a/public/logos/vntl.png and b/public/logos/vntl.png differ diff --git a/public/logos/wallet-v.png b/public/logos/wallet-v.png index 068273063..de63672bb 100644 Binary files a/public/logos/wallet-v.png and b/public/logos/wallet-v.png differ diff --git a/public/logos/wax.png b/public/logos/wax.png index 21598c746..aba7da19c 100644 Binary files a/public/logos/wax.png and b/public/logos/wax.png differ diff --git a/public/logos/wemix.png b/public/logos/wemix.png index 482298259..44892889a 100644 Binary files a/public/logos/wemix.png and b/public/logos/wemix.png differ diff --git a/public/logos/world-liberty-financial.webp b/public/logos/world-liberty-financial.webp index 69759826c..58971ec36 100644 Binary files a/public/logos/world-liberty-financial.webp and b/public/logos/world-liberty-financial.webp differ diff --git a/public/logos/xpla.webp b/public/logos/xpla.webp new file mode 100644 index 000000000..7b53e1328 Binary files /dev/null and b/public/logos/xpla.webp differ diff --git a/public/logos/xrplevm.ico b/public/logos/xrplevm.ico new file mode 100644 index 000000000..b8a31e12f Binary files /dev/null and b/public/logos/xrplevm.ico differ diff --git a/public/logos/xtrade-protocol.jpg b/public/logos/xtrade-protocol.jpg index 75a5d1799..0c94706d0 100644 Binary files a/public/logos/xtrade-protocol.jpg and b/public/logos/xtrade-protocol.jpg differ diff --git a/public/logos/zigchain.webp b/public/logos/zigchain.webp new file mode 100644 index 000000000..64f1ed63b Binary files /dev/null and b/public/logos/zigchain.webp differ diff --git a/public/logos/zircuit.png b/public/logos/zircuit.png index caca49078..de71c612c 100644 Binary files a/public/logos/zircuit.png and b/public/logos/zircuit.png differ diff --git a/public/logos/zora.webp b/public/logos/zora.webp index 264926204..a47c328ee 100644 Binary files a/public/logos/zora.webp and b/public/logos/zora.webp differ diff --git a/scripts/emit-page-mtimes.ts b/scripts/emit-page-mtimes.ts index 2e9e62542..fd68ee2e9 100644 --- a/scripts/emit-page-mtimes.ts +++ b/scripts/emit-page-mtimes.ts @@ -48,6 +48,8 @@ const PAGES: string[] = [ "products/[slug]/page.tsx", "compare/[slug]/page.tsx", "benchmarks/[slug]/page.tsx", + "tools/page.tsx", + "tools/perp-cost/page.tsx", ]; function gitMtimeSeconds(rel: string): number | null { diff --git a/src/app/api/fee-compare/route.ts b/src/app/api/fee-compare/route.ts index 3af347efa..a3766c6b8 100644 --- a/src/app/api/fee-compare/route.ts +++ b/src/app/api/fee-compare/route.ts @@ -53,7 +53,7 @@ const VENUE_NAMES: Record = { hyperliquid: "Hyperliquid", gains: "Gains", dydx: "dYdX v4", - "gmx-v2": "GMX v2", + "gmx-v2": "GMX", paradex: "Paradex", edgex: "EdgeX", }; diff --git a/src/app/api/report/delivery.test.ts b/src/app/api/report/delivery.test.ts new file mode 100644 index 000000000..ac87775e9 --- /dev/null +++ b/src/app/api/report/delivery.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; + +/** + * /api/report carries a reader's message and, with it, three things a + * reporter controls: the message, the contact, and the User-Agent header. + * Telegram's HTML mode will happily render markup out of any of them, and + * the bot token sits in the request URL rather than a header, so a logged + * response body would leak it. + * + * Read from the source: the route needs a live request and an outbound fetch + * to execute, and what is worth pinning is the shape of the delivery rather + * than one round trip. + */ +const SRC = readFileSync(join(import.meta.dir, "route.ts"), "utf8"); + +describe("report delivery", () => { + test("prefers Telegram and keeps Slack as the fallback", () => { + expect(SRC).toContain("process.env.TELEGRAM_BOT_TOKEN"); + expect(SRC).toContain("process.env.TELEGRAM_CHAT_ID"); + // Both must be present, or a token with no destination would look + // configured and every report would 502. + expect(SRC).toContain("tgToken && tgChat"); + expect(SRC).toContain("!telegram && !webhook"); + }); + + test("never logs a Telegram response body, which echoes the token's URL", () => { + const tgBlock = SRC.slice(SRC.indexOf("api.telegram.org")); + expect(tgBlock).toContain('console.error("Telegram rejected report", res.status)'); + // The Slack path logs a body; the Telegram path must not. + expect(tgBlock).not.toContain("await res.text()"); + }); + + test("sends every reporter-controlled value through the escaper", () => { + // slackSafe escapes &, < and >, which is exactly what HTML mode needs. + for (const v of ["safeMessage", "safeContact", "titleLine", "referer", "ip", "ua"]) { + expect(SRC).toContain(v); + } + expect(SRC).toContain('parse_mode: "HTML"'); + }); + + test("respects Telegram's own message ceiling", () => { + expect(SRC).toContain("text.slice(0, 4096)"); + }); +}); diff --git a/src/app/api/report/route.ts b/src/app/api/report/route.ts index b69288d88..0fe3144ac 100644 --- a/src/app/api/report/route.ts +++ b/src/app/api/report/route.ts @@ -80,8 +80,14 @@ export async function POST(req: Request) { const gl = globalLimit("report", 60, 60); if (!gl.ok) return tooManyRequests(gl.retryAfterSec); + // Telegram first, Slack as the fallback it used to be. A report is one + // person telling us a number looks wrong, and it should land where someone + // actually reads it rather than in a channel nobody opens. + const tgToken = process.env.TELEGRAM_BOT_TOKEN; + const tgChat = process.env.TELEGRAM_CHAT_ID; const webhook = process.env.SLACK_REPORT_WEBHOOK_URL; - if (!webhook) { + const telegram = tgToken && tgChat ? { token: tgToken, chat: tgChat } : null; + if (!telegram && !webhook) { return NextResponse.json( { error: "Reporting is not configured." }, { status: 503 } @@ -176,8 +182,55 @@ export async function POST(req: Request) { ], }; + if (telegram) { + // HTML, not Markdown: every interpolated value already went through + // slackSafe, which escapes &, < and > — exactly what Telegram's HTML + // mode needs. Markdown would need a different escape set and the two + // would drift. + const text = + `\u{1F41E} New OpenChainBench report\n\n` + + `Bench ${titleLine}\n` + + `Page ${referer}\n\n` + + `${safeMessage}\n\n` + + `Contact: ${safeContact ?? "not provided"}\n` + + `IP: ${ip}\n` + + `UA: ${ua}`; + try { + const res = await fetch( + `https://api.telegram.org/bot${telegram.token}/sendMessage`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: telegram.chat, + text: text.slice(0, 4096), // Telegram's own message ceiling + parse_mode: "HTML", + disable_web_page_preview: true, + }), + signal: AbortSignal.timeout(5_000), + }, + ); + if (!res.ok) { + // Never log the body: it echoes the request, and the token is in + // the URL of the request we just made. + console.error("Telegram rejected report", res.status); + return NextResponse.json( + { error: "Could not deliver the report. Try again in a moment." }, + { status: 502 } + ); + } + return NextResponse.json({ ok: true }); + } catch { + console.error("Telegram sendMessage failed"); + return NextResponse.json( + { error: "Could not deliver the report. Try again in a moment." }, + { status: 502 } + ); + } + } + try { - const slackRes = await fetch(webhook, { + const slackRes = await fetch(webhook!, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(slackPayload), diff --git a/src/app/data-api/bench-slugs.test.ts b/src/app/data-api/bench-slugs.test.ts new file mode 100644 index 000000000..70e99abad --- /dev/null +++ b/src/app/data-api/bench-slugs.test.ts @@ -0,0 +1,61 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { + DEV_ONLY_BENCH_SLUGS, + REMOVED_BENCH_SLUGS, +} from "@/lib/removed-benches"; + +/** + * The /data-api hub curates its benches by hand, because it groups and + * orders them editorially rather than deriving them. A hand-written list of + * slugs goes stale the day a bench is retired, and this one did: it carried + * indexing-freshness (retired 2026-08-05) and portfolio-chain-coverage long + * after both began answering 410, and indexing-freshness dragged + * /products/zerion onto the page with it. + * + * The page now filters the curated list through the retirement sets. This + * reads the file rather than importing it, because a Next page module may + * not export anything the router does not expect. + */ +const SRC = readFileSync(join(import.meta.dir, "page.tsx"), "utf8"); + +function curatedSlugs(): string[] { + // [\s\S] rather than . with the s flag: the repo's tsconfig target predates it. + const m = SRC.match(/const CURATED_BENCH_SLUGS = \[([\s\S]*?)\]/); + if (!m) throw new Error("CURATED_BENCH_SLUGS not found in page.tsx"); + return [...m[1]!.matchAll(/"([a-z0-9-]+)"/g)].map((x) => x[1]!); +} + +describe("the /data-api hub's bench list", () => { + test("is still parseable, so the rest of this file means something", () => { + expect(curatedSlugs().length).toBeGreaterThan(4); + }); + + test("is filtered through the retirement sets before it is rendered", () => { + // Without this the page links straight at 410s. + expect(SRC).toContain("REMOVED_BENCH_SLUGS.has(slug)"); + expect(SRC).toContain("DEV_ONLY_BENCH_SLUGS.has(slug)"); + }); + + test("renders no bench that is retired or dev-gated", () => { + const shown = curatedSlugs().filter( + (s) => !REMOVED_BENCH_SLUGS.has(s) && !DEV_ONLY_BENCH_SLUGS.has(s), + ); + for (const slug of shown) { + expect(REMOVED_BENCH_SLUGS.has(slug)).toBe(false); + expect(DEV_ONLY_BENCH_SLUGS.has(slug)).toBe(false); + } + expect(shown.length).toBeGreaterThan(0); + }); + + test("drops the two that were live links to 410 pages", () => { + // Pinned by name: both are in REMOVED_BENCH_SLUGS, so if either is ever + // un-retired this test says so instead of failing silently. + const curated = curatedSlugs(); + expect(curated).toContain("indexing-freshness"); + expect(curated).toContain("portfolio-chain-coverage"); + expect(REMOVED_BENCH_SLUGS.has("indexing-freshness")).toBe(true); + expect(REMOVED_BENCH_SLUGS.has("portfolio-chain-coverage")).toBe(true); + }); +}); diff --git a/src/app/data-api/page.tsx b/src/app/data-api/page.tsx index 8019892c0..0afbe896c 100644 --- a/src/app/data-api/page.tsx +++ b/src/app/data-api/page.tsx @@ -5,6 +5,7 @@ import { pageMetadata } from "@/lib/page-metadata"; import { safeJsonLd, buildBreadcrumbJsonLd } from "@/lib/jsonld"; import { SITE } from "@/data/site"; import { withUtm } from "@/lib/utm"; +import { DEV_ONLY_BENCH_SLUGS, REMOVED_BENCH_SLUGS } from "@/lib/removed-benches"; /** * Hub landing page for the data API vertical: price feeds, token metadata, @@ -27,7 +28,24 @@ export const metadata: import("next").Metadata = pageMetadata({ export const revalidate = 3600; -const BENCH_SLUGS = [ +/** + * The vertical's benches, as curated rather than derived: this hub groups + * and orders them editorially, so the list is written out. + * + * Filtered against the retirement sets before use. A hand-written list of + * slugs goes stale the day a bench is retired, and this one had: it still + * carried indexing-freshness (retired 2026-08-05) and + * portfolio-chain-coverage, both of which 410 on prod, and the first of them + * dragged /products/zerion onto the page as well — zerion has no product + * page and is itself in the removal set, exactly as the comment beside it + * there predicts ("zerion ... appear[s] in stale Redis results from retired + * benches (indexing-freshness for zerion)"). + * + * Three dead internal links on a live hub page, found by probing them. The + * filter is the fix rather than deleting the two entries, so the next + * retirement cannot put them back. + */ +const CURATED_BENCH_SLUGS = [ "aggregator-head-lag", "metadata-coverage", "asset-registry-coverage", @@ -39,6 +57,10 @@ const BENCH_SLUGS = [ "nft-collection-metadata", ] as const; +const BENCH_SLUGS = CURATED_BENCH_SLUGS.filter( + (slug) => !REMOVED_BENCH_SLUGS.has(slug) && !DEV_ONLY_BENCH_SLUGS.has(slug), +); + export default async function DataApiHubPage() { const snapshot = await fetchDataApiSnapshot(); @@ -147,16 +169,13 @@ export default async function DataApiHubPage() { tip="Best provider on aggregator-head-lag (p50 head lag, 24h, all chains & regions)." accent="#f59e0b" /> - + {/* The "Fastest wallet indexing" tile was removed on 2026-10-09 + with the bench behind it. indexing-freshness retired on + 2026-08-05, so headlineIndexing has been null ever since and + the tile rendered its fallback, "...", which reads as data + still loading rather than a bench that no longer exists. The + snapshot field stays and stays null, so the tile comes back + on its own if the bench ever does. */} {/* Group summary pills */} diff --git a/src/app/fee-compare/[venueA]/[venueB]/[wallet]/page.tsx b/src/app/fee-compare/[venueA]/[venueB]/[wallet]/page.tsx index a3a51dd48..82978be5a 100644 --- a/src/app/fee-compare/[venueA]/[venueB]/[wallet]/page.tsx +++ b/src/app/fee-compare/[venueA]/[venueB]/[wallet]/page.tsx @@ -10,7 +10,7 @@ const VENUE_NAMES: Record = { hyperliquid: "Hyperliquid", gains: "Gains", dydx: "dYdX v4", - "gmx-v2": "GMX v2", + "gmx-v2": "GMX", paradex: "Paradex", edgex: "EdgeX", }; diff --git a/src/app/hyperliquid/opengraph-image.tsx b/src/app/hyperliquid/opengraph-image.tsx index 62631546e..600c8a929 100644 --- a/src/app/hyperliquid/opengraph-image.tsx +++ b/src/app/hyperliquid/opengraph-image.tsx @@ -5,7 +5,8 @@ export const runtime = "nodejs"; // and link unfurlers fetch them constantly. Without a revalidate the // image was regenerated (satori, ~1-2 s of CPU) on every request. export const revalidate = 86400; -export const alt = "Hyperliquid frontends and HIP-3 DEX leaderboard. Live revenue, volume and users from a local HL node."; +export const alt = + "Hyperliquid frontends and HIP-3 DEX leaderboard. Live revenue, volume and users from Hyperliquid's public builder-fills feed and info API."; export const size = OG_SIZE; export const contentType = "image/png"; diff --git a/src/app/hyperliquid/traders/opengraph-image.tsx b/src/app/hyperliquid/traders/opengraph-image.tsx new file mode 100644 index 000000000..b5d96724d --- /dev/null +++ b/src/app/hyperliquid/traders/opengraph-image.tsx @@ -0,0 +1,20 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +// Same reasoning as the hub card: unfurlers fetch these constantly and +// satori costs ~1-2 s of CPU per render, while the content changes daily +// at most. +export const revalidate = 86400; +export const alt = + "Audit of Hyperliquid's published trader leaderboard: accounts claiming profit on zero volume, an aggregate that cannot be, and ROI without a denominator."; +export const size = OG_SIZE; +export const contentType = "image/png"; + +export default function OG() { + return renderHubOG({ + kicker: "Hyperliquid", + headline: "The leaderboard, audited.", + subline: + "Hyperliquid publishes PnL for tens of thousands of accounts. Thousands of them claim profit on no volume, and the aggregate cannot be what it says.", + }); +} diff --git a/src/app/hyperliquid/traders/page.tsx b/src/app/hyperliquid/traders/page.tsx new file mode 100644 index 000000000..2da7ba3b6 --- /dev/null +++ b/src/app/hyperliquid/traders/page.tsx @@ -0,0 +1,412 @@ +import Link from "next/link"; +import { fetchHlTraders } from "@/lib/hl-traders"; +import { pageMetadata } from "@/lib/page-metadata"; +import { safeJsonLd } from "@/lib/jsonld"; +import type { HlTraderRow, HlTradersSnapshot } from "@/types/hl-traders"; + +/** + * Audit of Hyperliquid's own published trader leaderboard. + * + * Deliberately not a copy of that table. The venue renders it already, + * and a reproduction would add nothing. What is missing is the reading + * of it, because three published properties contradict each other and + * the venue's UI offers no way to see it: + * + * 1. `pnl` and `vlm` sit in one row and count different universes. + * `vlm` is perp notional; `pnl` absorbs spot, vaults and staking. + * Thousands of rows therefore publish profit against zero volume. + * 2. the aggregate is strongly positive. Perp PnL is zero-sum between + * longs and shorts net of fees, so a positive sum over a majority + * of winners is proof the published set is not the population. + * 3. `roi` ships without its denominator, so it is not comparable + * across rows at any value. + * + * The table below is kept, capped and ranked, with the zero-volume rows + * marked in place rather than filtered out: dropping them would publish + * the flattering half of the fact. + * + * Data comes from the `hl-archive traders` collector through Upstash. + * When no snapshot exists the page says so, because zeros would read as + * a measurement. + */ + +const DESCRIPTION = + "Hyperliquid publishes a PnL leaderboard for tens of thousands of accounts. We audit it daily: profit on zero volume, ROI without a denominator, the perp split."; + +/** noindex until there is a snapshot to read. + * + * The page renders "Awaiting the first run" when the collector has not + * published yet, and an empty page that invites indexing is how a URL ends + * up in "crawled, currently not indexed": the crawl is spent, the + * impression never comes, and the rule has to be lifted by hand later. + * follow stays on, so the links out keep carrying. */ +export async function generateMetadata() { + const read = await fetchHlTraders().catch(() => null); + const hasData = Boolean(read?.snapshot); + return { + ...pageMetadata({ + path: "/hyperliquid/traders", + title: "Hyperliquid trader leaderboard, audited", + description: DESCRIPTION, + }), + robots: { index: hasData, follow: true }, + }; +} + +/** The snapshot refreshes once a day; an hourly window keeps the page + * cheap while still picking up a late collector run. */ +export const revalidate = 3600; + +function fmtUsd(v: number): string { + if (!Number.isFinite(v)) return "n/a"; + const neg = v < 0; + const a = Math.abs(v); + let s: string; + if (a >= 1e9) s = `$${(a / 1e9).toFixed(2)}B`; + else if (a >= 1e6) s = `$${(a / 1e6).toFixed(1)}M`; + else if (a >= 1e3) s = `$${(a / 1e3).toFixed(0)}K`; + else s = `$${a.toFixed(0)}`; + return neg ? `-${s}` : s; +} + +function fmtInt(v: number): string { + return Number.isFinite(v) ? v.toLocaleString("en-US") : "n/a"; +} + +function fmtPct(v: number, digits = 1): string { + return Number.isFinite(v) ? `${v.toFixed(digits)}%` : "n/a"; +} + +function shortAddr(a: string): string { + return a.length > 12 ? `${a.slice(0, 6)}…${a.slice(-4)}` : a; +} + +export default async function HlTradersPage() { + const read = await fetchHlTraders(); + + return ( +
+