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"
+ />
+
+ 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 @@
+
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 (
+
+
+
+
+
+ Hyperliquid
+
+
+ The leaderboard, audited
+
+
+ Hyperliquid publishes its own trader leaderboard as one public
+ blob: every account, with PnL, ROI, volume and equity. We do not
+ reproduce it. We read it, and three of its published properties
+ contradict each other.
+
+ This reading is {Math.round(read.ageHours)} hours old. The
+ collector runs daily, so the figures below describe the blob
+ as it stood then, not as it stands now.
+
+ {fmtInt(read.snapshot.integrity.malformed_rows)} of{" "}
+ {fmtInt(read.snapshot.integrity.accounts)} rows did not read
+ cleanly on this run, which means the published blob's
+ shape has moved. Treat every figure below as needing a
+ re-read before it is quoted.
+
+ )}
+
+
+
+ >
+ )}
+
+
+ Builder revenue and HIP-3 dexes live on the{" "}
+
+ Hyperliquid hub
+
+ . How we measure in general is on{" "}
+
+ Methodology
+
+ .
+
+ No audit snapshot has been published yet. The{" "}
+
+ hl-archive traders
+ {" "}
+ collector writes one per day; until it has run once, this page has
+ nothing measured to show. It deliberately shows nothing rather than
+ zeros, because a zero here would read as a reading.
+
+
+ );
+}
+
+function Findings({ snap }: { snap: HlTradersSnapshot }) {
+ const { integrity: i, decomposition: d } = snap;
+ const items = [
+ {
+ n: "I",
+ color: "var(--color-accent, #c97c5d)",
+ title: "Profit on no volume",
+ body: `${fmtInt(i.zero_vlm_accounts)} of ${fmtInt(
+ i.accounts,
+ )} accounts publish a non-zero all-time PnL against exactly zero all-time volume, worth ${fmtUsd(
+ i.zero_vlm_pnl_usd,
+ )}. That is ${fmtPct(
+ i.zero_vlm_pnl_pct,
+ )} of the leaderboard's aggregate PnL. The two fields count different universes: volume is perp notional, PnL also absorbs spot, vault deposits and staking. They are published side by side, and the table sorts on them.`,
+ },
+ {
+ n: "II",
+ color: "var(--color-warn, #c08a3c)",
+ title: "An aggregate that cannot be",
+ body: `The published rows sum to ${fmtUsd(
+ i.agg_pnl_usd,
+ )} of all-time PnL, with ${fmtPct(
+ i.winners_pct,
+ )} 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 that the published set is not the population: the losers are not in it. Spot and HLP yield are genuinely not zero-sum and account for part of the surplus, which is the same finding seen from the other side.`,
+ },
+ {
+ n: "III",
+ color: "#7a6db8",
+ title: "ROI without a denominator",
+ body: `${fmtInt(
+ i.roi_outliers,
+ )} accounts publish an ROI above 10,000% in absolute value, the highest of them past 2,600,000% on five figures of volume. ROI ships with no stated base, so it is not comparable between rows at any value, large or small. It is presented as a sortable column.`,
+ },
+ ];
+
+ return (
+
+
+
+ {items.map((it) => (
+
+
+
+ {it.n}
+
+
+ {it.title}
+
+
+ {it.body}
+
+
+ ))}
+
+
+ {d.sampled > 0 && (
+
+
+ The split the leaderboard flattens
+
+
+ The info API publishes a perp-only mirror of every window, so
+ the mix does not have to be inferred. Across the{" "}
+ {fmtInt(d.sampled)} highest-PnL accounts,{" "}
+ {fmtPct(d.non_perp_pnl_pct)} of lifetime PnL came from
+ somewhere other than perps ({fmtUsd(d.non_perp_pnl_usd)} of{" "}
+ {fmtUsd(d.pnl_usd)}), and {fmtInt(d.accounts_with_non_perp_pnl)}{" "}
+ of them carry a non-perp component at all. Read that as a
+ property of the rows the leaderboard showcases, never of the
+ population: ranking by PnL selects for the zero-volume cohort
+ by construction, which is why the sample size travels with the
+ figure.
+
+ The {fmtInt(rows.length)} highest rows of{" "}
+ {fmtInt(snap.integrity.accounts)}, ordered by the venue's own
+ all-time PnL. Rows claiming profit on zero volume are marked, not
+ removed: filtering them would publish the flattering half of the
+ fact.
+
+ );
+}
+
+function Methodology({ snap }: { snap: HlTradersSnapshot }) {
+ const items: [string, string][] = [
+ [
+ "Source",
+ `One GET of ${snap.source}, the venue's own published blob. Public, no key, roughly 39 MB. Every aggregate on this page is a count or a sum over its rows, so anyone can recompute all of them from the same file.`,
+ ],
+ [
+ "Perp split",
+ "Per account, POST /info with {\"type\":\"portfolio\"} returns pnlHistory and accountValueHistory over day, week, month and allTime plus a perp-only mirror of each. The split is read from perpAllTime against allTime, not modelled.",
+ ],
+ [
+ "Why no node",
+ "Per-fill data for every account would need a Hyperliquid node; the public CDN serves builder_fills and nothing else. It is also unnecessary here, because per-account PnL is published directly and a node adds only trade-level detail this page does not use.",
+ ],
+ [
+ "Zero-sum",
+ "Perp PnL nets to zero between longs and shorts, minus fees, with funding a transfer between them. Spot holdings and HLP yield are not zero-sum, so they are the honest explanation for part of a positive aggregate, and the reason the perp split matters.",
+ ],
+ [
+ "What we do not claim",
+ "Nothing here says a figure is wrong. Each one is the venue's own. The finding is that fields counting different universes are published in one sortable row, and that the set is not the population.",
+ ],
+ [
+ "Cadence",
+ `Collected daily. This reading is from ${snap.updated_at}.`,
+ ],
+ ];
+ return (
+
+
+
+
+ );
+}
diff --git a/src/app/rpc/page.tsx b/src/app/rpc/page.tsx
index d5d204f15..b77b368c1 100644
--- a/src/app/rpc/page.tsx
+++ b/src/app/rpc/page.tsx
@@ -26,11 +26,11 @@ import { withUtm } from "@/lib/utm";
*/
const DESCRIPTION =
- "RPC providers ranked per chain from 3 regions: free public endpoints with URLs, plus Alchemy, Chainstack, GetBlock and QuickNode on private (API-key) endpoints, ranked apart.";
+ "Free public RPC endpoints with URLs per chain, and Alchemy, Chainstack, GetBlock, QuickNode, Dwellir, Validation Cloud ranked apart, from 3 regions.";
export const metadata: import("next").Metadata = pageMetadata({
path: "/rpc",
- title: "RPC providers by chain: public and private endpoints, by latency",
+ title: "RPC providers by chain: public and private, by latency",
description: capSnippet(DESCRIPTION),
});
@@ -63,7 +63,14 @@ export default async function RpcHubPage() {
const featured = FEATURED.map((slug) => rpcSpecs.find((s) => s.slug === slug)).filter(
(s): s is (typeof rpcSpecs)[number] => Boolean(s),
);
- const linkableSlugs = rpcSpecs.map((s) => s.slug);
+ // The WebSocket benches live on their own `-rpc-ws` slugs, which
+ // the `-rpc` filter above excludes by construction. Their pages exist and
+ // answer 200, region variants included, so without them every row of the
+ // block-push cohort rendered "not yet ranked" and refused to link.
+ const wsSpecs = specs
+ .filter((s) => s.slug.endsWith("-rpc-ws"))
+ .filter((s) => !indexable || indexable.has(s.slug));
+ const linkableSlugs = [...rpcSpecs, ...wsSpecs].map((s) => s.slug);
// Chains whose page carries the API-key cohort (tier dimension): named
// in the intro so the " rpc provider" searcher sees Alchemy,
// Chainstack, GetBlock and QuickNode are measured too, behind the selector.
@@ -162,6 +169,20 @@ export default async function RpcHubPage() {
{chainLabelOf(s)} RPC endpoints
))}
+ {/* The two families shipped with this hub as their only
+ plausible entry point, and no link from it. */}
+
+ What each free RPC plan buys
+
+
+ WebSocket push race
+
= [
+ {
+ href: "/tools/perp-cost",
+ name: "Perp leverage cost",
+ what: "What ten perp venues take from the same leveraged trade, and the price move where a fee charged on the gain beats a fee charged on notional.",
+ input: "Margin, leverage, market, price move",
+ },
+ {
+ href: "/fee-compare",
+ name: "Perp fee comparison",
+ what: "Taker rates between any two perp venues, and, with a wallet address, what a real trade history would have cost on the other one.",
+ input: "Two venues, optionally a wallet",
+ },
+ {
+ href: "/speedtest-rpc",
+ name: "RPC speed test",
+ what: "Runs the same JSON-RPC calls we benchmark from your own browser, so the latency you read is measured from where you sit.",
+ input: "A chain, your connection",
+ },
+ {
+ href: "/rpc-map",
+ name: "RPC latency map",
+ what: "Where each public RPC endpoint answers quickly and where it does not, built from the speed test's contributions.",
+ input: "A chain, a provider",
+ },
+];
+
+export default function ToolsPage() {
+ const tools = TOOLS.filter((t) => !isDevOnlyRoute(t.href));
+
+ const jsonLd = buildItemListJsonLd({
+ name: "OpenChainBench tools",
+ url: `${SITE.url}/tools`,
+ description: DESCRIPTION,
+ items: tools.map((t) => ({
+ name: t.name,
+ url: `${SITE.url}${t.href}`,
+ description: t.what,
+ })),
+ breadcrumb: [
+ { name: "Home", url: SITE.url },
+ { name: "Tools", url: `${SITE.url}/tools` },
+ ],
+ });
+
+ return (
+
+
+
+
+ Tools
+
+
+ Tools that take your numbers
+
+
+ A benchmark ranks a cohort on one measurement. These do something else: you give them your
+ position, your wallet or your connection, and they answer for that. They run on the same
+ measurements and the same methodology as the{" "}
+
+ benchmarks
+
+ , and each one says where its numbers came from.
+
+
+
+ {tools.map((tool) => (
+
+
+
+
{tool.name}
+
+ {tool.input}
+
+
+
{tool.what}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/tools/perp-cost/opengraph-image.tsx b/src/app/tools/perp-cost/opengraph-image.tsx
new file mode 100644
index 000000000..db75b55c5
--- /dev/null
+++ b/src/app/tools/perp-cost/opengraph-image.tsx
@@ -0,0 +1,17 @@
+import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template";
+
+export const runtime = "nodejs";
+export const revalidate = 86400;
+export const alt =
+ "Perp leverage cost. What ten perp venues take from the same leveraged trade, and where a fee on the gain beats a fee on notional.";
+export const size = OG_SIZE;
+export const contentType = "image/png";
+
+export default function OG() {
+ return renderHubOG({
+ kicker: "Perp leverage cost",
+ headline: "What a leveraged trade costs you.",
+ subline:
+ "Nine venues charge a share of notional, twice. One charges a share of the gain, once. Drag the price move and the order changes.",
+ });
+}
diff --git a/src/app/tools/perp-cost/page.tsx b/src/app/tools/perp-cost/page.tsx
new file mode 100644
index 000000000..0fef08268
--- /dev/null
+++ b/src/app/tools/perp-cost/page.tsx
@@ -0,0 +1,161 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { PerpCostClient } from "@/components/perp-cost-client";
+import { buildBreadcrumbJsonLd, safeJsonLd } from "@/lib/jsonld";
+import { pageMetadata } from "@/lib/page-metadata";
+import { MEASURED_AT, NOT_SHOWN, VENUES } from "@/lib/perp-cost-model";
+import { SITE } from "@/data/site";
+
+const TITLE = "Perp leverage cost: fees, spread and liquidation";
+const DESCRIPTION =
+ "What ten perp venues take from the same leveraged trade: taker fee, half the quoted spread, and the move where a fee on the gain beats one on notional.";
+
+export const metadata: Metadata = pageMetadata({
+ path: "/tools/perp-cost",
+ title: TITLE,
+ description: DESCRIPTION,
+});
+
+export default function PerpCostToolPage() {
+ const jsonLd = {
+ "@context": "https://schema.org",
+ "@graph": [
+ buildBreadcrumbJsonLd([
+ { name: "Home", item: SITE.url },
+ { name: "Tools", item: `${SITE.url}/tools` },
+ { name: "Perp leverage cost", item: `${SITE.url}/tools/perp-cost` },
+ ]),
+ {
+ "@type": "WebApplication",
+ "@id": `${SITE.url}/tools/perp-cost#app`,
+ name: "Perp leverage cost calculator",
+ url: `${SITE.url}/tools/perp-cost`,
+ applicationCategory: "FinanceApplication",
+ operatingSystem: "Web",
+ offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
+ description: DESCRIPTION,
+ publisher: { "@id": `${SITE.url}/#org` },
+ },
+ ],
+ };
+
+ return (
+
+
+
+
+ Tools
+
+
+ What a leveraged trade actually costs you
+
+
+ Nine of the ten products below charge a share of your notional, twice, whether you
+ win or lose. Papertrade charges a share of your gain, once, and nothing if there is
+ no gain. No single fee rate can hold both, so this computes what each one takes from the
+ same trade. Drag the price move: the order changes, and where it changes is the answer.
+
+
+ Cohort-wide rankings of taker fee, spread and impact live on the{" "}
+
+ perp DEX leaderboard
+
+ , and the maximum-leverage terms behind the liquidation column are the{" "}
+
+ leverage terms benchmark
+
+ .
+
Divergences this month: fees up, token down, price to fees under the category median
+
Divergences this month: fee trend against token move, in both directions
+ p.signal === "fees-up-token-down").length,
@@ -1081,6 +1083,47 @@ function NaLegend({ entries }: { entries: readonly { marker: string; reason: str
* The text lives in src/lib/capital-hub-rules.ts and the Markdown view
* prints the same words.
*/
+/**
+ * The denominator under the divergence list.
+ *
+ * "1 token today" reads as a surveyed field that produced one answer. The
+ * usual cause is the opposite: the screen had almost nothing to test,
+ * because one of its legs is conditioned on a market direction that
+ * barely occurred that month. On 2026-10-04 the fee leg and the P/F leg
+ * each matched about half the board and the token leg matched four rows
+ * out of sixty-two, so the short list was the month rather than the
+ * filter. Printing the three counts costs nothing and turns a puzzling
+ * number into a readable one.
+ *
+ * Counts come from the same rows the table below renders, so a reader can
+ * recompute every one of them.
+ */
+function DivergenceFunnelNote({ rows }: { rows: ProtocolRow[] }) {
+ const f = divergenceFunnel(rows);
+ if (f.testable === 0) return null;
+ return (
+
+ What the screen had to work with.{" "}
+ Both readings run over the same {f.testable} testable {f.testable === 1 ? "protocol" : "protocols"}
+ {f.board !== f.testable && <> of {f.board} on the board>}. This month {f.feesUp} grew fees past{" "}
+ {SIGNAL_FEE_GROWTH_MIN_PCT}% and {f.pfBelowMedian} sit under their category median, but only{" "}
+ {f.tokenDown} saw the token fall more than {SIGNAL_PRICE_MOVE_MIN_PCT}%, against {f.tokenUp} that rose
+ by more than that. A screen that wants a falling token has that many candidates before any other
+ clause applies, so a short list here is usually the month rather than the filter.
+ {f.thinCategory > 0 && (
+ <>
+ {" "}
+ A further {f.thinCategory}{" "}
+ {f.thinCategory === 1 ? "protocol sits" : "protocols sit"} in{" "}
+ {f.thinCategoryNames.join(" and ")}, {f.thinCategoryNames.length === 1 ? "a category" : "categories"}{" "}
+ with fewer than {SIGNAL_MIN_CATEGORY_MEMBERS} ranked members: the median there is a pair rather
+ than a peer group, so those rows cannot carry either signal whatever they do.
+ >
+ )}
+
+ );
+}
+
function SignalReadings({ counts }: { counts: Record }) {
const kinds: SignalKind[] = ["fees-up-token-down", "fees-down-token-up"];
return (
diff --git a/src/components/count-leaderboard.test.ts b/src/components/count-leaderboard.test.ts
index 9d3035884..85ceffbf8 100644
--- a/src/components/count-leaderboard.test.ts
+++ b/src/components/count-leaderboard.test.ts
@@ -1,4 +1,6 @@
-import { describe, expect, it } from "bun:test";
+import { describe, expect, it, test } from "bun:test";
+import { displayResults } from "@/lib/provider-filters";
+import type { ProviderResult } from "@/types/benchmark";
/**
* The bar geometry of the default USD leaderboard, extracted so the
@@ -74,3 +76,68 @@ describe("the summary strip", () => {
expect(gap).toBe(0);
});
});
+
+/**
+ * The list and the summary strip have to agree on who is on the board.
+ *
+ * Bench 282 on 2026-10-09: the strip said "LEADER $29.5 BlockPI" while the
+ * list under it ranked Helius, Blockdaemon, Coinbase CDP, Syndica and Triton
+ * One at $0 in positions 01 to 05. None of the five publishes an Ethereum
+ * plan, so the loader had already marked them unavailable, carrying p50 = 0
+ * and a success rate of 0. The strip ran on a filtered list and ignored them;
+ * the list ran on the raw one and sorted them to the top, because on a cost
+ * board zero is the best possible price.
+ *
+ * displayResults is the filter both have to share. Its own doc comment asks
+ * for exactly this: "use for ranked surfaces (ledger, bar chart, per-chain
+ * pages)".
+ */
+describe("count leaderboard excludes rows with no reading", () => {
+ const costRow = (
+ slug: string,
+ p50: number,
+ availability: "live" | "unavailable",
+ successRate: number,
+ ): ProviderResult =>
+ ({
+ slug,
+ name: slug,
+ ms: { p50, p90: p50, p99: p50, mean: p50 },
+ availability,
+ successRate,
+ }) as unknown as ProviderResult;
+
+ // The real board: five providers with no Ethereum plan, two with one.
+ const board = [
+ costRow("helius", 0, "unavailable", 0),
+ costRow("blockdaemon", 0, "unavailable", 0),
+ costRow("coinbase-cdp", 0, "unavailable", 0),
+ costRow("syndica", 0, "unavailable", 0),
+ costRow("triton-one", 0, "unavailable", 0),
+ costRow("blockpi", 29.5, "live", 100),
+ costRow("chainstack", 49, "live", 100),
+ ];
+
+ test("keeps only the providers that actually quoted", () => {
+ expect(displayResults(board).map((r) => r.slug)).toEqual([
+ "blockpi",
+ "chainstack",
+ ]);
+ });
+
+ test("puts the real leader first once the empty rows are gone", () => {
+ const shown = [...displayResults(board)].sort((a, b) => a.ms.p50 - b.ms.p50);
+ expect(shown[0]?.slug).toBe("blockpi");
+ expect(shown[0]?.ms.p50).toBe(29.5);
+ });
+
+ // The guard is availability, not the value. A plan that genuinely bills
+ // nothing is a real answer and has to survive, or the fix for one wrong
+ // zero would hide every right one.
+ test("keeps a measured zero", () => {
+ const withFree = [...board, costRow("a-real-free-plan", 0, "live", 100)];
+ expect(displayResults(withFree).map((r) => r.slug)).toContain(
+ "a-real-free-plan",
+ );
+ });
+});
diff --git a/src/components/count-leaderboard.tsx b/src/components/count-leaderboard.tsx
index 7fd570483..99b3fd6b2 100644
--- a/src/components/count-leaderboard.tsx
+++ b/src/components/count-leaderboard.tsx
@@ -7,6 +7,7 @@ import Link from "next/link";
import type { Benchmark } from "@/types/benchmark";
import { fmtValue } from "@/lib/format";
import { rankResults } from "@/lib/ranking";
+import { displayResults } from "@/lib/provider-filters";
import { buildProviderColors } from "@/lib/series-colors";
import { ProviderLogo } from "@/components/provider-logo";
@@ -24,7 +25,20 @@ export function CountLeaderboard({
benchmark: Benchmark;
headerActions?: ReactNode;
}) {
- const ranked = rankResults(benchmark.results, benchmark.higherIsBetter);
+ // displayResults, not the raw list, which is what its own doc comment asks
+ // for: "use for ranked surfaces (ledger, bar chart, per-chain pages)".
+ //
+ // Without it an unavailable provider arrives carrying ms.p50 = 0 and a
+ // successRate of 0, and a cost board sorts it to the top as free. Bench 282
+ // showed Helius, Blockdaemon, Coinbase CDP, Syndica and Triton One at $0 in
+ // positions 01-05 above its real leader at $29.50 — none of them publishes
+ // an Ethereum plan, so the loader had already marked them unavailable and
+ // the header already ignored them. Only this list did not, so the page
+ // contradicted itself: "LEADER $29.5 BlockPI" above five cheaper rows.
+ const ranked = rankResults(
+ displayResults(benchmark.results),
+ benchmark.higherIsBetter,
+ );
// Exclude +Inf from max so finite bars render at meaningful widths
// instead of collapsing to 0% (Inf/Inf = NaN).
const finiteVals = ranked.map((r) => r.ms.p50).filter(Number.isFinite);
diff --git a/src/components/fee-compare-client.tsx b/src/components/fee-compare-client.tsx
index ae66db3da..66cf9755f 100644
--- a/src/components/fee-compare-client.tsx
+++ b/src/components/fee-compare-client.tsx
@@ -19,7 +19,7 @@ const COMPARABLE_VENUES = [
{ slug: "hyperliquid", name: "Hyperliquid", chain: "Hyperliquid L1" },
{ slug: "gains", name: "Gains", chain: "Arbitrum / Base" },
{ slug: "dydx", name: "dYdX v4", chain: "Cosmos" },
- { slug: "gmx-v2", name: "GMX v2", chain: "Arbitrum" },
+ { slug: "gmx-v2", name: "GMX", chain: "Arbitrum" },
{ slug: "paradex", name: "Paradex", chain: "Starknet" },
{ slug: "edgex", name: "EdgeX", chain: "StarkEx" },
] as const;
diff --git a/src/components/hl-feed-coverage-note.tsx b/src/components/hl-feed-coverage-note.tsx
index 7a1880a55..e2d4918e1 100644
--- a/src/components/hl-feed-coverage-note.tsx
+++ b/src/components/hl-feed-coverage-note.tsx
@@ -12,6 +12,14 @@ import type { HlFeedCoverage } from "@/lib/hl-feed";
* the hours before 13:00 hold only about 43% of the notional: a day from
* there is closer to a third than a half of itself.
*
+ * A third kind of day exists since 2026-10-15: one the node missed and cannot
+ * replay, rebuilt from the export's own morning spliced to an afternoon
+ * reassembled wallet by wallet from the info API. Rebuilding a day held in
+ * full came out 0.43% under the node, against 31% short for the export alone.
+ * Those days are reported on their own line and never folded into the node-day
+ * count, because close is not measured and the whole point of this note is
+ * that the reader can tell which they are reading.
+ *
* The note therefore has two voices. A window built from the export is a
* warning, because its totals read near half of what a full-feed tracker
* reports. A window that is almost all node days is a footnote naming the
@@ -26,7 +34,17 @@ export function HlFeedCoverageNote({
feed: HlFeedCoverage | null;
className?: string;
}) {
- if (!feedNeedsDisclosure(feed)) return null;
+ // A window holding reconstructions has to say so even when every day in it
+ // is nominally whole, which is exactly when the truncation test goes quiet.
+ // Saying nothing here would publish a reconstruction as a measurement, and
+ // that is the single thing this note exists to prevent.
+ const rebuilt = feed?.rebuiltDays ?? 0;
+ // Negative, and 0 when the harness does not report it. 0 alongside rebuilt
+ // days is treated as "not stated" rather than "no shortfall": a
+ // reconstruction described as exact is the most flattering way to be wrong.
+ const worstRebuilt = feed?.rebuiltWorstErrorPct ?? 0;
+ const truncation = feedNeedsDisclosure(feed);
+ if (!truncation && rebuilt === 0) return null;
const mostlyWhole = feedIsMostlyWhole(feed);
const tone = mostlyWhole
@@ -41,11 +59,13 @@ export function HlFeedCoverageNote({
className={`rounded-lg border p-4 text-[12.5px] leading-relaxed ${tone} ${className}`}
>
- {mostlyWhole
- ? "A few days come from a partial upstream feed"
- : "Upstream feed is publishing partial days"}
+ {!truncation
+ ? "Some days are rebuilt, not measured"
+ : mostlyWhole
+ ? "A few days come from a partial upstream feed"
+ : "Upstream feed is publishing partial days"}
- {!feed ? (
+ {!truncation ? null : !feed ? (
Coverage is not being measured on this run, so how much of each UTC
day the figures below cover is unknown. Hyperliquid's public
@@ -95,6 +115,34 @@ export function HlFeedCoverageNote({
.replace(" . ", ". ")}
)}
+ {rebuilt > 0 && (
+
+ {[
+ truncation
+ ? `${rebuilt} of those ${rebuilt === 1 ? "days is" : "days are"} rebuilt rather than measured:`
+ : `${rebuilt} ${rebuilt === 1 ? "day in this window is" : "days in this window are"} rebuilt rather than measured:`,
+ "the export's own hours up to its cutoff, spliced to an afternoon",
+ "reassembled wallet by wallet from Hyperliquid's info API, for an",
+ "outage our node cannot replay.",
+ // The magnitude, not a reassurance. Rebuilding two days we did
+ // hold in full gave -0.4% where the export's morning covered 69%
+ // of the day and -28% where it covered 27%, so quoting the good
+ // one as though it described all of them would be the flattering
+ // half of a measurement presented as the whole of it.
+ worstRebuilt < 0
+ ? `Each reads low, by up to ${Math.abs(worstRebuilt).toFixed(0)}% for the weakest day in this window.`
+ : "How far short they read is not reported on this run, so treat them as a floor.",
+ "How close one lands follows how much of the day the export's",
+ "morning covered: rebuilding two days we did hold in full gave",
+ "0.4% under the node where the morning covered 69% of it, and 28%",
+ "under where it covered 27%. The export alone was 31% and 73%",
+ "short on those same two days.",
+ "They remain a reconstruction and not a measurement, which is why",
+ "they are counted separately here and never added to the node-day",
+ "count.",
+ ].join(" ")}
+
+ )}
);
}
diff --git a/src/components/perp-cost-chart.tsx b/src/components/perp-cost-chart.tsx
new file mode 100644
index 000000000..a2bb844fb
--- /dev/null
+++ b/src/components/perp-cost-chart.tsx
@@ -0,0 +1,298 @@
+"use client";
+
+import { brandColor } from "@/lib/brand";
+import {
+ type CostModel,
+ type Market,
+ MOVE_RANGE,
+ papertradeKeep,
+ venueLabel,
+} from "@/lib/perp-cost-model";
+import { lineColor } from "@/lib/series-colors";
+
+const W = 760;
+const H = 300;
+const PAD = { top: 16, right: 18, bottom: 40, left: 62 };
+const PLOT = { w: W - PAD.left - PAD.right, h: H - PAD.top - PAD.bottom };
+
+/** The slider's full span, so the chart does not move when the slider does. */
+const MAX_MOVE = MOVE_RANGE.max / 1000 / 100;
+
+function compactUsd(v: number): string {
+ const a = Math.abs(v);
+ if (a >= 1e9) return `$${(v / 1e9).toFixed(a >= 1e10 ? 0 : 1)}B`;
+ if (a >= 1e6) return `$${(v / 1e6).toFixed(a >= 1e7 ? 0 : 1)}M`;
+ if (a >= 1e4) return `$${Math.round(v / 1e3)}k`;
+ if (a >= 100) return `$${Math.round(v)}`;
+ if (a === 0) return "$0";
+ return `$${v.toFixed(a < 10 ? 2 : 1)}`;
+}
+
+/**
+ * Ticks a reader recognises. Quartering the axis gave $247 and 0.16%, which
+ * are correct and unreadable; this walks 1, 2 or 5 times a power of ten so
+ * every label lands on a round number.
+ */
+function niceTicks(max: number, target: number): number[] {
+ if (!(max > 0)) return [0];
+ const raw = max / target;
+ const magnitude = 10 ** Math.floor(Math.log10(raw));
+ const normalised = raw / magnitude;
+ const step = (normalised >= 5 ? 5 : normalised >= 2 ? 2 : 1) * magnitude;
+ const ticks: number[] = [];
+ for (let v = 0; v <= max * 1.0001; v += step) ticks.push(v);
+ return ticks;
+}
+
+/**
+ * A series takes the venue's own brand colour when we have one, and a palette
+ * slot otherwise. Exported so the legend beside the chart cannot drift from
+ * the lines it is labelling.
+ */
+export function seriesColor(logoKey: string, i: number): string {
+ return brandColor(logoKey) ?? lineColor(i);
+}
+
+/**
+ * Why the ranking flips, drawn.
+ *
+ * A fee on notional does not care how far the price travels, so every
+ * notional-fee venue is a horizontal line. A fee on the gain grows with the
+ * gain, so Papertrade is a curve out of the origin. The curve therefore
+ * crosses each line exactly once, and that crossing is the whole argument of
+ * the page: left of it the gain fee is cheaper, right of it the flat fee is.
+ *
+ * The x span is the slider's whole range and not the current value, so
+ * dragging the slider moves the marker across a chart that holds still.
+ */
+export function PerpCostChart({ model, market }: { model: CostModel; market: Market }) {
+ const papertrade = model.live.find((r) => r.venue.charges === "gain") ?? null;
+ const flats = model.live.filter((r) => r.venue.charges === "notional");
+
+ const gainCostAt = (move: number) =>
+ move * model.notional * (1 - papertradeKeep(market, move));
+ const flatMax = flats.length > 0 ? Math.max(...flats.map((r) => r.cost)) : 0;
+
+ /**
+ * The frame, which is the whole readability of this chart.
+ *
+ * Drawn across the slider's full 2% the curve climbs to six times the flat
+ * lines, so the crossing, the one thing worth seeing, was squashed into the
+ * bottom eighth. So the x span is the region where the two are comparable:
+ * a few multiples of the crossover, never less than where the slider
+ * currently sits, and never more than the slider's own maximum. The y span
+ * then follows the curve's height at that edge, so the plot always fills.
+ */
+ const xMax = Math.min(
+ MAX_MOVE,
+ Math.max(
+ model.move * 1.3,
+ model.crossoverMove !== null ? model.crossoverMove * 3 : 0,
+ MAX_MOVE / 8,
+ ),
+ );
+ const curveMax = papertrade ? gainCostAt(xMax) : 0;
+ const yMax = Math.max(curveMax, flatMax, 1e-9) * 1.1;
+ const zoomed = xMax < MAX_MOVE * 0.999;
+
+ const x = (move: number) => PAD.left + (move / xMax) * PLOT.w;
+ const y = (cost: number) => PAD.top + PLOT.h - (cost / yMax) * PLOT.h;
+
+ const curve = papertrade
+ ? Array.from({ length: 121 }, (_, i) => {
+ const move = (i / 120) * xMax;
+ return `${i === 0 ? "M" : "L"}${x(move).toFixed(1)} ${y(gainCostAt(move)).toFixed(1)}`;
+ }).join(" ")
+ : null;
+
+ const colorOf = seriesColor;
+ const ptColor = colorOf("papertrade", 0);
+
+ const xTicks = niceTicks(xMax, 4);
+ const xTickDigits = xMax >= 0.01 ? 1 : xMax >= 0.002 ? 2 : 3;
+ const yTicks = niceTicks(yMax, 2);
+
+ const crossX = model.crossoverMove !== null ? x(model.crossoverMove) : null;
+ const crossY =
+ model.crossoverMove !== null && model.rival !== null ? y(model.rival.cost) : null;
+ // A crossover past the slider's own range cannot be drawn honestly.
+ const crossInRange = model.crossoverMove !== null && model.crossoverMove <= MAX_MOVE;
+
+ const nowX = x(Math.min(model.move, xMax));
+
+ const summary = papertrade
+ ? `Cost of the trade as the price move grows, from zero to 2% of price. ${venueLabel(
+ papertrade.venue,
+ )} is a curve from the origin because it charges on the gain. ${
+ flats.length > 0
+ ? `${flats.length} venue${flats.length > 1 ? "s" : ""} charging on notional are flat lines.`
+ : "No notional-fee venue is open at this leverage."
+ }${
+ crossInRange && model.rival
+ ? ` The curve crosses ${venueLabel(model.rival.venue)} at a ${(
+ model.crossoverMove! * 100
+ ).toFixed(3)}% move.`
+ : ""
+ }`
+ : "Cost of the trade as the price move grows. Every open venue charges on notional, so every line is flat.";
+
+ return (
+
+
+
+
+ A fee on notional does not move with the price, so it draws a flat line. A fee on the gain
+ grows with the gain, so it draws a curve out of the origin. They cross once.
+ {zoomed
+ ? ` Framed on the region where the two are comparable, up to a ${(xMax * 100).toFixed(
+ 2,
+ )}% move; the slider runs to 2%.`
+ : ""}
+
+
+ );
+}
diff --git a/src/components/perp-cost-client.tsx b/src/components/perp-cost-client.tsx
new file mode 100644
index 000000000..cd30d7d6d
--- /dev/null
+++ b/src/components/perp-cost-client.tsx
@@ -0,0 +1,687 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { PerpCostChart, seriesColor } from "@/components/perp-cost-chart";
+import { ProviderLogo } from "@/components/provider-logo";
+import { getProviderRegistry, safeRegistryURL } from "@/data/provider-registry";
+import {
+ type CostRow,
+ computeCostModel,
+ feeLegFraction,
+ LEVERAGE_RANGE,
+ type Market,
+ MARGIN_RANGE,
+ MOVE_RANGE,
+ unavailableReason,
+ venueLabel,
+} from "@/lib/perp-cost-model";
+import { withUtm } from "@/lib/utm";
+
+// ── Formatting ────────────────────────────────────────────────────────────
+
+function usd(value: number): string {
+ const magnitude = Math.abs(value);
+ const digits = magnitude < 100 ? 2 : 0;
+ return `${value < 0 ? "-" : ""}$${magnitude.toLocaleString("en-US", {
+ maximumFractionDigits: digits,
+ })}`;
+}
+
+function pct(value: number, digits = 2): string {
+ return `${value.toLocaleString("en-US", {
+ minimumFractionDigits: digits,
+ maximumFractionDigits: digits,
+ })}%`;
+}
+
+/**
+ * A gain of 0.17% on margin must not round to "0%", and one of 1,000% must not
+ * carry two decimals, so the precision follows the magnitude.
+ */
+function gainPct(value: number): string {
+ const magnitude = Math.abs(value);
+ return pct(value, magnitude < 1 ? 2 : magnitude < 10 ? 1 : 0);
+}
+
+/**
+ * Past the whole gain the figure stops being a share of anything, so it stops
+ * pretending to be one: an 18,424.8% share is a venue taking 184 times the
+ * gain, which is not a number a reader can repair on their own.
+ */
+function shareOfGainLabel(share: number): string {
+ if (share <= 1) return pct(share * 100, 1);
+ return `${share.toLocaleString("en-US", { maximumFractionDigits: 1 })}x the gain`;
+}
+
+function bps(value: number): string {
+ return value.toLocaleString("en-US", { maximumFractionDigits: 2 });
+}
+
+// ── Input handling ────────────────────────────────────────────────────────
+
+type NumberField = { value: number; valid: boolean };
+
+function readField(raw: string, min: number, max: number, fallback: number): NumberField {
+ const trimmed = raw.trim();
+ const parsed = Number(trimmed);
+ const valid = trimmed !== "" && Number.isFinite(parsed) && parsed >= min && parsed <= max;
+ return { value: valid ? parsed : fallback, valid };
+}
+
+/**
+ * The leverages where something actually changes: each is a venue's cap, its
+ * minimum, or the only leverage one product trades at. Typing 500 to discover
+ * that the x500 degen pair exists is not discoverable, a chip is.
+ */
+const LEVERAGE_PICKS = [1, 10, 25, 40, 50, 100, 200, 500, 1000];
+
+// ── Shared class strings ──────────────────────────────────────────────────
+
+const LABEL = "block font-sans text-[10px] uppercase tracking-[0.16em] text-ink-faint mb-1.5";
+const TH =
+ "px-3 py-2.5 font-sans text-[10px] uppercase tracking-[0.12em] text-ink-faint font-medium text-right whitespace-nowrap";
+const TD = "px-3 py-3 text-right font-mono text-xs tabular-nums text-ink-soft";
+const TAG =
+ "inline-block rounded border border-rule px-1.5 py-px font-mono text-[10px] uppercase tracking-[0.08em] text-ink-faint";
+
+function numberInputClass(valid: boolean): string {
+ return [
+ "w-full rounded-xl border bg-paper px-3.5 py-2.5 font-mono text-sm tabular-nums",
+ "focus:outline-none transition-colors",
+ valid ? "border-ink/15 text-ink focus:border-ink/40" : "border-bad text-ink",
+ ].join(" ");
+}
+
+// ── Component ─────────────────────────────────────────────────────────────
+
+export function PerpCostClient() {
+ const [marginRaw, setMarginRaw] = useState(String(MARGIN_RANGE.default));
+ const [leverageRaw, setLeverageRaw] = useState(String(LEVERAGE_RANGE.default));
+ const [moveSlider, setMoveSlider] = useState(MOVE_RANGE.default);
+ const [market, setMarket] = useState("BTC");
+
+ const margin = readField(marginRaw, MARGIN_RANGE.min, MARGIN_RANGE.max, MARGIN_RANGE.default);
+ const leverage = readField(
+ leverageRaw,
+ LEVERAGE_RANGE.min,
+ LEVERAGE_RANGE.max,
+ LEVERAGE_RANGE.default,
+ );
+ // The slider is in thousandths of a percent, so 200 is 0.200% of price.
+ const move = moveSlider / 1000 / 100;
+
+ const model = useMemo(
+ () => computeCostModel({ margin: margin.value, leverage: leverage.value, market, move }),
+ [margin.value, leverage.value, market, move],
+ );
+
+ const inputsValid = margin.valid && leverage.valid;
+ const widestShare = Math.max(...model.live.map((r) => r.shareOfGain), 1e-4);
+ const runnerUp = model.live[1] ?? null;
+
+ return (
+
+
+
+ {pct(move * 100, 3)}
+ {" "}
+ of {market} price
+
+
+ ={" "}
+
+ +{gainPct((model.rawGain / model.margin) * 100)}
+ {" "}
+ on your margin, {usd(model.rawGain)} on {usd(model.notional)} of notional
+
+
+
+
+ {!inputsValid && (
+
+ That value is outside the accepted range (margin $1 to $1,000,000,000, leverage 1x to
+ 1000x), so every figure below is for {usd(model.margin)} at {model.leverage}x.
+
+ At this move nothing is profitable.{" "}
+ The fees exceed the gain on every open venue, so the ranking below is the order of the
+ damage and not a recommendation.
+
+ {/* Not a failure, just a product that does not trade here, so
+ this reads neutral: eight red pills made the default view
+ look like an error state. */}
+
+
+ not offered at {model.leverage}x
+
+ {unavailableReason(row, model.leverage)}
+
+
+ ))}
+
+
+
+
+
+ {model.crossoverMove !== null && model.rival ? (
+ <>
+
+ Against {venueLabel(model.rival.venue)}, the cheapest notional-fee venue open at{" "}
+ {model.leverage}x, the crossover sits at a {pct(model.crossoverMove * 100, 3)} move
+
+ , a gain of{" "}
+ {gainPct(((model.crossoverMove * model.notional) / model.margin) * 100)} on your
+ margin. Below it Papertrade takes less; above it {model.rival.venue.name} does, and
+ the gap widens from there. Against this one rival the leverage does not move the
+ crossover, because it multiplies both costs equally. What leverage changes is which
+ rival is open at all, and that moves the number a long way.
+ >
+ ) : model.live.some((r) => r.venue.charges === "gain") ? (
+ <>
+
+ Papertrade is the only venue open at {model.leverage}x
+
+ , so there is nothing charging on notional to cross over against. The figure above is
+ its haircut on the gain; drag the move and the share falls, but no ordering appears
+ until a second venue opens.
+ >
+ ) : (
+ <>
+
+ No gain-based venue is open at {model.leverage}x
+
+ , so every row charges a share of notional and the ranking is simply the cheapest fee
+ plus half spread.
+ >
+ )}
+
+
+
+ {/* ── The other two paths ─────────────────────────────────────── */}
+
+
+
+ The other two paths
+
+
What happens when it does not work
+
+
+
+
+
+ What a flat close and a liquidation cost, by venue
+
+
+
+
+ Venue
+
+
+ Trade closes flat
+
+
+ Trade is liquidated
+
+
+
+
+ {model.live.map((row) => (
+
+
+
+
+
+ {row.flatCost === 0 ? (
+ costs nothing
+ ) : (
+ `${usd(row.flatCost)} of fees on a $0 gain`
+ )}
+
+
+ {row.equityLeftPct > 0 ? (
+ <>
+ {usd(row.equityLeftPct * model.margin)} left of {usd(model.margin)}
+
+ {pct(row.equityLeftPct * 100, 1)} of your margin, before the closing fee
+
+ >
+ ) : (
+
+ nothing comes back
+
+ )}
+
+
+ ))}
+
+
+
+
+
+ Only the venues open at the chosen leverage appear here; the first table lists the others
+ and why. The liquidation column is the equity the venue's own trigger leaves behind,{" "}
+ before the closing fee that trigger
+ charges, and it is not a promise the money reaches you. On{" "}
+ Gains it does not: we paired every
+ liquidation we could find and{" "}
+ amountSentToTrader read zero on all 27, across
+ every pair from 7.5x to 500x.{" "}
+ Papertrade's documentation calls
+ its liquidation a hard bust that forfeits the full isolated margin. For the others this is
+ the parameter, which we have not checked against a realised payout.
+
+ );
+}
+
+/**
+ * Logo, name, what sets this product apart, and where it trades. `compact`
+ * drops the chain and execution tags for the second table, which lists the
+ * same venues a screen below and does not need to repeat them.
+ */
+function VenueCell({
+ row,
+ muted = false,
+ compact = false,
+}: {
+ row: CostRow;
+ muted?: boolean;
+ compact?: boolean;
+}) {
+ const { venue } = row;
+ const site = safeRegistryURL(getProviderRegistry(venue.logoKey)?.url);
+ return (
+
+ );
+}
diff --git a/src/components/perp-venue-section.tsx b/src/components/perp-venue-section.tsx
index e5bb3a1e5..63de3ec84 100644
--- a/src/components/perp-venue-section.tsx
+++ b/src/components/perp-venue-section.tsx
@@ -7,6 +7,7 @@ import { findVenue, getPerpVolumeHistory } from "@/lib/perp-volume-history";
import { PERP_VENUES, type PerpVenueType } from "@/lib/perp-stats";
import { loadBenchFromBlob } from "@/lib/bench-blob";
import { logoPath } from "@/lib/logo-manifest";
+import { perpProductSlug } from "@/lib/perp-product-slug";
import { PerpVenueKpiStrip } from "@/components/perp-venue-kpi-strip";
import { PerpBarChart } from "@/components/perp-bar-chart";
import {
@@ -306,8 +307,12 @@ export async function PerpVenueSection({
Other perp venues on the same benchmarks
{otherVenues.map((v) => {
- const productSlug = v.slug === "gmx-v2" ? "gmx" : v.slug;
- const lp = logoPath(v.slug);
+ // One slug for both: this used to normalize gmx-v2 to gmx for the
+ // link on the line above and then look the logo up under the raw
+ // cohort slug, which has no manifest entry, so GMX rendered with
+ // the initial-letter fallback while every other venue had a mark.
+ const productSlug = perpProductSlug(v.slug);
+ const lp = logoPath(productSlug);
return (
[]) =>
+ rows.some((r) => r.bestArchiveDepthBlocks != null);
+
+describe("archive column visibility", () => {
+ it("shows the column when any row was measured", () => {
+ expect(
+ hasArchive([
+ { bestArchiveDepthBlocks: undefined },
+ { bestArchiveDepthBlocks: 5_000_000 },
+ ]),
+ ).toBe(true);
+ });
+
+ it("hides it on a cohort where nothing is measured", () => {
+ // The keyed / cold / websocket cohorts, every chain.
+ expect(
+ hasArchive([
+ { bestArchiveDepthBlocks: undefined },
+ { bestArchiveDepthBlocks: undefined },
+ ]),
+ ).toBe(false);
+ });
+
+ it("hides it rather than throwing on an empty cohort", () => {
+ expect(hasArchive([])).toBe(false);
+ });
+
+ it("counts a depth of 0 as measured, not as absent", () => {
+ // A provider that serves only the head block is a real answer, and
+ // `!= null` keeps it where a truthiness test would drop it.
+ expect(hasArchive([{ bestArchiveDepthBlocks: 0 }])).toBe(true);
+ });
+});
+
+describe("every optional column is gated in both places", () => {
+ const src = readFileSync("src/components/rpc-chains-leaderboard.tsx", "utf8");
+
+ // Each of these draws a header and a cell. Gating one and not the other
+ // shifts every column to its right by one position, which reads as a wrong
+ // number rather than as a broken table.
+ for (const flag of ["hasP90", "hasP95", "hasArchive"]) {
+ it(`${flag} gates exactly two pieces of JSX`, () => {
+ expect(src.match(new RegExp(`\\{${flag} && \\(`, "g"))?.length).toBe(2);
+ });
+
+ it(`${flag} is part of the column count`, () => {
+ expect(src).toContain(`(${flag} ? 1 : 0)`);
+ });
+ }
+
+ it("renders the archive header and cell exactly once each", () => {
+ expect(src.match(/sortKey === "archive"/g)?.length).toBe(1);
+ expect(src.match(/\{fmtArchive\(/g)?.length).toBe(1);
+ });
+
+ it("counts the empty-search span instead of typing it", () => {
+ expect(src).toContain("colSpan={colCount}");
+ expect(src).not.toMatch(/colSpan=\{\d/);
+ });
+});
diff --git a/src/components/rpc-chains-leaderboard.tsx b/src/components/rpc-chains-leaderboard.tsx
index 8352822ba..74ec576a0 100644
--- a/src/components/rpc-chains-leaderboard.tsx
+++ b/src/components/rpc-chains-leaderboard.tsx
@@ -7,7 +7,12 @@ import { useRouter } from "next/navigation";
import { ChevronRight } from "lucide-react";
import { ProviderLogo } from "@/components/provider-logo";
import { Sparkline } from "@/components/sparkline";
-import type { RpcHubChain, RpcRegionKey } from "@/lib/rpc-hub-stats";
+import {
+ RPC_REGION_KEYS,
+ RPC_REGION_LABELS,
+ type RpcHubChain,
+ type RpcRegionKey,
+} from "@/lib/rpc-hub-shape";
/**
* Sortable + searchable chain matrix for /rpc. One row per `-rpc`
@@ -23,22 +28,21 @@ type SortKey =
| "name"
| "bestP50"
| "bestP90"
+ | "bestP95"
| "archive"
| "us-east"
| "eu-west"
| "sgp"
| "providerCount";
-const REGION_COLS: { key: RpcRegionKey; label: string }[] = [
- { key: "us-east", label: "US-East" },
- { key: "eu-west", label: "EU-West" },
- { key: "sgp", label: "Singapore" },
-];
+const ALL_REGION_COLS: { key: RpcRegionKey; label: string }[] =
+ RPC_REGION_KEYS.map((key) => ({ key, label: RPC_REGION_LABELS[key] }));
function sortValue(r: RpcHubChain, k: SortKey): number | string | null {
if (k === "name") return r.name.toLowerCase();
if (k === "bestP50") return r.best?.p50Ms ?? null;
if (k === "bestP90") return r.bestP90Ms ?? null;
+ if (k === "bestP95") return r.bestP95Ms ?? null;
if (k === "archive") return r.bestArchiveDepthBlocks ?? null;
if (k === "providerCount") return r.providerCount;
return r.regions[k]?.p50Ms ?? null;
@@ -54,6 +58,12 @@ export function RpcChainsLeaderboard({
rows,
linkableSlugs,
benchQuery = "",
+ regions,
+ bestLabel = "Best overall (3-region avg)",
+ bestTip = "Lowest p50 averaged across the 3 probe regions. A provider can win overall without winning any single region: consistent everywhere beats fast in one region, slow elsewhere.",
+ scopeNote = "3 probe regions",
+ geoControl,
+ raceMode = false,
}: {
rows: RpcHubChain[];
/** Bench slugs whose page is indexable (worker sitemap). When given it
@@ -63,12 +73,57 @@ export function RpcChainsLeaderboard({
/** Suffix appended to every bench link ("#tier=keyed" on the
* API-key view, so the row opens the cohort it ranks). */
benchQuery?: string;
+ /** Which per-region winner columns to draw. The geo view passes an
+ * empty list: its rows already hold one region's numbers, so the
+ * column would repeat the Best column. Default is all three. */
+ regions?: RpcRegionKey[];
+ /** Heading and tooltip of the leading latency column, which stops
+ * being a 3-region average once the geo view scopes the rows. */
+ bestLabel?: string;
+ bestTip?: string;
+ /** Tail of the caption above the table, which claimed three regions
+ * whatever the rows held. */
+ scopeNote?: string;
+ /** The hub's region selector, rendered in this table's header bar. The
+ * state belongs to the hub (both tables are scoped by it); only the
+ * placement is ours, beside the caption that names the scope. */
+ geoControl?: React.ReactNode;
+ /** The block-push cohort is a race, not a latency: every leader reads 0
+ * because it is zero behind itself. Under this flag the leading cell
+ * shows the margin over the runner-up, which is the figure that carries
+ * information, instead of a zero that reads as a broken measurement. */
+ raceMode?: boolean;
}) {
const [showAll, setShowAll] = useState(false);
const linkableSet = useMemo(
() => (linkableSlugs ? new Set(linkableSlugs) : null),
[linkableSlugs],
);
+ const regionCols = useMemo(
+ () =>
+ regions
+ ? regions.map((key) => ({ key, label: RPC_REGION_LABELS[key] }))
+ : ALL_REGION_COLS,
+ [regions],
+ );
+ /** A headline p90 is computed over all three regions, so the geo view does
+ * not carry it over: it substitutes the region variant's own tail when
+ * there is one and leaves the column empty when there is not. Same rule as
+ * archive, because an empty column makes a claim of its own. */
+ const hasP90 = useMemo(() => rows.some((r) => r.bestP90Ms != null), [rows]);
+ /** p95 exists only where a spec asks for it, which today is the keyed
+ * cluster. Gated like every other optional column rather than drawn as a
+ * row of dashes on the benches that do not declare it. */
+ const hasP95 = useMemo(() => rows.some((r) => r.bestP95Ms != null), [rows]);
+ /** The archive probe only runs on the public cohort: the keyed harness has
+ * no depth probe at all, so on the three API-key cohorts every cell was a
+ * dash and the column read as "no provider serves archive" instead of "not
+ * measured here". Derived from `rows`, not from the search result, so
+ * typing in the box cannot make a column appear and disappear. */
+ const hasArchive = useMemo(
+ () => rows.some((r) => r.bestArchiveDepthBlocks != null),
+ [rows],
+ );
const router = useRouter();
const [sortKey, setSortKey] = useState("bestP50");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
@@ -98,6 +153,14 @@ export function RpcChainsLeaderboard({
});
}, [rows, sortKey, sortDir, q]);
+ // #, chain, best, providers, trend, plus whatever is drawn.
+ const colCount =
+ 5 +
+ regionCols.length +
+ (hasP90 ? 1 : 0) +
+ (hasP95 ? 1 : 0) +
+ (hasArchive ? 1 : 0);
+
const setSort = (k: SortKey) => {
if (k === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
else {
@@ -110,13 +173,15 @@ export function RpcChainsLeaderboard({
return (
-
- {filtered.length} of {rows.length} chains · 24h p50, 3 probe
- regions
-
+
+
+ {filtered.length} of {rows.length} chains · 24h p50, {scopeNote}
+
No chain matches “{q}”.
@@ -381,6 +478,37 @@ export function RpcChainsLeaderboard({
);
}
+/** The margin between the provider that pushes first and the next one.
+ * On a race the leader's own figure is zero by construction, so the gap is
+ * what a reader can act on: it is how much of a head start the winner
+ * gives you over the second-best socket on that chain. */
+function RaceMargin({ row }: { row: RpcHubChain }) {
+ const field = row.providers ?? [];
+ const runnerUp = field[1];
+ const margin =
+ runnerUp && row.best ? runnerUp.p50Ms - row.best.p50Ms : null;
+ if (margin == null || !Number.isFinite(margin) || margin <= 0) {
+ return (
+
+ no runner-up
+
+ );
+ }
+ return (
+
+ {fmtMs(margin)} ahead
+
+ );
+}
+
function Th({ children }: { children: React.ReactNode }) {
return (
-rpc#tier=keyed`, the tab that ranks them.
+ *
+ * Third cohort: the cold-start arm, the same keyed vendors and endpoints
+ * measured on a connection that is never reused. It has been a tier on
+ * the per-chain bench pages since 2026-10-08 but could not reach the hub
+ * until the snapshot carried it. Kept as its own cohort rather than extra
+ * columns because the vendor ordering differs between the two arms, and a
+ * reader comparing a pooled service with a one-shot client is asking a
+ * different question, not reading a second column.
+ *
+ * Geo selector (2026-10-16): the chain table always had three region
+ * columns, but each held only that region's winner, and the provider pivot
+ * averaged the three, so "who is fastest for my users in Singapore,
+ * provider by provider" was unreadable here. The per-provider per-region
+ * p50 was in the snapshot all along, so the selector re-scopes the cohort
+ * in place: no extra fetch, and the tail columns go empty rather than
+ * repeat a 3-region number under a region heading.
*/
type Tab = "chains" | "providers";
-type Cohort = "public" | "keyed";
+type Geo = RpcRegionKey | "all";
+type Cohort = "public" | "keyed" | "keyed-cold" | "websocket";
+
+/** Caption under the Access pills. Deliberately not a vendor list: the old
+ * copy named four providers and went stale twice as the cohort grew to six,
+ * so the count comes from the data and the names come from the table. */
+const COHORT_NOTE: Record = {
+ public: "Free, no-key endpoints probed every 60 s.",
+ keyed:
+ "Private endpoints (API key) on a pooled connection, probed every 120 s. Ranked on their own, never against the public gateways.",
+ "keyed-cold":
+ "The same private endpoints on a connection that is never reused: DNS, TCP and the TLS handshake are paid on every call. What a serverless or one-shot client gets.",
+ websocket:
+ "The same private endpoints on a persistent WebSocket, racing to push each new block. The column shows how far ahead the first provider is of the next one: a provider's own lag is measured against whichever one delivered that block first, so the winner is zero behind itself.",
+};
+
+/** How each cohort's table is named inside the summary-card tooltip. */
+const COHORT_MATRIX: Record = {
+ public: "public",
+ keyed: "private",
+ "keyed-cold": "cold-start",
+ websocket: "block-push",
+};
export function RpcHubTabs({
snapshot,
@@ -39,16 +84,88 @@ export function RpcHubTabs({
linkableSlugs ? c.chains.filter((ch) => linkableSlugs.includes(ch.slug)).length : c.chains.length;
const [tab, setTab] = useState("chains");
const [cohort, setCohort] = useState("public");
+ const [geo, setGeo] = useState("all");
const keyed = snapshot.keyed;
- const view: RpcHubCohort = cohort === "keyed" && keyed ? keyed : snapshot;
- const benchQuery = cohort === "keyed" && keyed ? "#tier=keyed" : "";
+ const keyedCold = snapshot.keyedCold;
+ const websocket = snapshot.websocket;
+ // A cohort the snapshot does not carry falls back to the public view
+ // rather than rendering an empty table: an older worker blob has no
+ // keyedCold field, and the pill for it is hidden in that case anyway.
+ const cohorts: Record = {
+ public: snapshot,
+ keyed,
+ "keyed-cold": keyedCold,
+ websocket,
+ };
+ const all: RpcHubCohort = cohorts[cohort] ?? snapshot;
+ const resolved: Cohort = cohorts[cohort] ? cohort : "public";
+ // One region's numbers substituted for the blended ones. Derived here so
+ // both tables and every summary card read the same scope.
+ const view: RpcHubCohort = useMemo(
+ () => (geo === "all" ? all : scopeCohortToRegion(all, geo)),
+ [all, geo],
+ );
+ // Chains with at least one provider answering from each region, which is
+ // what the pill's count has to say: Singapore is short of us-east on the
+ // private cohort because not every vendor has a point of presence there.
+ const geoCounts = useMemo(
+ () =>
+ Object.fromEntries(
+ RPC_REGION_KEYS.map((k) => [
+ k,
+ all.chains.filter((c) =>
+ c.providers.some((p) => p.regions?.[k] != null),
+ ).length,
+ ]),
+ ) as Record,
+ [all],
+ );
+ const geoLabel = geo === "all" ? "all regions" : RPC_REGION_LABELS[geo];
+ // Rendered here, displayed inside whichever table is showing: the scope is
+ // a property of that table, and the caption beside it already names it.
+ const geoControl = (
+
+ setGeo("all")}
+ count={all.chains.length}
+ title="Every probe region: the leading column is the 3-region average and each region column holds that region's winner."
+ >
+ All regions
+
+ {RPC_REGION_KEYS.map((k) => (
+ setGeo(k)}
+ count={geoCounts[k]}
+ title={`Every number measured from ${RPC_REGION_LABELS[k]} only, tails included: each region variant carries its own p90, p95 and p99.`}
+ >
+ {RPC_REGION_LABELS[k]}
+
+ ))}
+
+ );
+ // Where a row goes. The region rides as a query the bench page already
+ // serves (verified 200, variants included), so a reader scoped to
+ // Singapore lands on Singapore. The WebSocket benches are their own slugs
+ // rather than a tier of the chain page, so they take no tier hash.
+ const benchQuery =
+ (geo === "all" ? "" : `?region=${geo}`) +
+ (resolved === "public" || resolved === "websocket"
+ ? ""
+ : `#tier=${resolved}`);
// Keyed chain pages are indexable through their public view; the
// sitemap gate applies to the page, not to the cohort.
const fastest = fastestOverall(view);
return (
<>
- {keyed && (
+ {(keyed || keyedCold || websocket) && (
-
- {cohort === "keyed"
- ? "Private endpoints (API key): Alchemy, Chainstack, GetBlock and QuickNode, probed every 120 s. Ranked on their own, never against the public gateways."
- : "Free, no-key endpoints probed every 60 s."}
-
+ {COHORT_NOTE[resolved]}
)}
@@ -85,16 +218,24 @@ export function RpcHubTabs({
@@ -123,16 +264,37 @@ export function RpcHubTabs({
{tab === "chains" && (
)}
{tab === "providers" && (
({
chain: c.chain,
name: c.name,
@@ -186,10 +348,50 @@ function SummaryCard({
function fmtMs(v: number): string {
if (!Number.isFinite(v)) return "...";
+ // Same rule as the tables: the summary card carried the same zero, since
+ // "Smallest push lag" on the race cohort is a figure under one millisecond.
+ if (v < 10) return `${v.toFixed(1)} ms`;
if (v < 1000) return `${Math.round(v)} ms`;
return `${(v / 1000).toFixed(2)} s`;
}
+/** The region pills, sized to sit inside a table header next to the caption
+ * and the search box rather than in a selector row of their own. */
+function GeoButton({
+ children,
+ active,
+ count,
+ title,
+ onClick,
+}: {
+ children: React.ReactNode;
+ active: boolean;
+ count: number;
+ title?: string;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
+
function TabButton({
children,
active,
diff --git a/src/components/rpc-providers-pivot.tsx b/src/components/rpc-providers-pivot.tsx
index 95972a953..5201df1a5 100644
--- a/src/components/rpc-providers-pivot.tsx
+++ b/src/components/rpc-providers-pivot.tsx
@@ -5,7 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { ChevronRight } from "lucide-react";
import { ProviderLogo } from "@/components/provider-logo";
-import type { RpcHubPivotRow } from "@/lib/rpc-hub-stats";
+import type { RpcHubPivotRow } from "@/lib/rpc-hub-shape";
/**
* Provider pivot for /rpc: one row per free RPC gateway across every
@@ -38,9 +38,24 @@ function sortValue(r: RpcHubPivotRow, k: SortKey): number | null {
export function RpcProvidersPivot({
rows,
chains,
+ regionLabel = "all regions",
+ geoControl,
+ raceMode = false,
}: {
rows: RpcHubPivotRow[];
chains: ChainRef[];
+ /** Which probe scope the cells were measured in, named in the footnote.
+ * The hub's geo selector re-scopes the rows to one region, and a cell
+ * that reads "all regions" when it holds Singapore only is a wrong
+ * caption on a right number. */
+ regionLabel?: string;
+ /** The hub's region selector, rendered in this table's header bar so the
+ * control sits with the caption it changes. */
+ geoControl?: React.ReactNode;
+ /** The block-push cohort is a race: this column is a median lag behind
+ * whichever provider pushed the block first, not a latency, and the
+ * winner sits near zero because it is zero behind itself. */
+ raceMode?: boolean;
}) {
const router = useRouter();
const [sortKey, setSortKey] = useState("chainsCovered");
@@ -79,6 +94,19 @@ export function RpcProvidersPivot({
});
}, [rows, sortKey, sortDir, q]);
+ /** Both are optional on the row and absent whenever the cohort's specs
+ * have no success or sample-size query that resolves. A column of dashes
+ * states "no failures counted", which is a claim about the providers
+ * rather than about the measurement. */
+ const hasSuccess = useMemo(
+ () => rows.some((r) => r.medianSuccessPct != null),
+ [rows],
+ );
+ const hasErrors = useMemo(
+ () => rows.some((r) => r.errors24h != null),
+ [rows],
+ );
+
const setSort = (k: SortKey) => {
if (k === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
else {
@@ -94,13 +122,16 @@ export function RpcProvidersPivot({
return (
-
- {filtered.length} of {rows.length} providers · rank + median
- p50 across {chains.length} chains
-
+
+
+ {filtered.length} of {rows.length} providers · rank + median
+ p50 across {chains.length} chains, {regionLabel}
+
+ {geoControl}
+
setSort("medianP50Ms")}
>
- Median p50
-
- setSort("medianSuccessPct")}
- >
- Success
-
- setSort("errors24h")}
- >
- Errors (24h)
+
+ {raceMode ? "Median lag" : "Median p50"}
+
+ {hasSuccess && (
+ setSort("medianSuccessPct")}
+ >
+ Success
+
+ )}
+ {hasErrors && (
+ setSort("errors24h")}
+ >
+ Errors (24h)
+
+ )}
{/* Chain logos double as column headers for the rank
strip below: same 22px slots + gap as the cells, so
each rank square reads under its chain mark. */}
@@ -223,16 +266,20 @@ export function RpcProvidersPivot({
No provider matches “{q}”.
@@ -276,9 +323,13 @@ export function RpcProvidersPivot({
Per-chain cells show the provider's leaderboard rank on the
- chain marked by the logo above (24h p50, all regions). Success is
- the median 24h success rate across covered chains; Errors (24h) is
- the summed failed-probe count (sample size × failure rate).
+ chain marked by the logo above (24h p50, {regionLabel}).
+ {hasSuccess
+ ? " Success is the median 24h success rate across covered chains."
+ : ""}
+ {hasErrors
+ ? " Errors (24h) is the summed failed-probe count (sample size × failure rate)."
+ : ""}
);
@@ -358,6 +409,11 @@ function Td({
function fmtMs(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "...";
+ // One decimal below 10 ms. Rounding to the nearest millisecond printed a
+ // 0.3 ms median as "0 ms", which reads as a broken measurement rather than
+ // as a provider that is a third of a millisecond behind, and it flattened
+ // 2.1 against 5.8 into 2 against 6 on the HTTP cohorts.
+ if (v < 10) return `${v.toFixed(1)} ms`;
if (v < 1000) return `${Math.round(v)} ms`;
return `${(v / 1000).toFixed(2)} s`;
}
diff --git a/src/components/site-nav-items.ts b/src/components/site-nav-items.ts
index 20d7159f1..e6fe10222 100644
--- a/src/components/site-nav-items.ts
+++ b/src/components/site-nav-items.ts
@@ -4,6 +4,7 @@ import {
Bot,
BookMarked,
Boxes,
+ Calculator,
ChartColumn,
CircleHelp,
Code,
@@ -165,6 +166,9 @@ export function navGroups(hiddenRoutes: readonly string[] = []): NavGroup[] {
{
label: "Explore",
items: [
+ // A tool answers for the reader's own position, which no bench page
+ // does, so it belongs beside Compare and not under Benchmarks.
+ { href: "/tools", label: "Tools", icon: Calculator, match: section("/tools") },
{ href: "/compare", label: "Compare", icon: GitCompare, match: section("/compare") },
{ href: "/alternatives", label: "Alternatives", icon: Scale, match: section("/alternatives") },
{
diff --git a/src/components/speedtest/speedtest-rpc-client.tsx b/src/components/speedtest/speedtest-rpc-client.tsx
index 225d810ed..276c6129a 100644
--- a/src/components/speedtest/speedtest-rpc-client.tsx
+++ b/src/components/speedtest/speedtest-rpc-client.tsx
@@ -471,6 +471,25 @@ function contributeToMap(chainSlug: string | null, endpoints: Endpoint[]) {
* through us to dodge the block would measure our server instead of their
* connection and would send their API keys to us.
*/
+/** A blocking condition the reader has to act on, told at body size inside
+ * a bordered box. Deliberately not the page's caption style: these two
+ * messages are the difference between "this tool is broken" and "two
+ * clicks and it runs", and they were previously set as 10px footnotes. */
+function BlockNotice({
+ lead,
+ children,
+}: {
+ lead: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
{lead}
+
{children}
+
+ );
+}
+
function useIsBrave(): boolean {
const [brave, setBrave] = useState(false);
useEffect(() => {
@@ -512,6 +531,11 @@ export function SpeedtestRpcClient({ mapLive = true }: { mapLive?: boolean }) {
return () => cancelAnimationFrame(raf);
}, []);
const [prefillState, setPrefillState] = useState<"idle" | "validating" | "done">("idle");
+ /** Every prefilled endpoint failed the reachability check. Measured, not
+ * guessed from the browser's name: the check POSTs eth_chainId to each
+ * one, and all of them failing means the visitor would sit through the
+ * whole run to be told nothing answered. */
+ const [allUnreachable, setAllUnreachable] = useState(false);
const [skippedProviders, setSkippedProviders] = useState([]);
const prefillEpoch = useRef(0);
const [durationSec, setDurationSec] = useState(30);
@@ -569,6 +593,7 @@ export function SpeedtestRpcClient({ mapLive = true }: { mapLive?: boolean }) {
// some fail).
if (good.length === 0) {
setSkippedProviders([]);
+ setAllUnreachable(true);
setPrefillState("done");
console.warn(
"[speedtest] reachability precheck failed for ALL endpoints; keeping the list (likely browser/network-level blocking).",
@@ -577,6 +602,7 @@ export function SpeedtestRpcClient({ mapLive = true }: { mapLive?: boolean }) {
}
if (skipped.length > 0) setInputs(good);
setSkippedProviders(skipped);
+ setAllUnreachable(false);
setPrefillState("done");
})();
};
@@ -858,10 +884,16 @@ export function SpeedtestRpcClient({ mapLive = true }: { mapLive?: boolean }) {
{/* Said before the run, not after it: with Shields up every
endpoint fails and the visitor has spent the whole test
duration to be told to change a setting. */}
- {isBrave && (
-
- Brave detected. Shields blocks requests to RPC hosts by default, which makes every endpoint fail. Turn Shields down for this site (the lion in the address bar) before you start.
-
+ {allUnreachable && (
+
+ Every prefilled endpoint failed a single eth_chainId request
+ just now, so the run would take its full duration to tell you
+ the same thing. Something local is stopping requests to RPC
+ hosts: a privacy or ad-blocking extension, a VPN, a corporate
+ network{isBrave ? ", or Brave Shields (the lion in the address bar)" : ""}.
+ Turning it off for this site, or pasting an endpoint of your
+ own, is what fixes it.
+
)}
@@ -1125,13 +1157,23 @@ export function SpeedtestRpcClient({ mapLive = true }: { mapLive?: boolean }) {
// at once, and the reader is left with a dead end instead
// of the one thing that would fix it.
ranked.length === 0 ? (
-
- Every endpoint was blocked before it answered, including ones that do allow browser calls, so the block is on this side rather than at the providers.{" "}
- {isBrave
- ? "Brave Shields blocks requests to RPC hosts by default. Click the lion in the address bar and turn Shields down for this site, then run the test again. We cannot do this for you: the test only means anything because the requests go from your browser straight to the provider, never through us."
- : "A shields-up browser, a privacy or ad-blocking extension, a VPN or a corporate network will stop requests to RPC hosts. Try a normal window with extensions off, or paste your own endpoint."}{" "}
- The public benchmarks measure all of these server-side.
-
+
+
+ A privacy or ad-blocking extension, a VPN or a corporate
+ network will stop requests to RPC hosts
+ {isBrave ? ", and so will Brave Shields if they are up for this site" : ""}.
+ Try a window with extensions off, or paste your own
+ endpoint. We cannot do it for you: the test only means
+ anything because the requests go from your browser
+ straight to the provider, never through us.{" "}
+ Every endpoint failed before answering, including ones
+ that do allow browser calls, so the block is on this side
+ rather than at the providers. The public benchmarks
+ measure all of them server-side.
+
+
) : (
Not testable from a browser (skipped): {blockedDir.map((e) => epLabel(e)).join(", ")}. The public bench covers {blockedDir.length > 1 ? "them" : "it"} server-side.
diff --git a/src/components/static-ledger.test.ts b/src/components/static-ledger.test.ts
new file mode 100644
index 000000000..4ce646d74
--- /dev/null
+++ b/src/components/static-ledger.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+/**
+ * The static ledger is the Suspense fallback: it is what a crawler and a
+ * no-JS reader get, and for them it is the only table on the page.
+ *
+ * It had drifted from the interactive one in two ways that both published
+ * something the bench does not claim. Bench 282 sends no RPC traffic and sets
+ * `ledger_reliability: false` — its own spec comment says a success rate
+ * there "measures nothing" — yet this table printed a Success column filled
+ * with 100.00%, the value the loader defaults to when a spec declares no
+ * success query. And it headed the money column "Value" where every reader
+ * of the real table sees "Monthly bill", because it took the generic label
+ * instead of the one the spec names.
+ *
+ * Asserted against the source rather than a render: the component needs a
+ * whole Benchmark and a React tree, and what is worth pinning here is that
+ * the two values come from the spec at all.
+ */
+const SRC = readFileSync(
+ join(import.meta.dir, "static-ledger.tsx"),
+ "utf8",
+);
+
+describe("the crawler-facing ledger", () => {
+ test("gates the Success column on the spec's own reliability flag", () => {
+ expect(SRC).toContain("benchmark.ledgerReliability !== false");
+ // Both the header cell and the body cell, or the table misaligns.
+ const gated = SRC.match(/showReliability && \(/g) ?? [];
+ expect(gated.length).toBe(2);
+ });
+
+ test("takes the value column's name from the spec when it names one", () => {
+ expect(SRC).toContain('ledgerColumns?.find((c) => c.slot === "p50")?.label');
+ });
+
+ test("still falls back to the generic label for specs that name none", () => {
+ expect(SRC).toContain("valueColumnLabel(benchmark)");
+ });
+});
diff --git a/src/components/static-ledger.tsx b/src/components/static-ledger.tsx
index b0555ec32..74ae7421e 100644
--- a/src/components/static-ledger.tsx
+++ b/src/components/static-ledger.tsx
@@ -61,6 +61,21 @@ export function StaticLedger({ benchmark }: { benchmark: Benchmark }) {
: ranked < rows.length
? `Results: ${countRows(benchmark, rows.length)} measured, ${ranked} ranked by ${benchmark.metric} (${qualifier})`
: `Results: ${countRows(benchmark, rows.length)} ranked by ${benchmark.metric} (${qualifier})`;
+ // A success rate is a claim about requests this bench sent. Benches that
+ // send none set `ledger_reliability: false`, and the interactive table
+ // honours it — but this fallback is what crawlers and no-JS readers get,
+ // and it printed "Success 100.00%" for every row of bench 282, filled from
+ // the default the loader uses when a spec declares no success query. The
+ // spec's own comment says a success rate there "measures nothing".
+ const showReliability = benchmark.ledgerReliability !== false;
+ // The spec names its own value column, and the interactive table uses that
+ // name. This fallback fell back to the generic one, so bench 282's crawler
+ // view headed its money column "Value" where every reader of the real
+ // table sees "Monthly bill". Only a column bound to the headline slot can
+ // stand in for it.
+ const valueHeading =
+ benchmark.ledgerColumns?.find((c) => c.slot === "p50")?.label ??
+ valueColumnLabel(benchmark);
const showTail = benchmark.unit === "ms" || benchmark.unit === "s";
return (
@@ -76,13 +91,15 @@ export function StaticLedger({ benchmark }: { benchmark: Benchmark }) {
+ )}
))}
diff --git a/src/data/bench-published.json b/src/data/bench-published.json
index dee77e8c8..3352a70fe 100644
--- a/src/data/bench-published.json
+++ b/src/data/bench-published.json
@@ -11,7 +11,9 @@
"aptos-rpc": "2026-08-18T18:11:28.000Z",
"arbitrum-nova-rpc": "2026-08-04T08:05:53.000Z",
"arbitrum-rpc": "2026-07-03T14:33:15.000Z",
+ "arbitrum-rpc-ws": "2026-10-15T10:40:29.034Z",
"arc-rpc": "2026-09-21T13:03:12.000Z",
+ "arc-rpc-ws": "2026-10-15T10:40:29.034Z",
"archway-rpc": "2026-08-04T15:53:48.000Z",
"asset-registry-coverage": "2026-07-24T16:00:55.000Z",
"astar-rpc": "2026-08-04T15:53:48.000Z",
@@ -23,11 +25,13 @@
"babylon-rpc": "2026-08-27T23:39:33.000Z",
"bandchain-rpc": "2026-08-29T20:53:32.000Z",
"base-rpc": "2026-07-03T14:33:15.000Z",
+ "base-rpc-ws": "2026-10-15T10:40:29.034Z",
"berachain-rpc": "2026-07-03T22:44:42.000Z",
"bitcoin-cash-rpc": "2026-08-27T16:35:12.000Z",
"bitcoin-rpc": "2026-09-09T20:59:45.000Z",
"bitlayer-rpc": "2026-08-04T12:27:13.000Z",
"bnb-rpc": "2026-07-03T14:33:15.000Z",
+ "bnb-rpc-ws": "2026-10-15T10:40:29.034Z",
"bob-rpc": "2026-08-03T21:50:42.000Z",
"boba-rpc": "2026-08-04T15:53:48.000Z",
"bridge-execution-latency": "2026-09-10T14:24:06.000Z",
@@ -62,6 +66,7 @@
"eos-rpc": "2026-08-18T23:06:04.000Z",
"ethereum-classic-rpc": "2026-08-05T14:29:04.000Z",
"ethereum-rpc": "2026-07-03T14:33:15.000Z",
+ "ethereum-rpc-ws": "2026-10-15T10:40:29.034Z",
"etherlink-rpc": "2026-08-05T14:29:04.000Z",
"evm-block-builders": "2026-07-16T16:01:49.000Z",
"evm-quote-latency": "2026-06-26T12:41:12.000Z",
@@ -86,6 +91,7 @@
"hyperliquid-frontends": "2026-06-02T16:07:35.000Z",
"hyperliquid-hip3-deployers": "2026-06-10T23:44:26.000Z",
"hyperliquid-rpc": "2026-07-24T02:36:11.000Z",
+ "hyperliquid-rpc-ws": "2026-10-15T10:40:29.034Z",
"icon-rpc": "2026-08-30T12:43:27.000Z",
"immutable-rpc": "2026-07-26T14:46:53.000Z",
"injective-rpc": "2026-07-24T12:35:13.000Z",
@@ -152,6 +158,7 @@
"perp-funding-stability": "2026-06-24T13:14:37.000Z",
"perp-liq-rate": "2026-08-15T15:06:23.000Z",
"perp-mark-price-lag": "2026-08-02T20:16:43.000Z",
+ "perp-max-leverage-terms": "2026-10-12T15:35:55.000Z",
"perp-pe-ratio": "2026-08-23T01:05:02.000Z",
"perp-pf-ratio": "2026-09-12T18:34:12.000Z",
"perp-protocol-longevity": "2026-08-02T20:16:43.000Z",
@@ -166,6 +173,7 @@
"pm-ws-latency": "2026-07-28T21:59:34.000Z",
"polkadot-rpc": "2026-07-15T17:14:34.000Z",
"polygon-rpc": "2026-07-03T14:33:15.000Z",
+ "polygon-rpc-ws": "2026-10-15T10:40:29.034Z",
"polygon-zkevm-rpc": "2026-08-04T08:05:53.000Z",
"portfolio-chain-coverage": "2026-07-06T13:47:16.000Z",
"protocol-pf-ratio": "2026-09-23T11:54:19.000Z",
@@ -174,10 +182,12 @@
"regen-rpc": "2026-08-04T15:53:48.000Z",
"reya-rpc": "2026-08-04T15:53:48.000Z",
"robinhood-rpc": "2026-09-21T13:03:12.000Z",
+ "robinhood-rpc-ws": "2026-10-15T10:40:29.034Z",
"ronin-rpc": "2026-07-26T14:46:53.000Z",
"rootstock-rpc": "2026-08-03T21:50:42.000Z",
"rpc-capabilities": "2026-05-20T18:43:56.000Z",
"rpc-cost": "2026-09-30T08:29:38.979Z",
+ "rpc-free-tier": "2026-10-06T12:45:59.000Z",
"rpc-reliability": "2026-07-16T16:01:49.000Z",
"rwa-solana-depth": "2026-09-23T20:39:05.000Z",
"rwa-yield-accuracy": "2026-07-24T13:56:11.000Z",
@@ -186,8 +196,8 @@
"sentinel-rpc": "2026-08-28T18:56:40.000Z",
"shentu-rpc": "2026-08-29T17:19:43.000Z",
"solana-rpc": "2026-07-12T15:37:15.000Z",
+ "solana-rpc-ws": "2026-10-15T10:40:29.034Z",
"solana-tx-landing": "2026-05-21T12:25:08.000Z",
- "trading-platform-wars": "2026-08-11T15:07:42.000Z",
"soneium-rpc": "2026-07-03T22:44:42.000Z",
"songbird-rpc": "2026-08-05T14:29:04.000Z",
"sonic-rpc": "2026-07-03T22:44:42.000Z",
@@ -218,6 +228,7 @@
"trading-agent-alpha": "2026-10-06T14:29:17.731Z",
"trading-app-daily-volume": "2026-09-17T14:31:00.000Z",
"trading-platform-wallets": "2026-08-21T14:51:39.000Z",
+ "trading-platform-wars": "2026-08-11T15:07:42.000Z",
"tron-rpc": "2026-07-24T02:36:11.000Z",
"unichain-rpc": "2026-07-03T22:44:42.000Z",
"union-rpc": "2026-08-29T16:02:33.000Z",
diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts
index 7ee65ddd1..af25f5b01 100644
--- a/src/data/provider-registry.ts
+++ b/src/data/provider-registry.ts
@@ -10,6 +10,18 @@
* factual, no marketing adjectives. Twitter handle without the URL.
*/
+/**
+ * Provider registry. Optional enrichment for each provider slug that
+ * appears in one or more benchmark specs.
+ *
+ * The registry is purely additive. Providers without an entry still
+ * render at /providers/, they just lack the description, URL,
+ * and Twitter handle. Add a new entry when onboarding a provider.
+ *
+ * Keep descriptions sober and technical. One or two short sentences,
+ * factual, no marketing adjectives. Twitter handle without the URL.
+ */
+
/**
* A statement the provider makes about itself that no harness can measure:
* swap insurance, confidentiality, whether users are shielded from
@@ -64,12 +76,9 @@ export type ProviderRegistryEntry = {
description?: string;
twitter?: string;
- // Optional enrichment. NOTE: of the seven fields below, none is currently
- // read by any page. They were added for a product-page section that was
- // never built, and the comment here used to claim they were "surfaced on
- // the product detail page", which is not true. Either wire them or drop
- // them; do not add an eighth expecting it to render. `claims` below IS
- // rendered, and its test fails the build if a claim is malformed.
+ // Optional rich content surfaced on the product detail page when present.
+ // Every field is independently optional; the page renders only the
+ // sections that have data, so partial enrichment is safe.
longDescription?: string;
chains?: string[];
features?: string[];
@@ -105,8 +114,12 @@ export const PROVIDER_REGISTRY: Record = {
// ─── Cosmos RPC operators (chain-hunt sweep 2026-10-07) ───────
// New to the site with ZIGChain, Agoric, XPLA and XRPL EVM. Each runs a
// keyless public Tendermint RPC that answered our probe before inclusion.
- // nodestake and stakeandrelax also serve these chains and were already
- // registered, so they are not repeated here.
+ cumulo: {
+ url: "https://cumulo.pro",
+ description:
+ "Validator operator running public Tendermint RPC endpoints for Cosmos SDK chains, including XRPL EVM.",
+ twitter: "@cumulo_pro",
+ },
numia: {
url: "https://numia.xyz",
description:
@@ -118,12 +131,6 @@ export const PROVIDER_REGISTRY: Record = {
description:
"Validator operator publishing free Tendermint RPC endpoints for the Cosmos chains it validates.",
},
- cumulo: {
- url: "https://cumulo.pro",
- description:
- "Validator operator running public Tendermint RPC endpoints for Cosmos SDK chains, including XRPL EVM.",
- twitter: "@cumulo_pro",
- },
// ─── AI labs (bench 284) ──────────────────────────────────────
// These four entries are the labs whose models Recall Labs runs in its
@@ -186,6 +193,11 @@ export const PROVIDER_REGISTRY: Record = {
"DEX data terminal by CoinGecko covering 200+ chains. REST API for pools, tokens, trades, and OHLCV.",
twitter: "@GeckoTerminal",
},
+ papertrade: {
+ url: "https://papertrade.xyz",
+ description:
+ "Synthetic perpetuals on Hyperliquid with two markets, BTC and ETH, at up to 1000x. Positions face a single house LP rather than an order book, and there are no open, close or funding fees.",
+ },
jupiter: {
url: "https://jup.ag",
description:
@@ -359,6 +371,15 @@ export const PROVIDER_REGISTRY: Record = {
"DeFi shared engine that maps onchain interactions into composable actions. The Route API computes optimal multi-step paths across protocols and chains, abstracting approvals, swaps, vault entries, and bridges into a single call.",
twitter: "@EnsoBuild",
},
+ route: {
+ url: "https://route.fun",
+ description:
+ "Swap router for Robinhood Chain, chain ID 4663, and no other chain. route.fun is the swap interface; the partner API quotes and builds a swap in one request and is key-gated.",
+ twitter: "@routedotfun",
+ longDescription:
+ "Route routes swaps across Robinhood Chain liquidity, which spans Uniswap v3 and v4 pools including hooked ones, Pons launchpad pools and oracle-priced prop AMMs. It serves one chain, so it has no cross-chain path selection to do and its quote endpoint answers from a single network. The partner API at partners.route.fun takes a Bearer key and returns a quote and a built transaction together, rather than quoting and building in two calls. Route publishes that swap fees and token creator fees fund ROUTE buybacks, ROUTE/USDG liquidity and its treasury; that is the project's own statement about its economics and nothing here measures it.",
+ docs: "https://docs.route.fun",
+ },
// ─── Prediction markets (bench 028) ───────────────────────────
polymarket: {
diff --git a/src/lib/brand.ts b/src/lib/brand.ts
index d8476f61d..cfc71d12d 100644
--- a/src/lib/brand.ts
+++ b/src/lib/brand.ts
@@ -85,6 +85,7 @@ const BRANDS: Record = {
codex: { color: "#84cc16" }, // saturated lime - readable on white + dark
geckoterminal: { color: "#8B5CF6" }, // vivid violet (gecko brand)
jupiter: { color: "#C7F284" }, // jupiter matrix green (secondary brand) - keeps it
+ papertrade: { color: "#8B2020" }, // the red underscore in their own favicon
// distinct from mobula's orange on solana-dex-quote
raydium: { color: "#00C2FF" }, // raydium cyan
openocean: { color: "#2563EB" }, // openocean deep blue
diff --git a/src/lib/bridge-hub-stats.ts b/src/lib/bridge-hub-stats.ts
index a30d0eaea..37fe32b45 100644
--- a/src/lib/bridge-hub-stats.ts
+++ b/src/lib/bridge-hub-stats.ts
@@ -160,8 +160,14 @@ async function _fetchBridgeHub(): Promise {
};
}
+// Tagged "benchmarks" so the worker's post-publish revalidation reaches this
+// hub the way it reaches the bench pages it reads. Untagged, it ran on its own
+// 300s clock under a page whose ISR is 3600s, so the hub could show figures up
+// to an hour behind /benchmarks/bridge-fee and /benchmarks/bridge-quote-latency
+// while claiming the same trailing 24h window. Same defect, same fix, as the
+// /trading-agents hub.
export const fetchBridgeHub = unstable_cache(
_fetchBridgeHub,
["bridge-hub"],
- { revalidate: 300 }
+ { revalidate: 300, tags: ["benchmarks"] }
);
diff --git a/src/lib/capital-hub-rules.test.ts b/src/lib/capital-hub-rules.test.ts
index 797386fab..cb000d7f1 100644
--- a/src/lib/capital-hub-rules.test.ts
+++ b/src/lib/capital-hub-rules.test.ts
@@ -12,6 +12,7 @@ import {
categoryMedianUsable,
change7dOfSeries,
cohortCell,
+ divergenceFunnel,
fmtUsdLevel,
naLegend,
naMarker,
@@ -257,7 +258,7 @@ describe("perp open interest rows", () => {
{ slug: "hyperliquid", name: "Hyperliquid", venueType: "onchain" as const, openInterest: 12.77e9, volume24h: 6.61e9 },
{ slug: "trade-xyz", name: "trade.xyz", venueType: "onchain" as const, openInterest: 3.76e9, volume24h: 2.19e9 },
{ slug: "ondo", name: "Ondo Perps", venueType: "onchain" as const, openInterest: 88.6e6, volume24h: 126e6 },
- { slug: "gmx-v2", name: "GMX v2", venueType: "onchain" as const, openInterest: 51.5e6, volume24h: 54.8e6 },
+ { slug: "gmx-v2", name: "GMX", venueType: "onchain" as const, openInterest: 51.5e6, volume24h: 54.8e6 },
// The harness gets no open interest from these: a row with a reason, not a dropped row.
{ slug: "orderly", name: "Orderly", venueType: "onchain" as const, openInterest: null, volume24h: 63.6e6 },
{ slug: "backpack", name: "Backpack", venueType: "onchain" as const, openInterest: null, volume24h: 197.4e6 },
@@ -379,3 +380,61 @@ describe("sectionState", () => {
expect(sectionState([B({ live: true, failed: true })], "protocol-pf-ratio", 0)).toBe("failed");
});
});
+
+describe("divergenceFunnel", () => {
+ // Shaped like the live board on 2026-10-04: fees up and P/F below median
+ // are each roughly half the field, the token leg is the scarce one, and
+ // two rows sit in a category too small for its median to be a peer group.
+ const board = [
+ { feeGrowth30dPct: 263, priceChange30dPct: -21, pfVsCategory: 0.39, categorySize: 15, category: "Lending" },
+ { feeGrowth30dPct: 40, priceChange30dPct: 35, pfVsCategory: 0.5, categorySize: 18, category: "DEXs" },
+ { feeGrowth30dPct: 5, priceChange30dPct: 12, pfVsCategory: 2.1, categorySize: 18, category: "DEXs" },
+ { feeGrowth30dPct: -55, priceChange30dPct: 18, pfVsCategory: 1.8, categorySize: 18, category: "DEXs" },
+ { feeGrowth30dPct: 90, priceChange30dPct: -4, pfVsCategory: 0.7, categorySize: 9, category: "Liquid Staking" },
+ // Sub-floor category: disqualified before any clause is tested.
+ { feeGrowth30dPct: 300, priceChange30dPct: -40, pfVsCategory: 0.1, categorySize: 2, category: "Yield" },
+ { feeGrowth30dPct: 120, priceChange30dPct: -30, pfVsCategory: 0.2, categorySize: 3, category: "DEX Aggregator" },
+ // Incomplete reading: not testable, and not a thin-category exclusion.
+ { feeGrowth30dPct: null, priceChange30dPct: -50, pfVsCategory: 0.3, categorySize: 15, category: "Lending" },
+ ];
+
+ test("separates the board from what the screen could actually test", () => {
+ const f = divergenceFunnel(board);
+ expect(f.board).toBe(8);
+ expect(f.testable).toBe(5);
+ expect(f.thinCategory).toBe(2);
+ });
+
+ test("names the thin categories so the page can say who cannot signal", () => {
+ expect(divergenceFunnel(board).thinCategoryNames).toEqual(["DEX Aggregator", "Yield"]);
+ });
+
+ test("counts each leg over the testable rows only", () => {
+ const f = divergenceFunnel(board);
+ // 263, 40 and 90 clear +20; -55 clears -20.
+ expect(f.feesUp).toBe(3);
+ expect(f.feesDown).toBe(1);
+ // Only the -21 row clears -10; the -4 row sits inside the flat band.
+ expect(f.tokenDown).toBe(1);
+ expect(f.tokenUp).toBe(3);
+ expect(f.pfBelowMedian).toBe(3);
+ expect(f.pfAboveMedian).toBe(2);
+ });
+
+ test("a row the thin-category floor excluded never reaches a leg count", () => {
+ const f = divergenceFunnel(board);
+ // Both sub-floor rows would have cleared fees-up and token-down.
+ // Counting them would overstate how much the screen had to work with.
+ expect(f.feesUp).toBeLessThan(5);
+ expect(f.tokenDown).toBe(1);
+ });
+
+ test("an empty board yields zeros rather than NaN", () => {
+ const f = divergenceFunnel([]);
+ expect(f.board).toBe(0);
+ expect(f.testable).toBe(0);
+ expect(f.thinCategoryNames).toEqual([]);
+ expect(f.feesUp).toBe(0);
+ });
+});
+
diff --git a/src/lib/capital-hub-rules.ts b/src/lib/capital-hub-rules.ts
index c39e7a771..72fcf2fce 100644
--- a/src/lib/capital-hub-rules.ts
+++ b/src/lib/capital-hub-rules.ts
@@ -304,6 +304,61 @@ export function isDiverging(r: DivergenceCandidate): boolean {
return signalOf(r) === "fees-up-token-down";
}
+/**
+ * The denominator behind the divergence list.
+ *
+ * Without it a short list reads as a surveyed field that produced one
+ * answer, when the usual cause is that the screen had almost nothing to
+ * test. On 2026-10-04 the board carried 67 protocols: 33 of the 62
+ * testable ones grew fees past the threshold and 38 sat under their
+ * category median, both roughly half the field, while only 4 saw their
+ * token fall more than 10% against 39 that rose more than 10%. One row
+ * survived, and the binding clause was the market's direction, not the
+ * filter's strictness.
+ *
+ * Every count is over the same rows the table shows, so a reader can
+ * recompute all of them from it. Same lesson as the spec tokens: a count
+ * published without the set it was taken from is not a reading.
+ */
+export type DivergenceFunnel = {
+ /** Every protocol on the board. */
+ board: number;
+ /** Rows with all three readings present and a usable category median. */
+ testable: number;
+ /** Rows a sub-floor category disqualifies before any clause is tested. */
+ thinCategory: number;
+ /** Category names behind `thinCategory`, so the page can name them. */
+ thinCategoryNames: string[];
+ feesUp: number;
+ feesDown: number;
+ tokenDown: number;
+ tokenUp: number;
+ pfBelowMedian: number;
+ pfAboveMedian: number;
+};
+
+export function divergenceFunnel(
+ rows: readonly T[],
+): DivergenceFunnel {
+ const complete = (r: T) =>
+ r.feeGrowth30dPct != null && r.priceChange30dPct != null && r.pfVsCategory != null;
+ const thin = rows.filter((r) => complete(r) && !categoryMedianUsable(r.categorySize));
+ const testable = rows.filter((r) => complete(r) && categoryMedianUsable(r.categorySize));
+ const n = (f: (r: T) => boolean) => testable.filter(f).length;
+ return {
+ board: rows.length,
+ testable: testable.length,
+ thinCategory: thin.length,
+ thinCategoryNames: [...new Set(thin.map((r) => r.category).filter((c): c is string => !!c))].sort(),
+ feesUp: n((r) => (r.feeGrowth30dPct as number) > SIGNAL_FEE_GROWTH_MIN_PCT),
+ feesDown: n((r) => (r.feeGrowth30dPct as number) < -SIGNAL_FEE_GROWTH_MIN_PCT),
+ tokenDown: n((r) => (r.priceChange30dPct as number) < -SIGNAL_PRICE_MOVE_MIN_PCT),
+ tokenUp: n((r) => (r.priceChange30dPct as number) > SIGNAL_PRICE_MOVE_MIN_PCT),
+ pfBelowMedian: n((r) => (r.pfVsCategory as number) < 1),
+ pfAboveMedian: n((r) => (r.pfVsCategory as number) > 1),
+ };
+}
+
export function selectDivergences(rows: T[], limit = 5): T[] {
return rows
.filter(isDiverging)
diff --git a/src/lib/citation.ts b/src/lib/citation.ts
index 5641ab4a3..39b70cd28 100644
--- a/src/lib/citation.ts
+++ b/src/lib/citation.ts
@@ -228,8 +228,43 @@ export function headlineParts(b: Benchmark): { claim: string; rest: string } {
rest: `${b.title}. No ${chainForFloor} endpoint answered above the ${LEADER_MIN_SUCCESS_PCT} % success floor in the last 24h.`,
};
}
- if (!top) return { claim: "", rest: `${b.title}. Awaiting first run.` };
+ if (!top) {
+ // "Nobody is ranked" is not "nothing ran". perp-max-leverage-terms
+ // shows four venues and a timestamp from this morning while /api/stat,
+ // /api/citable and llms.txt said the bench had never run, and on a
+ // board that ranks nobody by design that never corrects itself.
+ const measured = liveResults(b.results).length;
+ if (measured > 0) {
+ return {
+ claim: "",
+ rest: `${b.title}. ${measured} ${nounFor(b, measured)} measured; none is ranked on this benchmark.`,
+ };
+ }
+ return { claim: "", rest: `${b.title}. Awaiting first run.` };
+ }
const value = fmtUnit(top.value, b.unit);
+ // Block-push races. The leader is zero behind itself by construction, so
+ // quoting its figure publishes an artifact the page's own methodology
+ // disowns, and on a chain where the leader reads tens of milliseconds it
+ // publishes a falsehood: a provider that actually arrived first would sit
+ // inside the first histogram bucket. The gap to the runner-up is the
+ // number that carries information, so the sentence is built from it.
+ if (b.slug.endsWith("-rpc-ws")) {
+ const m = b.title.match(/private ([A-Za-z0-9 .-]+?) WebSocket/i);
+ const wsChain = m ? m[1] : "chain";
+ const field = rankedCandidates(b);
+ const second = field[1];
+ const inBucket = top.value < 0.5;
+ const lead = inBucket
+ ? `${top.name} has the lowest median lag behind the first ${wsChain} block push, inside the first histogram bucket`
+ : `${top.name} has the lowest median lag behind the first ${wsChain} block push, ${value}`;
+ return {
+ claim: second
+ ? `${lead}; ${second.name} trails at ${fmtUnit(second.ms.p50, b.unit)}`
+ : lead,
+ rest: `(p50, 24h) across ${field.length} ranked providers.`,
+ };
+ }
// Chain RPC pages: the sentence names the entity searchers use ("free
// public Arbitrum RPC endpoints") and the cohort size, instead of
// jamming the H1 in as the object. Same string feeds the TL;DR,
diff --git a/src/lib/cold-tier-aliases.test.ts b/src/lib/cold-tier-aliases.test.ts
new file mode 100644
index 000000000..23193c764
--- /dev/null
+++ b/src/lib/cold-tier-aliases.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, test } from "bun:test";
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import yaml from "js-yaml";
+import { hasLogo, logoPath } from "@/lib/logo-manifest";
+import { canonicalize } from "@/lib/providers";
+
+/**
+ * The cold-start tier is a second measurement arm on the same endpoints, so
+ * every `-cold` row needs two aliases to behave like its vendor: one in
+ * logo-manifest (or the row renders initials instead of a mark) and one in
+ * providers.ts (or the slug builds its own product page, an indexable duplicate
+ * of the vendor with no description and no official link).
+ *
+ * Nothing strips the suffix, both tables are explicit, and adding a vendor to
+ * the tier without them is silent. That is how `/products/dwellir-cold` reached
+ * production as a 200 carrying the same title as `/products/dwellir` plus a
+ * sitemap entry, two weeks after the original four were folded correctly.
+ *
+ * So the fixture is the spec directory itself: this reads whatever cold rows
+ * exist today and fails on the next one added without its aliases.
+ */
+
+const SPEC_DIR = join(import.meta.dir, "..", "..", "benchmarks");
+
+type SpecProvider = { slug?: string; tier?: string };
+
+/** Every `tier: keyed-cold` slug declared anywhere in the spec directory. */
+function coldSlugs(): string[] {
+ const found = new Set();
+ for (const file of readdirSync(SPEC_DIR)) {
+ if (!file.endsWith("-rpc.yml")) continue;
+ const spec = yaml.load(readFileSync(join(SPEC_DIR, file), "utf8")) as {
+ providers?: SpecProvider[];
+ };
+ for (const p of spec?.providers ?? []) {
+ if (p?.tier === "keyed-cold" && p.slug) found.add(p.slug);
+ }
+ }
+ return [...found].sort();
+}
+
+describe("cold-start tier aliases", () => {
+ const slugs = coldSlugs();
+
+ // A guard that silently guards nothing is worse than no guard: if the tier
+ // is ever renamed, this test would pass on an empty list.
+ test("the spec directory declares a cold tier at all", () => {
+ expect(slugs.length).toBeGreaterThanOrEqual(5);
+ for (const slug of slugs) expect(slug.endsWith("-cold")).toBe(true);
+ });
+
+ test("every cold slug resolves to its vendor's product page", () => {
+ const wrong = slugs.filter((slug) => canonicalize(slug).slug !== slug.replace(/-cold$/, ""));
+ expect(wrong).toEqual([]);
+ });
+
+ test("every cold slug renders its vendor's logo, not initials", () => {
+ const missing = slugs.filter((slug) => !hasLogo(slug));
+ expect(missing).toEqual([]);
+ });
+
+ test("the cold logo is the same file as the vendor's", () => {
+ const mismatched = slugs.filter(
+ (slug) => logoPath(slug) !== logoPath(slug.replace(/-cold$/, "")),
+ );
+ expect(mismatched).toEqual([]);
+ });
+
+ // The cold arm is ranked against the keyed arm's vendors, so a vendor
+ // measured warm but not cold is a hole in the comparison rather than a
+ // crash. Reported per chain, because that is how it gets fixed.
+ test("every keyed vendor on a chain also has a cold row there", () => {
+ const holes: string[] = [];
+ for (const file of readdirSync(SPEC_DIR)) {
+ if (!file.endsWith("-rpc.yml")) continue;
+ const spec = yaml.load(readFileSync(join(SPEC_DIR, file), "utf8")) as {
+ providers?: SpecProvider[];
+ };
+ const providers = spec?.providers ?? [];
+ const keyed = providers.filter((p) => p?.tier === "keyed").map((p) => p.slug);
+ const cold = new Set(
+ providers
+ .filter((p) => p?.tier === "keyed-cold")
+ .map((p) => p.slug?.replace(/-cold$/, "")),
+ );
+ for (const slug of keyed) {
+ if (slug && !cold.has(slug)) holes.push(`${file}: ${slug}`);
+ }
+ }
+ expect(holes).toEqual([]);
+ });
+});
diff --git a/src/lib/data-api-stats.ts b/src/lib/data-api-stats.ts
index a4ab62154..28f7ccbd1 100644
--- a/src/lib/data-api-stats.ts
+++ b/src/lib/data-api-stats.ts
@@ -17,6 +17,7 @@
import { unstable_cache } from "next/cache";
import { loadBenchFromBlob } from "@/lib/bench-blob";
+import { DEV_ONLY_BENCH_SLUGS, REMOVED_BENCH_SLUGS } from "@/lib/removed-benches";
import type { Benchmark, ProviderResult } from "@/types/benchmark";
export type DataApiGroup =
@@ -83,7 +84,24 @@ const BENCH_GROUP: Record = {
"nft-collection-metadata": "nft-data",
};
-const BENCH_SLUGS = Object.keys(BENCH_GROUP);
+/**
+ * Filtered through the retirement sets, because BENCH_GROUP above is a
+ * hand-written map and a hand-written map goes stale the day a bench is
+ * retired. This one had: indexing-freshness (retired 2026-08-05) and
+ * portfolio-chain-coverage both stayed in it, and every surface reading this
+ * list rendered a link to a page that answers 410 — the /data-api hub, and
+ * the data-API section on /products/mobula ("Wallet indexing freshness 1.5 s
+ * rank #1") and /products/moralis.
+ *
+ * The product pages are the reason this belongs here rather than only in the
+ * hub page: their own living-appearance filter does not reach this section,
+ * so filtering at the one place the list is built is what actually closes it.
+ *
+ * Names stay in BENCH_GROUP so an un-retirement restores the bench on its own.
+ */
+const BENCH_SLUGS = Object.keys(BENCH_GROUP).filter(
+ (slug) => !REMOVED_BENCH_SLUGS.has(slug) && !DEV_ONLY_BENCH_SLUGS.has(slug),
+);
/** Short display titles for table rows (trimmed from the full spec title). */
const BENCH_SHORT_TITLE: Record = {
diff --git a/src/lib/hl-builder-stats.ts b/src/lib/hl-builder-stats.ts
index aea5c1813..1ef6dc9f4 100644
--- a/src/lib/hl-builder-stats.ts
+++ b/src/lib/hl-builder-stats.ts
@@ -10,10 +10,7 @@ import { unstable_cache } from "next/cache";
import { Prometheus } from "@/lib/prometheus";
import type { HlWindow, HlFeedCoverage } from "@/lib/hl-feed";
import { getSpecs } from "@/lib/spec";
-import {
- readCohortSnapshot,
- writeCohortSnapshot,
-} from "@/lib/cohort-snapshot";
+import { readCohortSnapshot, writeCohortSnapshot } from "@/lib/cohort-snapshot";
export type { HlWindow, HlFeedCoverage } from "@/lib/hl-feed";
@@ -108,13 +105,25 @@ function promUrl(): string | null {
* whole" must not look the same, which is the mistake the coverage gauges
* were added to stop making in the first place.
*/
-async function readFeedCoverage(prom: Prometheus): Promise {
- const [hours, truncated, inWindow, measured, nodeDays] = await Promise.all([
+async function readFeedCoverage(
+ prom: Prometheus,
+): Promise {
+ const [
+ hours,
+ truncated,
+ inWindow,
+ measured,
+ nodeDays,
+ rebuiltDays,
+ rebuiltWorst,
+ ] = await Promise.all([
prom.scalar(`hl_frontend_day_coverage_hours_v2`),
prom.scalar(`hl_frontend_day_truncated_v2`),
prom.scalar(`hl_frontend_truncated_days_window_v2`),
prom.scalar(`hl_frontend_coverage_days_measured_v2`),
prom.scalar(`hl_frontend_node_days_window_v2`),
+ prom.scalar(`hl_frontend_rebuilt_days_window_v2`),
+ prom.scalar(`hl_frontend_rebuilt_worst_error_pct_v2`),
]);
if (hours === null && truncated === null && inWindow === null) return null;
const windowDays = 30;
@@ -129,6 +138,14 @@ async function readFeedCoverage(prom: Prometheus): Promise 0 as
+ // "unstated" rather than as "no shortfall".
+ rebuiltWorstErrorPct: rebuiltWorst ?? 0,
};
}
@@ -189,34 +206,42 @@ export async function fetchHlBuilderStatsFresh(
feed,
] = await Promise.all([
prom.scalar(`hl_frontend_fees_usd_30d_v2${sel}`),
- prom.scalar(`hl_frontend_revenue_delta_pct_v2{builder="${slug}",window="30d"}`),
+ prom.scalar(
+ `hl_frontend_revenue_delta_pct_v2{builder="${slug}",window="30d"}`,
+ ),
prom.scalar(`hl_frontend_volume_usd_30d_v2${sel}`),
prom.scalar(`hl_frontend_users_30d_v2${sel}`),
prom.scalar(`hl_frontend_fees_usd_24h_v2${sel}`),
prom.scalar(`hl_frontend_volume_usd_24h_v2${sel}`),
prom.scalar(`hl_frontend_users_24h_v2${sel}`),
- prom.scalar(`hl_frontend_revenue_delta_pct_v2{builder="${slug}",window="24h"}`),
+ prom.scalar(
+ `hl_frontend_revenue_delta_pct_v2{builder="${slug}",window="24h"}`,
+ ),
prom.scalar(`hl_frontend_fees_usd_7d_v2${sel}`),
prom.scalar(`hl_frontend_volume_usd_7d_v2${sel}`),
prom.scalar(`hl_frontend_users_7d_v2${sel}`),
- prom.scalar(`hl_frontend_revenue_delta_pct_v2{builder="${slug}",window="7d"}`),
+ prom.scalar(
+ `hl_frontend_revenue_delta_pct_v2{builder="${slug}",window="7d"}`,
+ ),
prom.scalar(`hl_frontend_global_volume_share_24h_v2${sel}`),
prom.scalar(`hl_frontend_biggest_day_revenue_usd_v2${sel}`),
prom.scalar(`hl_frontend_biggest_day_unix_v2${sel}`),
- prom.scalar(`hl_frontend_milestone_revenue_days_v2{builder="${slug}",threshold="10k"}`),
- prom.scalar(`hl_frontend_milestone_revenue_days_v2{builder="${slug}",threshold="100k"}`),
- prom.scalar(`hl_frontend_milestone_revenue_days_v2{builder="${slug}",threshold="1m"}`),
+ prom.scalar(
+ `hl_frontend_milestone_revenue_days_v2{builder="${slug}",threshold="10k"}`,
+ ),
+ prom.scalar(
+ `hl_frontend_milestone_revenue_days_v2{builder="${slug}",threshold="100k"}`,
+ ),
+ prom.scalar(
+ `hl_frontend_milestone_revenue_days_v2{builder="${slug}",threshold="1m"}`,
+ ),
prom.scalar(`hl_frontend_profitable_user_pct_30d_v2${sel}`),
queryVector(prom, `hl_frontend_coin_volume_share_24h_v2${sel}`),
queryVector(prom, `hl_frontend_volume_by_percentile_30d_v2${sel}`),
readFeedCoverage(prom),
]);
- if (
- revenue30d === null &&
- volume30d === null &&
- users30d === null
- ) {
+ if (revenue30d === null && volume30d === null && users30d === null) {
return null;
}
@@ -239,9 +264,10 @@ export async function fetchHlBuilderStatsFresh(
const b = s.labels.bucket;
if (b) byBucket.set(b, s.value);
}
- const percentileShares30d: PercentileBucket[] = PERCENTILE_ORDER.map(
- (b) => ({ bucket: b, share: byBucket.get(b) ?? 0 }),
- );
+ const percentileShares30d: PercentileBucket[] = PERCENTILE_ORDER.map((b) => ({
+ bucket: b,
+ share: byBucket.get(b) ?? 0,
+ }));
return {
slug,
@@ -296,9 +322,7 @@ export async function fetchHlBuilderStatsFresh(
async function fetchHlBuilderStatsRaw(
slug: string,
): Promise {
- const snapshot = await readCohortSnapshot(
- hlBuilderKey(slug),
- );
+ const snapshot = await readCohortSnapshot(hlBuilderKey(slug));
if (snapshot) return snapshot.data;
const fresh = await fetchHlBuilderStatsFresh(slug);
if (fresh) {
@@ -342,7 +366,10 @@ export type HlCohortRow = {
/** Revenue, volume and users per window. The leaderboard's timeframe
* control reads these; the flat `*30d` fields above stay for callers that
* only ever wanted the headline window. */
- byWindow: Record;
+ byWindow: Record<
+ HlWindow,
+ { revenue: number; volume: number; users: number }
+ >;
};
export type HlCohortSummary = {
@@ -514,7 +541,10 @@ export async function fetchHlCohortFresh(): Promise {
volume: number;
users: number;
share: number;
- byWindow: Record;
+ byWindow: Record<
+ HlWindow,
+ { revenue: number; volume: number; users: number }
+ >;
};
const byBuilder = new Map();
const ensure = (slug: string): Acc => {
@@ -628,16 +658,17 @@ export async function fetchHlHip3CohortFresh(): Promise {
(hip3Spec?.providers ?? []).map((p) => [p.slug, p.name]),
);
- const [vol24h, vol7d, vol30d, oi, traded, listed, sampled, sampled7] = await Promise.all([
- queryVector(prom, `hl_hip3_deployer_volume_usd_24h`),
- queryVector(prom, `hl_hip3_deployer_volume_usd_7d`),
- queryVector(prom, `hl_hip3_deployer_volume_usd_30d`),
- queryVector(prom, `hl_hip3_deployer_open_interest_usd`),
- queryVector(prom, `hl_hip3_deployer_markets_24h`),
- queryVector(prom, `hl_hip3_deployer_markets_listed`),
- queryVector(prom, `hl_hip3_deployer_days_sampled`),
- queryVector(prom, `hl_hip3_deployer_days_sampled_7d`),
- ]);
+ const [vol24h, vol7d, vol30d, oi, traded, listed, sampled, sampled7] =
+ await Promise.all([
+ queryVector(prom, `hl_hip3_deployer_volume_usd_24h`),
+ queryVector(prom, `hl_hip3_deployer_volume_usd_7d`),
+ queryVector(prom, `hl_hip3_deployer_volume_usd_30d`),
+ queryVector(prom, `hl_hip3_deployer_open_interest_usd`),
+ queryVector(prom, `hl_hip3_deployer_markets_24h`),
+ queryVector(prom, `hl_hip3_deployer_markets_listed`),
+ queryVector(prom, `hl_hip3_deployer_days_sampled`),
+ queryVector(prom, `hl_hip3_deployer_days_sampled_7d`),
+ ]);
if (vol24h === null && listed === null) return null;
@@ -913,7 +944,10 @@ function toCompactFrontend(
async function fetchHlHistoryRaw(): Promise {
// 2h: history is daily-stepped data, 10-min default is too aggressive and
// causes unnecessary 200-query Prom rebuilds on every staleness window.
- const snapshot = await readCohortSnapshot(HL_HISTORY_KEY, 2 * 60 * 60 * 1000);
+ const snapshot = await readCohortSnapshot(
+ HL_HISTORY_KEY,
+ 2 * 60 * 60 * 1000,
+ );
if (snapshot) return snapshot.data;
const fresh = await fetchHlHistoryFresh();
if (fresh) {
diff --git a/src/lib/hl-feed.test.ts b/src/lib/hl-feed.test.ts
index 482ebeec9..2e8264751 100644
--- a/src/lib/hl-feed.test.ts
+++ b/src/lib/hl-feed.test.ts
@@ -19,6 +19,8 @@ describe("feedNeedsDisclosure", () => {
daysMeasured: 24,
windowDays: 30,
nodeDays: 0,
+ rebuiltDays: 0,
+ rebuiltWorstErrorPct: 0,
}),
).toBe(true);
});
@@ -32,6 +34,8 @@ describe("feedNeedsDisclosure", () => {
daysMeasured: 30,
windowDays: 30,
nodeDays: 30,
+ rebuiltDays: 0,
+ rebuiltWorstErrorPct: 0,
}),
).toBe(false);
});
@@ -47,6 +51,8 @@ describe("feedNeedsDisclosure", () => {
daysMeasured: 30,
windowDays: 30,
nodeDays: 29,
+ rebuiltDays: 0,
+ rebuiltWorstErrorPct: 0,
}),
).toBe(true);
});
@@ -82,6 +88,8 @@ describe("feedIsMostlyWhole", () => {
daysMeasured: 30,
windowDays: 30,
nodeDays: 30,
+ rebuiltDays: 0,
+ rebuiltWorstErrorPct: 0,
};
// The live state on 2026-10-07 once the node source landed: 26 of 30 days
@@ -89,20 +97,60 @@ describe("feedIsMostlyWhole", () => {
// down. The figures went from 0.53x of CoinMarketMan's to 0.89x, so the
// page should footnote the gap rather than lead with it.
it("treats a window that is nearly all node days as whole", () => {
- expect(feedIsMostlyWhole({ ...base, nodeDays: 26, truncatedDaysWindow: 3 })).toBe(true);
+ expect(
+ feedIsMostlyWhole({ ...base, nodeDays: 26, truncatedDaysWindow: 3 }),
+ ).toBe(true);
});
// The state before the node source: every day off the truncated export.
it("does not soften a window built from the export", () => {
- expect(feedIsMostlyWhole({ ...base, nodeDays: 0, truncatedDaysWindow: 18 })).toBe(false);
+ expect(
+ feedIsMostlyWhole({ ...base, nodeDays: 0, truncatedDaysWindow: 18 }),
+ ).toBe(false);
});
// Half the window missing is not a footnote.
it("does not soften a half-sourced window", () => {
- expect(feedIsMostlyWhole({ ...base, nodeDays: 15, truncatedDaysWindow: 9 })).toBe(false);
+ expect(
+ feedIsMostlyWhole({ ...base, nodeDays: 15, truncatedDaysWindow: 9 }),
+ ).toBe(false);
});
it("never softens an unmeasured feed", () => {
expect(feedIsMostlyWhole(null)).toBe(false);
});
});
+
+// The disclosure's own trigger does not know about rebuilt days: a window whose
+// every day is nominally whole passes feedNeedsDisclosure quietly, and that is
+// precisely the window that may be carrying reconstructions. If the note keyed
+// only on truncation it would print nothing and publish a rebuilt day as a
+// measured one, which is the single failure the note exists to prevent.
+//
+// The component therefore renders on `truncation || rebuiltDays > 0`. This pins
+// the half that is testable here: truncation alone goes quiet.
+describe("rebuilt days are not covered by the truncation trigger", () => {
+ const whole = {
+ coverageHours: 24,
+ latestDayTruncated: false,
+ truncatedDaysWindow: 0,
+ daysMeasured: 30,
+ windowDays: 30,
+ nodeDays: 25,
+ rebuiltDays: 5,
+ rebuiltWorstErrorPct: 0,
+ };
+
+ it("says nothing about a window of whole days, rebuilt or not", () => {
+ expect(feedNeedsDisclosure(whole)).toBe(false);
+ expect(feedNeedsDisclosure({ ...whole, rebuiltDays: 0 })).toBe(false);
+ });
+
+ // So the count has to survive the read, or the component has nothing to key
+ // on. A rebuilt day is never folded into nodeDays either: 25 + 5 = 30 here,
+ // and nodeDays alone must not claim the window is measured.
+ it("keeps rebuilt days out of the node-day count", () => {
+ expect(whole.nodeDays).toBe(25);
+ expect(whole.nodeDays + whole.rebuiltDays).toBe(whole.windowDays);
+ });
+});
diff --git a/src/lib/hl-feed.ts b/src/lib/hl-feed.ts
index b9c942a13..ec6d1baaf 100644
--- a/src/lib/hl-feed.ts
+++ b/src/lib/hl-feed.ts
@@ -55,6 +55,23 @@ export type HlFeedCoverage = {
* rather than the public per-builder export. A day sourced there is whole;
* the export cuts most days off around 12:10 UTC. */
nodeDays: number;
+ /** Days in the window served as reconstructions: the export's own morning
+ * spliced to an afternoon rebuilt per wallet from Hyperliquid's info API,
+ * for outages the node cannot replay. How close they land varies with how
+ * much of the day the morning covered, from -0.4% to -28% across the two
+ * days scored against the node, so `rebuiltWorstErrorPct` carries the
+ * magnitude and this is only the count. Never folded into `nodeDays`, and
+ * told to the reader because a reconstruction presented as a measurement
+ * is the one thing this disclosure exists to prevent. */
+ rebuiltDays: number;
+ /** 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 export's morning covered: two complete days
+ * scored against the node gave -0.4% at a 68.8% morning and -28.2% at
+ * 27.2%. The worst of the window rather than its mean, because what a
+ * reader needs is how wrong the figure could be, and averaging a -0.5%
+ * day with a -35% one describes neither. */
+ rebuiltWorstErrorPct: number;
};
/**
diff --git a/src/lib/hl-traders.ts b/src/lib/hl-traders.ts
new file mode 100644
index 000000000..5169ea249
--- /dev/null
+++ b/src/lib/hl-traders.ts
@@ -0,0 +1,73 @@
+/**
+ * Reader for the Hyperliquid trader-leaderboard audit snapshot.
+ *
+ * The `hl-archive traders` collector writes one raw JSON blob per run
+ * under `ocb:hl-traders:v1`. Nothing here falls back to a live fetch:
+ * the source blob is ~39 MB and auditing it is the collector's job, so a
+ * missing snapshot has to surface as "no reading yet" on the page rather
+ * than as zeros, which would publish as a measurement.
+ */
+
+import { redisCommand } from "@/lib/cohort-snapshot";
+import type { HlTradersSnapshot } from "@/types/hl-traders";
+
+const KEY = "ocb:hl-traders:v1";
+
+/**
+ * Hours after which the snapshot is too old to present as current. The
+ * collector runs daily, so one missed run is tolerable and two are not.
+ * Matches the `STALE_AFTER_HOURS` convention used for bench data.
+ */
+export const HL_TRADERS_STALE_AFTER_HOURS = 48;
+
+export type HlTradersRead = {
+ snapshot: HlTradersSnapshot;
+ ageHours: number;
+ stale: boolean;
+};
+
+/** Narrow an unknown blob to the snapshot shape. */
+function isSnapshot(v: unknown): v is HlTradersSnapshot {
+ if (typeof v !== "object" || v === null) return false;
+ const o = v as Record;
+ return (
+ typeof o.updated_at === "string" &&
+ typeof o.integrity === "object" &&
+ o.integrity !== null &&
+ Array.isArray(o.top)
+ );
+}
+
+/**
+ * Fetch the snapshot. Returns null when Upstash is unconfigured, the key
+ * is absent, or the payload does not parse. Never throws: the page
+ * renders an explicit empty state instead of a 500.
+ */
+export async function fetchHlTraders(): Promise {
+ let raw: unknown;
+ try {
+ raw = await redisCommand(["GET", KEY], 6_000);
+ } catch {
+ return null;
+ }
+ if (typeof raw !== "string" || raw.length === 0) return null;
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (!isSnapshot(parsed)) return null;
+
+ const ts = Date.parse(parsed.updated_at);
+ const ageHours = Number.isFinite(ts)
+ ? (Date.now() - ts) / 3_600_000
+ : Number.POSITIVE_INFINITY;
+
+ return {
+ snapshot: parsed,
+ ageHours,
+ stale: ageHours > HL_TRADERS_STALE_AFTER_HOURS,
+ };
+}
diff --git a/src/lib/keyed-cohort-prose.test.ts b/src/lib/keyed-cohort-prose.test.ts
new file mode 100644
index 000000000..437c155ac
--- /dev/null
+++ b/src/lib/keyed-cohort-prose.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, test } from "bun:test";
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import yaml from "js-yaml";
+
+/**
+ * The methodology bullet that enumerates the private cohort must name every
+ * provider the page actually ranks in it.
+ *
+ * This has now rotted three times. Dwellir joined the keyed tier on all eight
+ * EVM chains in September and was never added to the prose, so nine pages
+ * described a five-provider cohort while ranking six. Robinhood's list kept a
+ * different provider order and silently omitted two. A reader checking our
+ * method against our table found a list that disagreed with it.
+ *
+ * Nothing derives this sentence from the data, which is why it drifts, so the
+ * test reads the specs and compares the two directly: every keyed provider's
+ * display name must appear somewhere in the bullet that introduces the cohort.
+ * A provider added to a spec without a prose edit fails here rather than on
+ * the page.
+ */
+
+const SPEC_DIR = join(import.meta.dir, "..", "..", "benchmarks");
+
+type SpecProvider = { slug?: string; name?: string; tier?: string };
+type Spec = { providers?: SpecProvider[]; methodology?: string[] };
+
+/** The bullet that introduces the private cohort, for specs that have one. */
+function cohortBullet(spec: Spec): string | null {
+ return (spec.methodology ?? []).find((b) => b.includes("Private cohort")) ?? null;
+}
+
+function keyedSpecs(): { file: string; spec: Spec }[] {
+ return readdirSync(SPEC_DIR)
+ .filter((f) => f.endsWith("-rpc.yml"))
+ .map((file) => ({
+ file,
+ spec: yaml.load(readFileSync(join(SPEC_DIR, file), "utf8")) as Spec,
+ }))
+ .filter(({ spec }) => (spec.providers ?? []).some((p) => p?.tier === "keyed"));
+}
+
+describe("private cohort prose", () => {
+ const specs = keyedSpecs();
+
+ test("the keyed chain specs are found", () => {
+ expect(specs.length).toBeGreaterThanOrEqual(9);
+ });
+
+ test("every spec with a keyed tier introduces the cohort in its methodology", () => {
+ const silent = specs.filter(({ spec }) => !cohortBullet(spec)).map((s) => s.file);
+ expect(silent).toEqual([]);
+ });
+
+ test("the bullet names every provider the keyed tier ranks", () => {
+ const unnamed: string[] = [];
+ for (const { file, spec } of specs) {
+ const bullet = cohortBullet(spec);
+ if (!bullet) continue;
+ for (const p of spec.providers ?? []) {
+ if (p?.tier !== "keyed" || !p.name) continue;
+ if (!bullet.includes(p.name)) unnamed.push(`${file}: ${p.name}`);
+ }
+ }
+ expect(unnamed).toEqual([]);
+ });
+
+ // The opposite direction, a name left in the prose after its row was
+ // removed, is deliberately not checked. It cannot be read from this
+ // sentence: several chains we bench are also provider names elsewhere in
+ // the directory, and the bullet necessarily says "keyed Arc endpoints" or
+ // "keyed Arbitrum endpoints". A first attempt flagged Arc, Arbitrum and
+ // Robinhood as ghost vendors on their own pages. Omission is the failure
+ // that has actually happened, three times; a stale extra name has not.
+
+ // The grammar slip a previous substitution left behind on one page.
+ test("no list reads \"and X and Y\"", () => {
+ const broken: string[] = [];
+ for (const { file } of specs) {
+ const raw = readFileSync(join(SPEC_DIR, file), "utf8");
+ if (/ and [A-Z][A-Za-z]+ and [A-Z]/.test(raw)) broken.push(file);
+ }
+ expect(broken).toEqual([]);
+ });
+});
diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts
index 587742728..feb2869bc 100644
--- a/src/lib/logo-manifest.ts
+++ b/src/lib/logo-manifest.ts
@@ -296,6 +296,14 @@ const RAW: Record = {
paraswap: "/logos/paraswap.png",
relay: "/logos/relay.svg",
lifi: "/logos/lifi.png",
+ // Route, added with the Robinhood Chain pair (#3025). The mark Route
+ // supplied is a black "r" sitting in a 400px square that is 91% white,
+ // so it is cropped to the glyph and the white is dropped to alpha: left
+ // as delivered it rendered as a pale tile with a dot in it next to
+ // tightly cropped neighbours, and could not sit on a dark page at all.
+ // Being monochrome on transparent, the slug is opted into the light chip
+ // in .
+ route: "/logos/route.png",
geckoterminal: "/logos/geckoterminal.png",
gains: "/logos/gains.svg",
blockscout: "/logos/blockscout.svg",
@@ -308,6 +316,7 @@ const RAW: Record = {
moralis: "/logos/moralis.png",
stellarexpert: "/logos/stellarexpert.png",
jupiter: "/logos/jupiter.png",
+ papertrade: "/logos/papertrade.svg",
raydium: "/logos/raydium.svg",
openocean: "/logos/openocean.png",
cow: "/logos/cow.png",
@@ -827,6 +836,11 @@ const ALIASES: Record = {
hypercore: "hyperliquid", // HyperCore is the Hyperliquid trading engine, same brand
hyperevm: "hyperliquid", // HyperEVM is Hyperliquid's EVM execution layer, same brand
"hyperliquid-hip3": "hyperliquid", // bench 266 row for builder-deployed HIP-3 dexs
+ // The perp cohort keys this venue `gmx-v2` while the brand, the logo file
+ // and the product page are all `gmx`; PERP_PRODUCT_SLUGS carries the same
+ // mapping for the link. Without this alias logoPath returns null and the
+ // row renders with no mark, which is how it shipped.
+ "gmx-v2": "gmx",
"publicnode-feehistory": "publicnode",
"binance-wallet": "binance", // bench 268 per-chain rows (binance-wallet-bnb…); the spec row is `binance`
// Same brand split across two HL builder addresses; one logo is enough.
@@ -857,6 +871,7 @@ const ALIASES: Record = {
"optimism-official": "optimism",
// Long-tail RPC cluster (055-066).
"sonic-official": "sonic",
+ "astar-official": "astar",
"monad-official": "monad",
"solana-official": "solana",
"solana-labs": "solana",
@@ -1023,12 +1038,13 @@ const ALIASES: Record = {
"agoric-official": "agoric",
"xpla-official": "xpla",
"xrplevm-official": "xrplevm",
- // Cold-start cohort on the nine keyed RPC pages (2026-10-08). Same four
- // providers, same endpoints, measured on a connection that is never
- // reused, so they need a `-cold` slug to stay distinct from the pooled
- // rows. Without these aliases the rows render initials instead of marks:
- // nothing strips a suffix, this table is explicit. Same mistake as the
- // "-official" slugs earlier today.
+ // Cold-start cohort on the nine keyed RPC pages (2026-10-08). The same
+ // providers and endpoints as the keyed tier, measured on a connection that
+ // is never reused, so they need a `-cold` slug to stay distinct from the
+ // pooled rows. Without these aliases the rows render initials instead of
+ // marks: nothing strips a suffix, this table is explicit. Same mistake as
+ // the "-official" slugs earlier today. The twin list lives in
+ // providers.ts ALIASES, which also needs a line per cold vendor.
"getblock-cold": "getblock",
"quicknode-cold": "quicknode",
"alchemy-cold": "alchemy",
diff --git a/src/lib/materialize/load.ts b/src/lib/materialize/load.ts
index e2eb41dcc..d0a214d60 100644
--- a/src/lib/materialize/load.ts
+++ b/src/lib/materialize/load.ts
@@ -852,6 +852,7 @@ function applyDimensionsToSpec(spec: Spec, labels: Record): Spec
...p.queries,
p50: inject(p.queries.p50),
p90: inject(p.queries.p90),
+ p95: inject(p.queries.p95),
p99: inject(p.queries.p99),
mean: inject(p.queries.mean),
success: inject(p.queries.success),
@@ -1011,7 +1012,11 @@ async function tryLoadLive(
q.p90 ? prom.scalar(q.p90) : Promise.resolve(null),
q.p99 ? prom.scalar(q.p99) : Promise.resolve(null),
]);
- const [mean, success, sampleSize, slotP50, slotP99, liveActivity, rankGate] = await Promise.all([
+ // p95 rides with the optional batch on purpose: no retry and no
+ // skip-on-null, so a bench that declares it badly loses a column and
+ // not its providers.
+ const [p95, mean, success, sampleSize, slotP50, slotP99, liveActivity, rankGate] = await Promise.all([
+ q.p95 ? prom.scalar(q.p95) : Promise.resolve(null),
q.mean ? prom.scalar(q.mean) : Promise.resolve(null),
q.success ? prom.scalar(q.success) : Promise.resolve(null),
q.sample_size ? prom.scalar(q.sample_size) : Promise.resolve(null),
@@ -1117,7 +1122,13 @@ async function tryLoadLive(
// its quote is negative) is not dropped by the legacy `p50 > 0`
// guard, which only applies to rows without the flag.
availability: "live",
- ms: { p50, p90, p99, mean: mean ?? p50 },
+ ms: {
+ p50,
+ p90,
+ p99,
+ mean: mean ?? p50,
+ ...(p95 != null && Number.isFinite(p95) ? { p95 } : {}),
+ },
slots:
slotP50 != null && slotP99 != null
? { p50: slotP50, p99: slotP99 }
diff --git a/src/lib/perp-cost-model.test.ts b/src/lib/perp-cost-model.test.ts
new file mode 100644
index 000000000..0c3b2ccc0
--- /dev/null
+++ b/src/lib/perp-cost-model.test.ts
@@ -0,0 +1,240 @@
+import { describe, expect, test } from "bun:test";
+import {
+ computeCostModel,
+ crossoverMove,
+ feeLegFraction,
+ gainsStandardThresholdPct,
+ isOpenAt,
+ LEVERAGE_RANGE,
+ type Market,
+ maxLeverageOn,
+ papertradeKeep,
+ unavailableReason,
+ VENUES,
+} from "@/lib/perp-cost-model";
+
+const MARKETS: Market[] = ["BTC", "ETH"];
+
+// A calculator fails quietly: a negative net rendered as a gain, a liquidation
+// distance that goes negative past a venue's own maximum, a crowned row that
+// actually loses money. These assert what must hold rather than eyeball it.
+
+describe("papertradeKeep", () => {
+ test("it is a fraction on every move, and zero at or below the deadband", () => {
+ for (const market of MARKETS) {
+ for (const move of [0, 1e-9, 2e-5, 2.1e-5, 1e-4, 1e-3, 0.01, 0.5, 1, 10]) {
+ const keep = papertradeKeep(market, move);
+ expect(Number.isFinite(keep)).toBe(true);
+ expect(keep).toBeGreaterThanOrEqual(0);
+ expect(keep).toBeLessThanOrEqual(0.9 * 0.98);
+ }
+ expect(papertradeKeep(market, 1e-6)).toBe(0);
+ }
+ });
+
+ // The bisection in crossoverMove() is only sound because of this.
+ test("it rises with the move and never falls", () => {
+ for (const market of MARKETS) {
+ let previous = -1;
+ for (let move = 1e-4; move <= 0.5; move *= 1.15) {
+ const keep = papertradeKeep(market, move);
+ expect(keep).toBeGreaterThanOrEqual(previous);
+ previous = keep;
+ }
+ }
+ });
+
+ test("it approaches one minus the base rate and the win fee, from below", () => {
+ const ceiling = 0.9 * 0.98;
+ expect(papertradeKeep("BTC", 100)).toBeLessThan(ceiling);
+ expect(papertradeKeep("BTC", 100)).toBeCloseTo(ceiling, 4);
+ });
+});
+
+describe("gainsStandardThresholdPct", () => {
+ test("it stays inside the two thresholds the contract publishes", () => {
+ for (const lev of [1, 1.1, 24.9, 25, 42.5, 60, 60.1, 200, 1e6]) {
+ const threshold = gainsStandardThresholdPct(lev);
+ expect(threshold).toBeLessThanOrEqual(90);
+ expect(threshold).toBeGreaterThanOrEqual(75);
+ }
+ });
+
+ test("it is flat outside the interpolated band and linear inside it", () => {
+ expect(gainsStandardThresholdPct(1)).toBe(90);
+ expect(gainsStandardThresholdPct(25)).toBe(90);
+ expect(gainsStandardThresholdPct(60)).toBe(75);
+ expect(gainsStandardThresholdPct(1e6)).toBe(75);
+ expect(gainsStandardThresholdPct(42.5)).toBeCloseTo(82.5, 9);
+ });
+});
+
+describe("every venue holds across its whole leverage range", () => {
+ for (const market of MARKETS) {
+ for (const venue of VENUES) {
+ test(`${venue.id} on ${market}`, () => {
+ const mx = maxLeverageOn(venue, market);
+ expect(mx).toBeGreaterThan(0);
+ expect(venue.minLeverage).toBeGreaterThan(0);
+ expect(venue.minLeverage).toBeLessThanOrEqual(mx);
+
+ const legFee = feeLegFraction(venue, market);
+ expect(legFee).toBeGreaterThanOrEqual(0);
+ expect(legFee).toBeLessThan(0.01);
+
+ // An implied cap is approached and not offered, so the boundary itself
+ // sits outside the range and must not be asserted on.
+ const top = venue.impliedCap ? mx * (1 - 1e-6) : mx;
+ const step = Math.max((top - venue.minLeverage) / 200, 0.05);
+ for (let lev = venue.minLeverage; lev <= top; lev += step) {
+ const row = computeCostModel({ margin: 1000, leverage: lev, market, move: 0.002 }).live.find(
+ (r) => r.venue.id === venue.id,
+ );
+ expect(row, `${venue.id} must be open at ${lev}x`).toBeDefined();
+ expect(row!.roomPct).not.toBeNull();
+ expect(row!.roomPct!).toBeGreaterThan(0);
+ expect(row!.roomPct!).toBeLessThanOrEqual(100);
+ expect(row!.equityLeftPct).toBeGreaterThanOrEqual(0);
+ expect(row!.equityLeftPct).toBeLessThanOrEqual(1);
+ expect(Number.isFinite(row!.cost)).toBe(true);
+ }
+ });
+ }
+ }
+});
+
+describe("isOpenAt", () => {
+ test("a published cap includes its boundary and an implied one excludes it", () => {
+ const gmx = VENUES.find((v) => v.id === "gmx")!;
+ const dydx = VENUES.find((v) => v.id === "dydx")!;
+ expect(isOpenAt(gmx, "BTC", 199.9)).toBe(true);
+ expect(isOpenAt(gmx, "BTC", 200)).toBe(false);
+ expect(isOpenAt(dydx, "BTC", 50)).toBe(true);
+ expect(isOpenAt(dydx, "BTC", 50.1)).toBe(false);
+ });
+
+ test("a fixed leverage product is open at exactly one leverage", () => {
+ const degen = VENUES.find((v) => v.id === "gains-degen")!;
+ expect(isOpenAt(degen, "BTC", 499)).toBe(false);
+ expect(isOpenAt(degen, "BTC", 500)).toBe(true);
+ expect(isOpenAt(degen, "BTC", 501)).toBe(false);
+ });
+});
+
+describe("unavailableReason", () => {
+ // Reading the minimum first printed "fixed at 1.1x" for a standard Gains
+ // pair at 500x, where the real reason is its 200x cap.
+ test("it names the bound that was actually crossed", () => {
+ const at500 = computeCostModel({ margin: 1000, leverage: 500, market: "BTC", move: 0.002 });
+ const reasons = new Map(
+ at500.unavailable.map((r) => [r.venue.id, unavailableReason(r, 500)]),
+ );
+ expect(reasons.get("gains-std")).toBe("caps at 200x");
+ expect(reasons.get("gmx")).toContain("bites below 200x");
+
+ const at10 = computeCostModel({ margin: 1000, leverage: 10, market: "BTC", move: 0.002 });
+ const low = new Map(at10.unavailable.map((r) => [r.venue.id, unavailableReason(r, 10)]));
+ expect(low.get("gains-degen")).toBe("fixed at 500x");
+ });
+});
+
+describe("the crossover", () => {
+ test("it does not move with the margin or the leverage, only the move", () => {
+ const rival = VENUES.find((v) => v.id === "gains-degen")!;
+ const direct = crossoverMove("BTC", feeLegFraction(rival, "BTC"));
+ for (const margin of [1, 1000, 1e6]) {
+ const model = computeCostModel({ margin, leverage: 500, market: "BTC", move: 0.002 });
+ expect(model.rival?.venue.id).toBe("gains-degen");
+ expect(model.crossoverMove!).toBeCloseTo(direct, 12);
+ }
+ });
+
+ test("the cheaper venue flips exactly once around it", () => {
+ const market: Market = "BTC";
+ const rival = VENUES.find((v) => v.id === "gains-degen")!;
+ const legFee = feeLegFraction(rival, market);
+ let flips = 0;
+ let previous: string | null = null;
+ for (let move = 2e-5; move < 0.05; move *= 1.02) {
+ const cheaper = move * (1 - papertradeKeep(market, move)) < 2 * legFee ? "pt" : "rival";
+ if (previous && cheaper !== previous) flips += 1;
+ previous = cheaper;
+ }
+ expect(flips).toBe(1);
+ });
+
+ test("it is null when Papertrade has no notional-fee venue to cross", () => {
+ // Between 201x and 1000x, 500x aside, Papertrade is the only venue open.
+ const model = computeCostModel({ margin: 1000, leverage: 300, market: "BTC", move: 0.002 });
+ expect(model.live.map((r) => r.venue.id)).toEqual(["papertrade"]);
+ expect(model.rival).toBeNull();
+ expect(model.crossoverMove).toBeNull();
+ });
+});
+
+describe("computeCostModel", () => {
+ // Checked against the figures the Python harness produced off chain.
+ test("the reference position reproduces the measured values", () => {
+ const model = computeCostModel({ margin: 1000, leverage: 500, market: "BTC", move: 0.002 });
+ const degen = model.live.find((r) => r.venue.id === "gains-degen")!;
+ const papertrade = model.live.find((r) => r.venue.id === "papertrade")!;
+
+ expect(degen.cost).toBeCloseTo(200, 6);
+ expect(degen.flatCost).toBeCloseTo(200, 6);
+ expect(degen.roomPct!).toBeCloseTo(0.12, 6);
+ expect(papertrade.roomPct!).toBeCloseTo(0.1524, 4);
+ expect(papertrade.flatCost).toBe(0);
+ });
+
+ test("the sort is cheapest first and the two sets partition the venues", () => {
+ for (const market of MARKETS) {
+ for (const leverage of [1, 10, 40, 50, 200, 300, 500, 1000]) {
+ const model = computeCostModel({ margin: 1000, leverage, market, move: 0.002 });
+ expect(model.live.length + model.unavailable.length).toBe(VENUES.length);
+ for (let i = 1; i < model.live.length; i += 1) {
+ expect(model.live[i].cost).toBeGreaterThanOrEqual(model.live[i - 1].cost - 1e-9);
+ }
+ if (model.live.length > 0) expect(model.cheapest).toBe(model.live[0]);
+ if (model.live.length <= 1) expect(model.dearest).toBeNull();
+ }
+ }
+ });
+
+ // At the bottom of the slider the fees exceed the gain on every venue. The
+ // table used to crown the first row green regardless.
+ test("it reports that nothing is profitable when the fees exceed the gain", () => {
+ const model = computeCostModel({ margin: 1000, leverage: 50, market: "BTC", move: 1e-5 });
+ expect(model.anyProfitable).toBe(false);
+ expect(model.cheapest!.net).toBeLessThanOrEqual(0);
+ expect(model.live.some((r) => r.shareOfGain > 1)).toBe(true);
+ });
+
+ test("a sub-deadband win on Papertrade nets zero rather than a negative", () => {
+ const model = computeCostModel({ margin: 1000, leverage: 500, market: "BTC", move: 1e-6 });
+ const papertrade = model.live.find((r) => r.venue.id === "papertrade")!;
+ expect(papertrade.net).toBe(0);
+ expect(papertrade.cost).toBeCloseTo(model.rawGain, 12);
+ });
+
+ test("no figure reaches NaN or Infinity at the edges of the accepted inputs", () => {
+ const corners = [
+ { margin: 1, leverage: LEVERAGE_RANGE.min },
+ { margin: 1, leverage: LEVERAGE_RANGE.max },
+ { margin: 1e9, leverage: LEVERAGE_RANGE.min },
+ { margin: 1e9, leverage: LEVERAGE_RANGE.max },
+ ];
+ for (const market of MARKETS) {
+ for (const { margin, leverage } of corners) {
+ for (const move of [1e-5, 0.02]) {
+ const model = computeCostModel({ margin, leverage, market, move });
+ for (const row of [...model.live, ...model.unavailable]) {
+ for (const n of [row.cost, row.net, row.shareOfGain, row.equityLeftPct, row.flatCost]) {
+ expect(Number.isFinite(n)).toBe(true);
+ }
+ if (row.roomPct !== null) expect(Number.isFinite(row.roomPct)).toBe(true);
+ }
+ }
+ }
+ }
+ });
+});
diff --git a/src/lib/perp-cost-model.ts b/src/lib/perp-cost-model.ts
new file mode 100644
index 000000000..64a9b9047
--- /dev/null
+++ b/src/lib/perp-cost-model.ts
@@ -0,0 +1,500 @@
+/**
+ * What a leveraged perp trade costs, per venue, on one trade.
+ *
+ * Nine of the ten products here charge a share of NOTIONAL, twice, whether the
+ * trade wins or loses. Papertrade charges a share of the GAIN, once, and
+ * nothing when there is no gain. No single fee rate can express both, which is
+ * why this is a model and not a column: the ranking depends on how far the
+ * price travels, and the page exists to show where it flips.
+ *
+ * Every parameter below carries its provenance in `src`, because they are not
+ * equally solid: `api` was read from the venue's own endpoint, `chain` from its
+ * contracts, `docs` derived from a documented rule rather than a published
+ * field. Two venues were left out rather than guessed at, see NOT_SHOWN.
+ *
+ * Fees and spreads were read on MEASURED_AT. They are static here: making them
+ * live needs a harness, and a stale number with a date beats a fresh number
+ * with no method.
+ */
+
+export type Market = "BTC" | "ETH";
+export type Provenance = "api" | "chain" | "docs";
+
+/** The day every fee, spread and maintenance figure below was read. */
+export const MEASURED_AT = "2026-10-13";
+
+export const MARGIN_RANGE = { min: 1, max: 1_000_000_000, default: 1000 } as const;
+export const LEVERAGE_RANGE = { min: 1, max: 1000, default: 500 } as const;
+/** The price-move slider, in thousandths of a percent: 0.001% to 2%. */
+export const MOVE_RANGE = { min: 1, max: 2000, default: 200 } as const;
+
+const BPS = 1e-4;
+
+// ── Papertrade ────────────────────────────────────────────────────────────
+// Its haircut is its own published formula. The reference notional is a
+// constant and not the position size, by design, so the share it takes does
+// not vary with how much is traded. The bust buffer is read from
+// /state/trading and is an offset BEFORE the zero-equity price, so the room is
+// 1/leverage minus the buffer and not the buffer itself.
+export const PAPERTRADE = {
+ baseRate: 0.1,
+ rateMultiplier: 15_000,
+ referenceNotional: 1e5,
+ deadband: 0.2 * BPS,
+ winFee: 0.02,
+ perMarket: {
+ BTC: { positionMultiplier: 814.598, bustBuffer: 476_041_580_087_574 / 1e18 },
+ ETH: { positionMultiplier: 483.979, bustBuffer: 481_304_050_180_130 / 1e18 },
+ },
+} as const;
+
+/**
+ * The fraction of a winning move Papertrade hands back, after its haircut and
+ * its 2% win fee. Zero at or below the deadband, and it approaches
+ * (1 - baseRate) * (1 - winFee) from below as the move grows.
+ */
+export function papertradeKeep(market: Market, move: number): number {
+ const m = move - PAPERTRADE.deadband;
+ if (!(m > 0)) return 0;
+ const { positionMultiplier } = PAPERTRADE.perMarket[market];
+ const scale =
+ (1 - PAPERTRADE.baseRate) /
+ (1 +
+ 1 / (m * PAPERTRADE.rateMultiplier) +
+ PAPERTRADE.referenceNotional / (1e6 * m * positionMultiplier));
+ return scale * (1 - PAPERTRADE.winFee);
+}
+
+// ── Gains ─────────────────────────────────────────────────────────────────
+// getPairLiquidationParams on the Arbitrum diamond gives the threshold as a
+// share of collateral: 90% at or below 25x, 75% at or above 60x, interpolated
+// linearly between. The x500 degen pairs read 80% flat.
+const GAINS_STANDARD_THRESHOLD = {
+ startThresholdPct: 90,
+ endThresholdPct: 75,
+ startLeverage: 25,
+ endLeverage: 60,
+} as const;
+
+export function gainsStandardThresholdPct(leverage: number): number {
+ const { startThresholdPct, endThresholdPct, startLeverage, endLeverage } =
+ GAINS_STANDARD_THRESHOLD;
+ if (leverage <= startLeverage) return startThresholdPct;
+ if (leverage >= endLeverage) return endThresholdPct;
+ return (
+ startThresholdPct -
+ ((leverage - startLeverage) * (startThresholdPct - endThresholdPct)) /
+ (endLeverage - startLeverage)
+ );
+}
+
+export type Venue = {
+ id: string;
+ name: string;
+ /**
+ * What distinguishes this product from the venue's other one, empty when the
+ * venue has only this product. The chain and execution tags carry the rest,
+ * so this never repeats them.
+ */
+ sub: string;
+ /**
+ * Logo manifest key, and the provider-registry key the official link comes
+ * from. Both read the same slug, so a venue cannot show one brand's mark
+ * beside another brand's site.
+ */
+ logoKey: string;
+ /** Where the figures in this row were read, not every chain it serves. */
+ chain: string;
+ /** How a fill happens, which is why some rows carry no spread at all. */
+ execution: "Order book" | "Oracle pool" | "House LP";
+ /** What the fee is charged on. */
+ charges: "notional" | "gain";
+ src: Provenance;
+ /** Taker fee, in bps of notional, per leg. */
+ takerBps: number;
+ /**
+ * The FULL quoted book spread in bps, halved per leg below because a taker
+ * crosses half the book. Gains' 0.0100% on-chain pair spread therefore
+ * enters as 1.0 and not as 0.5.
+ */
+ spreadBps: Record;
+ minLeverage: number;
+ maxLeverage: Record;
+ /**
+ * True when the cap is not published but implied by the margin requirement
+ * eating the whole margin, so it is a limit the venue approaches rather than
+ * one it offers: the boundary itself cannot be opened.
+ */
+ impliedCap?: boolean;
+ /** Maintenance requirement as a share of notional. */
+ maintenance: (leverage: number, maxLeverage: number, market: Market) => number;
+ /** True when the fee is charged inside the liquidation trigger. */
+ feeInTrigger: boolean;
+ /** True when the trigger is documented to leave equity behind. */
+ returnsEquity: boolean;
+};
+
+export const VENUES: readonly Venue[] = [
+ {
+ id: "gains-std",
+ logoKey: "gains",
+ chain: "Arbitrum",
+ execution: "Oracle pool",
+ name: "Gains",
+ sub: "standard pair",
+ charges: "notional",
+ src: "chain",
+ takerBps: 3.5,
+ spreadBps: { BTC: 1.0, ETH: 1.0 },
+ minLeverage: 1.1,
+ maxLeverage: { BTC: 200, ETH: 200 },
+ maintenance: (lev) => (100 - gainsStandardThresholdPct(lev)) / 100 / lev,
+ feeInTrigger: true,
+ returnsEquity: false,
+ },
+ {
+ // pairMinLeverage and pairMaxLeverage both read 500x, so this is a fixed
+ // leverage product and not a 500x ceiling.
+ id: "gains-degen",
+ logoKey: "gains",
+ chain: "Arbitrum",
+ execution: "Oracle pool",
+ name: "Gains",
+ sub: "x500 degen, fixed",
+ charges: "notional",
+ src: "chain",
+ takerBps: 2.0,
+ spreadBps: { BTC: 0, ETH: 0 },
+ minLeverage: 500,
+ maxLeverage: { BTC: 500, ETH: 500 },
+ maintenance: (lev) => 20 / 100 / lev,
+ feeInTrigger: true,
+ returnsEquity: false,
+ },
+ {
+ id: "papertrade",
+ logoKey: "papertrade",
+ chain: "HyperEVM",
+ execution: "House LP",
+ name: "Papertrade",
+ sub: "charges on the gain",
+ charges: "gain",
+ src: "api",
+ takerBps: 0,
+ spreadBps: { BTC: 0, ETH: 0 },
+ minLeverage: 1,
+ maxLeverage: { BTC: 1000, ETH: 1000 },
+ maintenance: (_lev, _mx, market) => PAPERTRADE.perMarket[market].bustBuffer,
+ feeInTrigger: false,
+ returnsEquity: false,
+ },
+ {
+ id: "lighter",
+ logoKey: "lighter",
+ chain: "zkSync rollup",
+ execution: "Order book",
+ name: "Lighter",
+ sub: "",
+ charges: "notional",
+ src: "api",
+ takerBps: 0,
+ spreadBps: { BTC: 0.3197, ETH: 0.3186 },
+ minLeverage: 1,
+ maxLeverage: { BTC: 50, ETH: 50 },
+ maintenance: () => 0.012,
+ feeInTrigger: false,
+ returnsEquity: true,
+ },
+ {
+ id: "paradex",
+ logoKey: "paradex",
+ chain: "Starknet",
+ execution: "Order book",
+ name: "Paradex",
+ sub: "",
+ charges: "notional",
+ src: "api",
+ takerBps: 0,
+ spreadBps: { BTC: 1.4947, ETH: 1.4335 },
+ minLeverage: 1,
+ maxLeverage: { BTC: 50, ETH: 50 },
+ maintenance: () => 0.01,
+ feeInTrigger: false,
+ returnsEquity: true,
+ },
+ {
+ id: "extended",
+ logoKey: "extended",
+ chain: "Starknet",
+ execution: "Order book",
+ name: "Extended",
+ sub: "",
+ charges: "notional",
+ src: "docs",
+ takerBps: 2.5,
+ spreadBps: { BTC: 0.0604, ETH: 0.1994 },
+ minLeverage: 1,
+ maxLeverage: { BTC: 50, ETH: 50 },
+ maintenance: () => 0.01,
+ feeInTrigger: false,
+ returnsEquity: true,
+ },
+ {
+ id: "edgex",
+ logoKey: "edgex",
+ chain: "StarkEx",
+ execution: "Order book",
+ name: "edgeX",
+ sub: "",
+ charges: "notional",
+ src: "api",
+ takerBps: 4.5,
+ spreadBps: { BTC: 0.0181, ETH: 0.2479 },
+ minLeverage: 1,
+ maxLeverage: { BTC: 100, ETH: 100 },
+ maintenance: () => 0.005,
+ feeInTrigger: false,
+ returnsEquity: true,
+ },
+ {
+ id: "hyperliquid",
+ logoKey: "hyperliquid",
+ chain: "Hyperliquid L1",
+ execution: "Order book",
+ name: "Hyperliquid",
+ sub: "",
+ charges: "notional",
+ src: "docs",
+ takerBps: 4.5,
+ spreadBps: { BTC: 0.0603, ETH: 0.1991 },
+ minLeverage: 1,
+ maxLeverage: { BTC: 40, ETH: 25 },
+ maintenance: (_lev, mx) => 1 / (2 * mx),
+ feeInTrigger: false,
+ returnsEquity: true,
+ },
+ {
+ id: "dydx",
+ logoKey: "dydx",
+ chain: "dYdX Chain",
+ execution: "Order book",
+ name: "dYdX v4",
+ sub: "",
+ charges: "notional",
+ src: "api",
+ takerBps: 5,
+ spreadBps: { BTC: 8.4248, ETH: 1.7929 },
+ minLeverage: 1,
+ maxLeverage: { BTC: 50, ETH: 50 },
+ maintenance: () => 0.012,
+ feeInTrigger: false,
+ returnsEquity: true,
+ },
+ {
+ // GMX publishes no leverage cap. Its 200x is 1 / 0.005, the leverage at
+ // which the margin requirement equals the whole margin, so a position AT
+ // 200x has no room at all and only something strictly below can be opened.
+ id: "gmx",
+ logoKey: "gmx",
+ chain: "Arbitrum",
+ execution: "Oracle pool",
+ name: "GMX v2",
+ sub: "implied cap",
+ charges: "notional",
+ src: "chain",
+ takerBps: 6,
+ spreadBps: { BTC: 0, ETH: 0 },
+ minLeverage: 1,
+ maxLeverage: { BTC: 200, ETH: 200 },
+ impliedCap: true,
+ maintenance: () => 0.005,
+ feeInTrigger: false,
+ returnsEquity: true,
+ },
+];
+
+/** Venues deliberately absent, and why. A missing row beats a wrong one. */
+export const NOT_SHOWN = [
+ {
+ name: "Aster",
+ reason:
+ "its leverage-bracket endpoint needs a key, and the only public margin fields contradict the product it advertises",
+ },
+ {
+ name: "Ostium",
+ reason:
+ "its subgraph publishes two threshold fields and only one matches its documented formula, an unresolved conflict",
+ },
+] as const;
+
+/** "Gains x500 degen, fixed" or just "Lighter", for prose and for labels. */
+export function venueLabel(venue: Venue): string {
+ return venue.sub ? `${venue.name} ${venue.sub}` : venue.name;
+}
+
+/** Taker fee plus half the quoted spread, as a fraction of notional, per leg. */
+export function feeLegFraction(venue: Venue, market: Market): number {
+ return (venue.takerBps + venue.spreadBps[market] / 2) * BPS;
+}
+
+export function maxLeverageOn(venue: Venue, market: Market): number {
+ return venue.maxLeverage[market];
+}
+
+export function isOpenAt(venue: Venue, market: Market, leverage: number): boolean {
+ const mx = maxLeverageOn(venue, market);
+ if (leverage < venue.minLeverage) return false;
+ return venue.impliedCap ? leverage < mx : leverage <= mx;
+}
+
+export type CostRow = {
+ venue: Venue;
+ open: boolean;
+ maxLeverage: number;
+ /** What the venue takes, in dollars, on this trade. */
+ cost: number;
+ /** What is left of the gain after it. */
+ net: number;
+ /** Cost over raw gain. Above 1 the fees exceed the gain. */
+ shareOfGain: number;
+ /**
+ * Distance from entry to the liquidation trigger, in percent of price, or
+ * null when the position has no room at all at this leverage.
+ */
+ roomPct: number | null;
+ /** Share of margin the trigger leaves behind, before the closing fee. */
+ equityLeftPct: number;
+ /** What a flat close costs, in dollars. */
+ flatCost: number;
+};
+
+export type CostModel = {
+ margin: number;
+ leverage: number;
+ market: Market;
+ move: number;
+ notional: number;
+ rawGain: number;
+ /** Open at this leverage, cheapest first. */
+ live: CostRow[];
+ /** Not open at this leverage, in declaration order. */
+ unavailable: CostRow[];
+ cheapest: CostRow | null;
+ dearest: CostRow | null;
+ /** False when even the cheapest venue takes more than the whole gain. */
+ anyProfitable: boolean;
+ /** The cheapest open venue charging on notional, if any. */
+ rival: CostRow | null;
+ /**
+ * The price move at which Papertrade's haircut equals that rival's round
+ * trip, or null when the comparison does not apply. Below it Papertrade
+ * takes less, above it the rival does.
+ */
+ crossoverMove: number | null;
+};
+
+/**
+ * Where Papertrade's haircut equals a notional-fee round trip.
+ *
+ * Both sides are proportional to notional, so it cancels: the crossover
+ * depends only on the price move, never on the margin or the leverage. Solving
+ * `move * (1 - keep(move)) = 2 * feeLeg` by bisection, since keep() is
+ * monotone in move (asserted in the tests) and so the left side is too.
+ */
+export function crossoverMove(market: Market, rivalFeeLeg: number): number {
+ let lo = 1e-7;
+ let hi = 1;
+ for (let i = 0; i < 120; i += 1) {
+ const mid = (lo + hi) / 2;
+ if (mid * (1 - papertradeKeep(market, mid)) < 2 * rivalFeeLeg) lo = mid;
+ else hi = mid;
+ }
+ return (lo + hi) / 2;
+}
+
+export function computeCostModel({
+ margin,
+ leverage,
+ market,
+ move,
+}: {
+ margin: number;
+ leverage: number;
+ market: Market;
+ /** Price move in your favour, as a fraction. 0.002 is 0.2% of price. */
+ move: number;
+}): CostModel {
+ const notional = margin * leverage;
+ const rawGain = move * notional;
+
+ const rows: CostRow[] = VENUES.map((venue) => {
+ const mx = maxLeverageOn(venue, market);
+ const open = isOpenAt(venue, market, leverage);
+ const legFee = feeLegFraction(venue, market);
+ const roundTrip = 2 * legFee * notional;
+ const cost =
+ venue.charges === "gain" ? rawGain * (1 - papertradeKeep(market, move)) : roundTrip;
+
+ let roomPct: number | null = null;
+ let equityLeftPct = 0;
+ if (open) {
+ const mm = venue.maintenance(leverage, mx, market);
+ const room = (1 / leverage - mm - (venue.feeInTrigger ? 2 * legFee : 0)) * 100;
+ roomPct = room > 0 ? room : null;
+ if (venue.returnsEquity && roomPct !== null) {
+ equityLeftPct = Math.max(0, Math.min(1, 1 - (roomPct / 100) * leverage));
+ }
+ }
+
+ return {
+ venue,
+ open,
+ maxLeverage: mx,
+ cost,
+ net: rawGain - cost,
+ shareOfGain: rawGain > 0 ? cost / rawGain : 0,
+ roomPct,
+ equityLeftPct,
+ flatCost: venue.charges === "gain" ? 0 : roundTrip,
+ };
+ });
+
+ const live = rows.filter((r) => r.open).sort((a, b) => b.net - a.net);
+ const unavailable = rows.filter((r) => !r.open);
+ const cheapest = live[0] ?? null;
+ const dearest = live.length > 1 ? live[live.length - 1] : null;
+ const papertrade = rows.find((r) => r.venue.id === "papertrade") ?? null;
+ const rival = live.find((r) => r.venue.charges === "notional") ?? null;
+
+ return {
+ margin,
+ leverage,
+ market,
+ move,
+ notional,
+ rawGain,
+ live,
+ unavailable,
+ cheapest,
+ dearest,
+ anyProfitable: cheapest !== null && cheapest.net > 0,
+ rival,
+ crossoverMove:
+ papertrade?.open && rival ? crossoverMove(market, feeLegFraction(rival.venue, market)) : null,
+ };
+}
+
+/**
+ * Why a venue is absent, phrased from the bound actually crossed. Reading the
+ * minimum first said "fixed at 1.1x" for a venue that was in fact capped.
+ */
+export function unavailableReason(row: CostRow, leverage: number): string {
+ const { minLeverage, impliedCap } = row.venue;
+ const mx = row.maxLeverage;
+ if (leverage < minLeverage) {
+ return minLeverage === mx ? `fixed at ${minLeverage}x` : `starts at ${minLeverage}x`;
+ }
+ return impliedCap
+ ? `no published cap; the margin requirement bites below ${mx}x`
+ : `caps at ${mx}x`;
+}
diff --git a/src/lib/perp-stats.ts b/src/lib/perp-stats.ts
index ad35e6242..bcad55e02 100644
--- a/src/lib/perp-stats.ts
+++ b/src/lib/perp-stats.ts
@@ -94,7 +94,7 @@ type VenueSeed = {
export const PERP_VENUES: VenueSeed[] = [
{ slug: "hyperliquid", name: "Hyperliquid", chain: "Hyperliquid L1", venueType: "onchain" },
{ slug: "lighter", name: "Lighter", chain: "Lighter L2", venueType: "onchain" },
- { slug: "gmx-v2", name: "GMX v2", chain: "Arbitrum", venueType: "onchain" },
+ { slug: "gmx-v2", name: "GMX", chain: "Arbitrum", venueType: "onchain" },
{ slug: "gains", name: "Gains", chain: "Arbitrum", venueType: "onchain" },
{ slug: "dydx", name: "dYdX v4", chain: "Cosmos", venueType: "onchain" },
{ slug: "nado", name: "Nado", chain: "Ink", venueType: "onchain" },
@@ -116,6 +116,7 @@ export const PERP_VENUES: VenueSeed[] = [
{ slug: "standx", name: "StandX", chain: "BNB Chain", venueType: "onchain" },
{ slug: "apex", name: "ApeX Omni", chain: "Omnichain", venueType: "onchain" },
{ slug: "jupiter", name: "Jupiter Perps", chain: "Solana", venueType: "onchain" },
+ { slug: "papertrade", name: "Papertrade", chain: "Hyperliquid", venueType: "onchain" },
{ slug: "lighter-rh", name: "Lighter RH", chain: "Lighter L2, Robinhood", venueType: "onchain" },
{ slug: "trade-xyz", name: "trade.xyz", chain: "Hyperliquid HIP-3", venueType: "onchain" },
// Ondo Perps, live since June 2026: matching runs off-chain in Intel SGX
diff --git a/src/lib/perp-venue-context.ts b/src/lib/perp-venue-context.ts
index 4b4d5b7d5..ad407b2d9 100644
--- a/src/lib/perp-venue-context.ts
+++ b/src/lib/perp-venue-context.ts
@@ -51,6 +51,7 @@ export const PERP_VENUE_META: Record<
standx: { url: "https://standx.com", chainLabel: "BNB Chain" },
apex: { url: "https://omni.apex.exchange", chainLabel: "Omnichain" },
jupiter: { url: "https://jup.ag/perps", chainLabel: "Solana" },
+ papertrade: { url: "https://papertrade.xyz", chainLabel: "Hyperliquid" },
"lighter-rh": { url: "https://rh.lighter.xyz", chainLabel: "Lighter L2, Robinhood" },
"trade-xyz": { url: "https://trade.xyz", chainLabel: "Hyperliquid HIP-3", productSlug: PERP_PRODUCT_SLUGS["trade-xyz"] },
binance: { url: "https://www.binance.com/en/futures", chainLabel: "Offchain, CEX" },
diff --git a/src/lib/providers.ts b/src/lib/providers.ts
index 77e781049..ae2c947ef 100644
--- a/src/lib/providers.ts
+++ b/src/lib/providers.ts
@@ -55,14 +55,20 @@ const PRODUCT_ALIASES: Record = {
// Arc publishes a keyless QuickNode endpoint under its own domain; the
// product page is QuickNode's.
"arc-quicknode": "quicknode",
- // The cold-start cohort added on 2026-10-08 is the same four vendors on a
- // connection that is never reused. Each `-cold` slug built its own product
- // page: four indexable duplicates of the real provider, with no description
- // and no official link, because the registry has no entry for them. They are
- // a measurement arm, not a product. Folding them here collapses their bench
+ // The cold-start cohort added on 2026-10-08 is the keyed vendors measured on
+ // a connection that is never reused. Each `-cold` slug built its own product
+ // page: indexable duplicates of the real provider, with no description and
+ // no official link, because the registry has no entry for them. They are a
+ // measurement arm, not a product. Folding them here collapses their bench
// appearances onto the vendor's page and leaves one page per vendor, which
// also restores parity with the logo-manifest ALIASES entries they already
// have.
+ //
+ // Every vendor in the cold tier needs a line here, and adding one to the
+ // tier without it is silent: /products/dwellir-cold shipped to production as
+ // a 200 with the same title as /products/dwellir and a sitemap entry, while
+ // the original four redirected. Keep this list in step with the
+ // `tier: keyed-cold` blocks in the `-rpc` specs.
"alchemy-cold": "alchemy",
"chainstack-cold": "chainstack",
"getblock-cold": "getblock",
diff --git a/src/lib/rpc-hub-region-scope.test.ts b/src/lib/rpc-hub-region-scope.test.ts
new file mode 100644
index 000000000..245e829a7
--- /dev/null
+++ b/src/lib/rpc-hub-region-scope.test.ts
@@ -0,0 +1,227 @@
+import { describe, expect, it } from "bun:test";
+import {
+ scopeCohortToRegion,
+ type RpcHubChain,
+ type RpcHubCohort,
+} from "@/lib/rpc-hub-shape";
+
+/**
+ * The /rpc geo view, pinned on the shapes the staging payload actually
+ * ships (the private cohort on 2026-10-16: 53 provider x chain cells, all
+ * three regions present except 8 cells with no Singapore point of
+ * presence).
+ *
+ * What the scope has to get right is which figures survive it. A region's
+ * p50 exists per provider, so it substitutes cleanly. A p90 does not: the
+ * blob computes the tail across all three regions, and carrying it into a
+ * column headed "Singapore" would publish a 3-region number as a regional
+ * one. Same for the leader's sparkline, which belongs to whoever led
+ * overall.
+ */
+
+const chain = (
+ slug: string,
+ providers: {
+ provider: string;
+ p50Ms: number;
+ regions: Record;
+ successPct?: number;
+ sampleSize?: number;
+ }[],
+ extra: Partial = {},
+): RpcHubChain => {
+ const field = providers.map((p) => ({
+ provider: p.provider,
+ name: p.provider,
+ p50Ms: p.p50Ms,
+ p90Ms: p.p50Ms * 3,
+ regions: p.regions,
+ ...(p.successPct != null ? { successPct: p.successPct } : {}),
+ ...(p.sampleSize != null ? { sampleSize: p.sampleSize } : {}),
+ }));
+ const leader = field[0];
+ return {
+ chain: slug.replace(/-rpc$/, ""),
+ slug,
+ name: slug,
+ benchTitle: slug,
+ providerCount: field.length,
+ best: {
+ provider: leader.provider,
+ providerName: leader.name,
+ p50Ms: leader.p50Ms,
+ },
+ bestP90Ms: leader.p90Ms,
+ regions: {},
+ providers: field,
+ series: [1, 2, 3],
+ updatedAt: "2026-10-16T10:00:00Z",
+ ...extra,
+ };
+};
+
+/** Arbitrum's real shape: QuickNode leads overall and from Singapore,
+ * Chainstack is quicker from EU-West. Plus a vendor with no Singapore
+ * point of presence, and a chain measured from nowhere but us-east. */
+const cohort = (): RpcHubCohort => ({
+ chains: [
+ chain("arbitrum-rpc", [
+ {
+ provider: "quicknode",
+ p50Ms: 6.2,
+ regions: { "us-east": 5.8, "eu-west": 8.2, sgp: 4.5 },
+ successPct: 100,
+ sampleSize: 700,
+ },
+ {
+ provider: "chainstack",
+ p50Ms: 7.1,
+ regions: { "us-east": 6.9, "eu-west": 6.0, sgp: 9.8 },
+ successPct: 99.8,
+ sampleSize: 700,
+ },
+ {
+ provider: "validationcloud",
+ p50Ms: 20.4,
+ regions: { "us-east": 19.1, "eu-west": 21.7 },
+ },
+ ]),
+ chain("arc-rpc", [
+ { provider: "alchemy", p50Ms: 30.1, regions: { "us-east": 30.1 } },
+ ]),
+ ],
+ providersPivot: [],
+ totals: { chains: 2, uniqueProviders: 3, regions: 3 },
+});
+
+describe("scopeCohortToRegion", () => {
+ it("substitutes that region's p50 for every provider", () => {
+ const sgp = scopeCohortToRegion(cohort(), "sgp");
+ const arb = sgp.chains.find((c) => c.slug === "arbitrum-rpc");
+ expect(arb?.providers.map((p) => [p.provider, p.p50Ms])).toEqual([
+ ["quicknode", 4.5],
+ ["chainstack", 9.8],
+ ]);
+ });
+
+ it("re-ranks, so a provider that only wins one region wins there", () => {
+ const eu = scopeCohortToRegion(cohort(), "eu-west");
+ const arb = eu.chains.find((c) => c.slug === "arbitrum-rpc");
+ // QuickNode leads the blended board at 6.2; from EU-West it is 8.2
+ // against Chainstack's 6.0, and the leader has to change with it.
+ expect(arb?.best).toEqual({
+ provider: "chainstack",
+ providerName: "chainstack",
+ p50Ms: 6.0,
+ });
+ expect(arb?.regions).toEqual({
+ "eu-west": {
+ provider: "chainstack",
+ providerName: "chainstack",
+ p50Ms: 6.0,
+ },
+ });
+ });
+
+ it("drops a provider with no point of presence in the region", () => {
+ const sgp = scopeCohortToRegion(cohort(), "sgp");
+ const arb = sgp.chains.find((c) => c.slug === "arbitrum-rpc");
+ expect(arb?.providers.map((p) => p.provider)).not.toContain(
+ "validationcloud",
+ );
+ expect(arb?.providerCount).toBe(2);
+ });
+
+ it("drops a chain nobody answers from the region", () => {
+ const sgp = scopeCohortToRegion(cohort(), "sgp");
+ expect(sgp.chains.map((c) => c.slug)).toEqual(["arbitrum-rpc"]);
+ expect(sgp.totals.chains).toBe(1);
+ expect(sgp.totals.regions).toBe(1);
+ });
+
+ it("clears the tails rather than publish a 3-region p90 as regional", () => {
+ const sgp = scopeCohortToRegion(cohort(), "sgp");
+ for (const c of sgp.chains) {
+ expect(c.bestP90Ms).toBeUndefined();
+ for (const p of c.providers) expect(p.p90Ms).toBeUndefined();
+ }
+ });
+
+ it("keeps the leader's sparkline only when the leader is the same row", () => {
+ const sgp = scopeCohortToRegion(cohort(), "sgp");
+ // QuickNode leads both blended and from Singapore: the series is its own.
+ expect(sgp.chains[0]?.series).toEqual([1, 2, 3]);
+
+ const eu = scopeCohortToRegion(cohort(), "eu-west");
+ const arb = eu.chains.find((c) => c.slug === "arbitrum-rpc");
+ // Chainstack leads EU-West, so the stored series belongs to QuickNode.
+ expect(arb?.series).toBeUndefined();
+ });
+
+ it("rebuilds the pivot on the region's ordering", () => {
+ const eu = scopeCohortToRegion(cohort(), "eu-west");
+ const byProvider = Object.fromEntries(
+ eu.providersPivot.map((r) => [r.provider, r]),
+ );
+ expect(byProvider.chainstack?.chains.arbitrum).toEqual({
+ p50Ms: 6.0,
+ rank: 1,
+ successPct: 99.8,
+ sampleSize: 700,
+ });
+ expect(byProvider.quicknode?.chains.arbitrum?.rank).toBe(2);
+ expect(byProvider.quicknode?.chains.arbitrum?.p50Ms).toBe(8.2);
+ expect(eu.totals.uniqueProviders).toBe(eu.providersPivot.length);
+ });
+
+ it("carries reliability and archive through untouched", () => {
+ const base = cohort();
+ base.chains[0]!.providers[0]!.archiveDepth = {
+ supportedBlocks: [300, 7200],
+ };
+ const sgp = scopeCohortToRegion(base, "sgp");
+ const qn = sgp.chains[0]?.providers[0];
+ expect(qn?.archiveDepth?.supportedBlocks).toEqual([300, 7200]);
+ expect(qn?.successPct).toBe(100);
+ expect(qn?.sampleSize).toBe(700);
+ });
+
+ it("substitutes the region's own tail when the variant measured one", () => {
+ // What the region variant blob supplies: its headline queries already
+ // carry region="", so these are that region's p90/p95/p99 and not a
+ // 3-region figure. This is the whole point of the geo view for a provider
+ // asking "what is my p95 at this edge location".
+ const base = cohort();
+ base.chains[0]!.providers[0]!.regionTails = {
+ sgp: { p90: 7.1, p95: 9.4, p99: 21.0 },
+ };
+ const sgp = scopeCohortToRegion(base, "sgp");
+ const qn = sgp.chains[0]?.providers[0];
+ expect(qn?.provider).toBe("quicknode");
+ expect([qn?.p90Ms, qn?.p95Ms, qn?.p99Ms]).toEqual([7.1, 9.4, 21.0]);
+ // And the chain's headline tail follows the regional leader, which here
+ // is that same row.
+ expect(sgp.chains[0]?.bestP90Ms).toBe(7.1);
+ expect(sgp.chains[0]?.bestP95Ms).toBe(9.4);
+ });
+
+ it("drops the tail for a region the variant did not measure", () => {
+ const base = cohort();
+ base.chains[0]!.providers[0]!.regionTails = {
+ "us-east": { p90: 7.1, p95: 9.4 },
+ };
+ const sgp = scopeCohortToRegion(base, "sgp");
+ const qn = sgp.chains[0]?.providers[0];
+ // A us-east tail says nothing about Singapore, so nothing is shown.
+ expect(qn?.p90Ms).toBeUndefined();
+ expect(qn?.p95Ms).toBeUndefined();
+ expect(sgp.chains[0]?.bestP95Ms).toBeUndefined();
+ });
+
+ it("leaves the unscoped cohort alone", () => {
+ const base = cohort();
+ const before = JSON.stringify(base);
+ scopeCohortToRegion(base, "sgp");
+ expect(JSON.stringify(base)).toBe(before);
+ });
+});
diff --git a/src/lib/rpc-hub-shape.ts b/src/lib/rpc-hub-shape.ts
new file mode 100644
index 000000000..4a8ba9aa3
--- /dev/null
+++ b/src/lib/rpc-hub-shape.ts
@@ -0,0 +1,325 @@
+/**
+ * The /rpc hub's data shape, and the two pure transforms over it.
+ *
+ * Split out of `rpc-hub-stats` so a client component can call them: that
+ * module reaches for next/cache, the Upstash store and the spec loader, and
+ * a value import from a "use client" file would pull all of it into the
+ * browser bundle. This file imports nothing on purpose.
+ *
+ * `rpc-hub-stats` re-exports everything here, so server callers can keep
+ * importing from where they always did.
+ */
+
+export type RpcRegionKey = "us-east" | "eu-west" | "sgp";
+
+export const RPC_REGION_KEYS: readonly RpcRegionKey[] = [
+ "us-east",
+ "eu-west",
+ "sgp",
+] as const;
+
+export type RpcRegionBest = {
+ provider: string;
+ providerName: string;
+ p50Ms: number;
+};
+
+export type RpcHubProvider = {
+ provider: string;
+ name: string;
+ p50Ms: number;
+ p90Ms?: number;
+ /** Fifth quantile, published only where the spec asks for it. */
+ p95Ms?: number;
+ p99Ms?: number;
+ successPct?: number;
+ sampleSize?: number;
+ /** p50 per probe region, keyed by region value. */
+ regions: Partial>;
+ /** The tails per probe region, from each region variant's own blob (the
+ * variant injects `region=""` into the headline queries, so its
+ * p90/p95/p99 are already region-scoped). Separate from `regions` to keep
+ * older blobs, which carry p50 numbers there and nothing else, parseable.
+ * This is what lets the geo view show a tail per edge location instead of
+ * repeating a 3-region figure under a region heading. */
+ regionTails?: Partial<
+ Record
+ >;
+ /** Archive block depths this provider supports (subset of the
+ * harness's `depthBuckets` = {300, 7200, 216000, 1296000, 5000000}).
+ * Absent = not measured (Solana / Substrate chains skip archive)
+ * or not yet propagated by the worker. */
+ archiveDepth?: { supportedBlocks: number[] };
+};
+
+export type RpcHubUnresponsiveProvider = {
+ provider: string;
+ name: string;
+ /** Success rate over 24h (%, 2 decimals). Call counters keep
+ * recording through an outage, so this stays meaningful. */
+ successPct?: number;
+ sampleSize?: number;
+};
+
+export type RpcHubChain = {
+ /** Chain slug, e.g. "ethereum". */
+ chain: string;
+ /** Bench slug, e.g. "ethereum-rpc". */
+ slug: string;
+ /** Chain display label from the chain registry ("Ethereum"). */
+ name: string;
+ /** Bench display title (spec.title). */
+ benchTitle: string;
+ providerCount: number;
+ /** Declared results in the spec (live or not); drives the hub link gate. */
+ declaredCount?: number;
+ /** Cohort providers currently flagged unresponsive on this chain
+ * (probed, all calls failing, no latency). Never part of
+ * best/fastest computations — display-only context. */
+ unresponsiveCount?: number;
+ /** The unresponsive rows themselves (never ranked). Additive optional
+ * field: old blobs without it still parse. */
+ unresponsive?: RpcHubUnresponsiveProvider[];
+ best: RpcRegionBest | null;
+ /** p90 of the overall best provider (same row as `best`). Optional so old blobs stay parseable. */
+ bestP90Ms?: number;
+ /** p95 of that same row, where the spec declares one. */
+ bestP95Ms?: number;
+ /** Max archive block depth supported across ALL providers on this
+ * chain (0 or absent = archive not measured / all providers pruned).
+ * Paired with bestArchiveProviders which names the providers hitting
+ * that max depth — the tooltip in the /rpc hub reads both. */
+ bestArchiveDepthBlocks?: number;
+ /** Providers whose supported set includes bestArchiveDepthBlocks
+ * (i.e. the ones actually offering the max archive on this chain).
+ * Empty when bestArchiveDepthBlocks is undefined. */
+ bestArchiveProviders?: string[];
+ /** Best provider per probe region. */
+ regions: Partial>;
+ /** Full live provider field, sorted fastest first. */
+ providers: RpcHubProvider[];
+ /** Leader's 24h latency series, downsampled to <=48 points. */
+ series?: number[];
+ dataConfidence?: string;
+ updatedAt: string;
+};
+
+export type RpcHubPivotRow = {
+ provider: string;
+ name: string;
+ chainsCovered: number;
+ medianRank: number;
+ medianP50Ms: number;
+ /** Median 24h success rate across covered chains (%, 2 decimals).
+ * Optional: absent from older blobs and success-less rows. */
+ medianSuccessPct?: number;
+ /** Failed probes over 24h summed across covered chains:
+ * Σ round(sampleSize × (1 − successPct/100)). Optional (needs
+ * sampleSize; absent from older blobs). */
+ errors24h?: number;
+ chains: Record<
+ string,
+ { p50Ms: number; rank: number; successPct?: number; sampleSize?: number }
+ >;
+};
+
+export type RpcHubCohort = {
+ chains: RpcHubChain[];
+ providersPivot: RpcHubPivotRow[];
+ totals: { chains: number; uniqueProviders: number; regions: number };
+};
+
+export type RpcHubSnapshot = RpcHubCohort & {
+ /** The API-key cohort (bench blobs at sig `tier=keyed`): the chains
+ * whose spec declares a keyed tier, ranked on their own. Absent on
+ * blobs written before the tier dimension existed and when no keyed
+ * blob resolves yet; the hub then shows the public view alone. */
+ keyed?: RpcHubCohort;
+ /** The cold-start arm (bench blobs at sig `tier=keyed-cold`): the same
+ * vendors and endpoints as `keyed`, measured on a connection that is
+ * never reused, so DNS, TCP and the TLS handshake are paid on every
+ * call. Ranked on its own and never merged into `keyed`: the two are
+ * different questions (a pooled service against a one-shot client),
+ * and the ordering between vendors is not the same in both. Absent on
+ * blobs written before this arm reached the hub. */
+ keyedCold?: RpcHubCohort;
+ /** The WebSocket race (benches 296 to 304, slugs `-rpc-ws`): the same
+ * private providers measured on block delivery instead of request latency.
+ *
+ * A separate bench family rather than a tier, so it is read from its own
+ * slugs and not from a variant sig. Its figure is also a different quantity:
+ * milliseconds behind whichever provider pushed that block first, where the
+ * leader reads near zero by construction. The page must label it as a lag
+ * rather than as a latency, or a reader sees "0 ms" and concludes the
+ * provider answers instantly. */
+ websocket?: RpcHubCohort;
+ generatedAt: string;
+};
+
+export const round1 = (v: number) => Math.round(v * 10) / 10;
+export const round2 = (v: number) => Math.round(v * 100) / 100;
+
+export function median(values: number[]): number {
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 === 0
+ ? (sorted[mid - 1] + sorted[mid]) / 2
+ : sorted[mid];
+}
+
+export function buildPivot(chains: RpcHubChain[]): RpcHubPivotRow[] {
+ const acc = new Map<
+ string,
+ { name: string; chains: RpcHubPivotRow["chains"] }
+ >();
+ for (const c of chains) {
+ c.providers.forEach((p, i) => {
+ const entry =
+ acc.get(p.provider) ?? { name: p.name, chains: {} };
+ entry.chains[c.chain] = {
+ p50Ms: p.p50Ms,
+ rank: i + 1,
+ ...(p.successPct != null ? { successPct: p.successPct } : {}),
+ ...(p.sampleSize != null ? { sampleSize: p.sampleSize } : {}),
+ };
+ acc.set(p.provider, entry);
+ });
+ }
+ const rows: RpcHubPivotRow[] = [...acc.entries()].map(
+ ([provider, { name, chains: perChain }]) => {
+ const cells = Object.values(perChain);
+ // Reliability aggregates over covered chains only. Success is a
+ // median (robust to one bad chain); errors are an absolute sum of
+ // failed probes, same derivation as the ledger's Errors column.
+ const successes = cells
+ .map((c) => c.successPct)
+ .filter((v): v is number => v != null);
+ const errorCells = cells.filter(
+ (c) => c.sampleSize != null && c.successPct != null,
+ );
+ const errors24h = errorCells.reduce(
+ (sum, c) =>
+ sum + Math.round((c.sampleSize as number) * (1 - (c.successPct as number) / 100)),
+ 0,
+ );
+ return {
+ provider,
+ name,
+ chainsCovered: cells.length,
+ medianRank: round1(median(cells.map((c) => c.rank))),
+ medianP50Ms: round1(median(cells.map((c) => c.p50Ms))),
+ ...(successes.length > 0
+ ? { medianSuccessPct: round2(median(successes)) }
+ : {}),
+ ...(errorCells.length > 0 ? { errors24h } : {}),
+ chains: perChain,
+ };
+ },
+ );
+ // Coverage first (the pivot's whole point), then rank quality.
+ return rows.sort(
+ (a, b) =>
+ b.chainsCovered - a.chainsCovered ||
+ a.medianRank - b.medianRank ||
+ a.medianP50Ms - b.medianP50Ms,
+ );
+}
+
+/** Probe-region display names, so the hub, the selector and the column
+ * headers cannot drift apart. */
+export const RPC_REGION_LABELS: Record = {
+ "us-east": "US-East",
+ "eu-west": "EU-West",
+ sgp: "Singapore",
+};
+
+/**
+ * Re-scope a cohort to one probe region.
+ *
+ * The hub's chain table has always carried three region columns, but each
+ * shows only that region's *winner*, and the provider pivot averages the
+ * three. So "who is fastest for my users in Singapore, provider by
+ * provider" could not be read off the board, even though every number it
+ * needs already ships: `RpcHubProvider.regions` holds the per-region p50
+ * for every provider on every chain.
+ *
+ * This takes a cohort and returns the same shape with one region's numbers
+ * substituted, which lets the existing tables render a geo view with no new
+ * data and no extra fetch.
+ *
+ * Three things are deliberately dropped rather than carried over, because
+ * under a region heading they would read as regional figures when they are
+ * not:
+ *
+ * - the tails, unless the region variant supplied its own. A provider's
+ * headline `p90Ms` is computed over all three regions, so it must not
+ * appear under a region heading; `regionTails` holds what the variant
+ * measured in that one region, and those substitute cleanly. A cell with
+ * no variant tail goes empty rather than borrowing the 3-region figure.
+ * - a chain where no provider answered from this region: dropped, so a
+ * chain never appears with an empty field.
+ * - `series`: it is the all-region leader's trend. Kept only when the
+ * regional leader is that same provider, otherwise the sparkline would
+ * sit on a row it does not belong to.
+ */
+export function scopeCohortToRegion(
+ view: RpcHubCohort,
+ region: RpcRegionKey,
+): RpcHubCohort {
+ const chains: RpcHubChain[] = [];
+ for (const c of view.chains) {
+ const providers: RpcHubProvider[] = [];
+ for (const p of c.providers) {
+ const ms = p.regions?.[region];
+ if (ms == null) continue;
+ const tail = p.regionTails?.[region];
+ providers.push({
+ provider: p.provider,
+ name: p.name,
+ p50Ms: ms,
+ ...(tail?.p90 != null ? { p90Ms: tail.p90 } : {}),
+ ...(tail?.p95 != null ? { p95Ms: tail.p95 } : {}),
+ ...(tail?.p99 != null ? { p99Ms: tail.p99 } : {}),
+ ...(p.successPct != null ? { successPct: p.successPct } : {}),
+ ...(p.sampleSize != null ? { sampleSize: p.sampleSize } : {}),
+ ...(p.archiveDepth ? { archiveDepth: p.archiveDepth } : {}),
+ regions: { [region]: ms },
+ ...(tail ? { regionTails: { [region]: tail } } : {}),
+ });
+ }
+ if (providers.length === 0) continue;
+ providers.sort((a, b) => a.p50Ms - b.p50Ms);
+ const leader = providers[0];
+ const best: RpcRegionBest = {
+ provider: leader.provider,
+ providerName: leader.name,
+ p50Ms: leader.p50Ms,
+ };
+ const scoped: RpcHubChain = {
+ ...c,
+ providers,
+ providerCount: providers.length,
+ best,
+ regions: { [region]: best },
+ };
+ // The chain's headline tails belong to the 3-region leader; under a
+ // region scope they are the regional leader's own, or nothing.
+ if (leader.p90Ms != null) scoped.bestP90Ms = leader.p90Ms;
+ else delete scoped.bestP90Ms;
+ if (leader.p95Ms != null) scoped.bestP95Ms = leader.p95Ms;
+ else delete scoped.bestP95Ms;
+ if (c.best?.provider !== leader.provider) delete scoped.series;
+ chains.push(scoped);
+ }
+ const providersPivot = buildPivot(chains);
+ return {
+ ...view,
+ chains,
+ providersPivot,
+ totals: {
+ chains: chains.length,
+ uniqueProviders: providersPivot.length,
+ regions: 1,
+ },
+ };
+}
diff --git a/src/lib/rpc-hub-stats.ts b/src/lib/rpc-hub-stats.ts
index 8f3ccf079..90ca090fb 100644
--- a/src/lib/rpc-hub-stats.ts
+++ b/src/lib/rpc-hub-stats.ts
@@ -34,16 +34,36 @@ import { loadSnapshotFromBlob } from "@/lib/bench-blob";
import { chainLabelForSlug } from "@/lib/chains";
import { matchesChainSlug } from "@/lib/chain-aliases";
import type { Benchmark, ProviderResult } from "@/types/benchmark";
+import {
+ RPC_REGION_KEYS,
+ buildPivot,
+ round1,
+ round2,
+ type RpcHubChain,
+ type RpcHubCohort,
+ type RpcHubSnapshot,
+ type RpcRegionBest,
+ type RpcRegionKey,
+} from "@/lib/rpc-hub-shape";
+
+export { RPC_REGION_KEYS };
+export {
+ RPC_REGION_LABELS,
+ buildPivot,
+ scopeCohortToRegion,
+} from "@/lib/rpc-hub-shape";
+export type {
+ RpcHubChain,
+ RpcHubCohort,
+ RpcHubPivotRow,
+ RpcHubProvider,
+ RpcHubSnapshot,
+ RpcHubUnresponsiveProvider,
+ RpcRegionBest,
+ RpcRegionKey,
+} from "@/lib/rpc-hub-shape";
import type { Spec } from "@/lib/spec-schema";
-export type RpcRegionKey = "us-east" | "eu-west" | "sgp";
-
-export const RPC_REGION_KEYS: readonly RpcRegionKey[] = [
- "us-east",
- "eu-west",
- "sgp",
-] as const;
-
/** `-rpc`-suffixed bench slugs that are NOT chain leaderboards and must
* never be pivoted into the /rpc cross-chain matrix. Right now this is
* bench 074 mev-protect-rpc, which measures MEV-protection endpoints
@@ -57,113 +77,6 @@ export const NON_CHAIN_RPC_SLUGS: ReadonlySet = new Set([
"mev-protect-rpc",
]);
-export type RpcRegionBest = {
- provider: string;
- providerName: string;
- p50Ms: number;
-};
-
-export type RpcHubProvider = {
- provider: string;
- name: string;
- p50Ms: number;
- p90Ms?: number;
- p99Ms?: number;
- successPct?: number;
- sampleSize?: number;
- /** p50 per probe region, keyed by region value. */
- regions: Partial>;
- /** Archive block depths this provider supports (subset of the
- * harness's `depthBuckets` = {300, 7200, 216000, 1296000, 5000000}).
- * Absent = not measured (Solana / Substrate chains skip archive)
- * or not yet propagated by the worker. */
- archiveDepth?: { supportedBlocks: number[] };
-};
-
-export type RpcHubUnresponsiveProvider = {
- provider: string;
- name: string;
- /** Success rate over 24h (%, 2 decimals). Call counters keep
- * recording through an outage, so this stays meaningful. */
- successPct?: number;
- sampleSize?: number;
-};
-
-export type RpcHubChain = {
- /** Chain slug, e.g. "ethereum". */
- chain: string;
- /** Bench slug, e.g. "ethereum-rpc". */
- slug: string;
- /** Chain display label from the chain registry ("Ethereum"). */
- name: string;
- /** Bench display title (spec.title). */
- benchTitle: string;
- providerCount: number;
- /** Declared results in the spec (live or not); drives the hub link gate. */
- declaredCount?: number;
- /** Cohort providers currently flagged unresponsive on this chain
- * (probed, all calls failing, no latency). Never part of
- * best/fastest computations — display-only context. */
- unresponsiveCount?: number;
- /** The unresponsive rows themselves (never ranked). Additive optional
- * field: old blobs without it still parse. */
- unresponsive?: RpcHubUnresponsiveProvider[];
- best: RpcRegionBest | null;
- /** p90 of the overall best provider (same row as `best`). Optional so old blobs stay parseable. */
- bestP90Ms?: number;
- /** Max archive block depth supported across ALL providers on this
- * chain (0 or absent = archive not measured / all providers pruned).
- * Paired with bestArchiveProviders which names the providers hitting
- * that max depth — the tooltip in the /rpc hub reads both. */
- bestArchiveDepthBlocks?: number;
- /** Providers whose supported set includes bestArchiveDepthBlocks
- * (i.e. the ones actually offering the max archive on this chain).
- * Empty when bestArchiveDepthBlocks is undefined. */
- bestArchiveProviders?: string[];
- /** Best provider per probe region. */
- regions: Partial>;
- /** Full live provider field, sorted fastest first. */
- providers: RpcHubProvider[];
- /** Leader's 24h latency series, downsampled to <=48 points. */
- series?: number[];
- dataConfidence?: string;
- updatedAt: string;
-};
-
-export type RpcHubPivotRow = {
- provider: string;
- name: string;
- chainsCovered: number;
- medianRank: number;
- medianP50Ms: number;
- /** Median 24h success rate across covered chains (%, 2 decimals).
- * Optional: absent from older blobs and success-less rows. */
- medianSuccessPct?: number;
- /** Failed probes over 24h summed across covered chains:
- * Σ round(sampleSize × (1 − successPct/100)). Optional (needs
- * sampleSize; absent from older blobs). */
- errors24h?: number;
- chains: Record<
- string,
- { p50Ms: number; rank: number; successPct?: number; sampleSize?: number }
- >;
-};
-
-export type RpcHubCohort = {
- chains: RpcHubChain[];
- providersPivot: RpcHubPivotRow[];
- totals: { chains: number; uniqueProviders: number; regions: number };
-};
-
-export type RpcHubSnapshot = RpcHubCohort & {
- /** The API-key cohort (bench blobs at sig `tier=keyed`): the chains
- * whose spec declares a keyed tier, ranked on their own. Absent on
- * blobs written before the tier dimension existed and when no keyed
- * blob resolves yet; the hub then shows the public view alone. */
- keyed?: RpcHubCohort;
- generatedAt: string;
-};
-
/** Upstash key for the rpc-hub cohort blob, written by the materialize
* worker after every tier sweep. The cohort-snapshot module appends
* its own `:v1`. */
@@ -181,18 +94,23 @@ export type RpcHubSnapshot = RpcHubCohort & {
// site reads the worker's snapshot again instead of rebuilding on
// every cache miss: the two had drifted apart at v2 ("rpc-hub" on
// the worker side, "rpc-hub-v2" here).
-export const RPC_HUB_KEY = "rpc-hub-v3";
-
-const round1 = (v: number) => Math.round(v * 10) / 10;
-const round2 = (v: number) => Math.round(v * 100) / 100;
-
-function median(values: number[]): number {
- const sorted = [...values].sort((a, b) => a - b);
- const mid = Math.floor(sorted.length / 2);
- return sorted.length % 2 === 0
- ? (sorted[mid - 1] + sorted[mid]) / 2
- : sorted[mid];
-}
+// v4: `keyedCold` cohort (the cold-start arm reaches the hub). Same reasoning
+// as v3: a v3 blob from an older worker has no keyedCold field, so the
+// third pill would stay hidden until the worker rebuild lands. A fresh key
+// makes Vercel rebuild from the bench blobs, which already carry the
+// `tier=keyed-cold` variants, and the worker writes this same constant.
+// v5: `websocket` cohort (benches 296-304 reach the hub). Same reasoning as
+// v3 and v4: a v4 blob from an older worker has no websocket field, so
+// the pill would stay hidden for a full TTL behind a blob that can never
+// grow it. The worker imports this constant and writes the new shape
+// after its next rebuild.
+// v6: `p95Ms` on the providers, `bestP95Ms` on the chains and `regionTails`
+// carrying each region variant's own p90/p95/p99. Same reasoning once
+// more: a v5 blob has none of those fields, so the p95 column and the
+// geo view's per-region tails would stay empty behind a blob that
+// cannot grow them, for a full TTL and then for as long as the worker
+// kept rewriting v5.
+export const RPC_HUB_KEY = "rpc-hub-v6";
/** Cap a series at `max` points so 10 chains of sparklines cannot bloat
* the cohort blob (Upstash + unstable_cache 2 MB ceilings). */
@@ -247,6 +165,13 @@ async function buildChain(spec: Spec, tier?: string): Promise"), whose headline p50
// is the region-scoped aggregate for each provider.
const regionP50: Record>> = {};
+ // The region variant's headline queries already carry region="", so its
+ // p90/p95/p99 are that region's tails and not a 3-region figure. Read them
+ // here so the hub can publish a tail per edge location.
+ const regionTails: Record<
+ string,
+ Partial>
+ > = {};
for (const r of rows) {
const points = bench.extras.regions?.[r.slug] ?? [];
for (const pt of points) {
@@ -272,6 +197,14 @@ async function buildChain(spec: Spec, tier?: string): Promise 0) tail.p90 = round1(r.ms.p90);
+ if (r.ms.p95 != null && Number.isFinite(r.ms.p95) && r.ms.p95 > 0)
+ tail.p95 = round1(r.ms.p95);
+ if (Number.isFinite(r.ms.p99) && r.ms.p99 > 0) tail.p99 = round1(r.ms.p99);
+ if (Object.keys(tail).length > 0) (regionTails[r.slug] ??= {})[region] = tail;
}
}
@@ -290,7 +223,10 @@ async function buildChain(spec: Spec, tier?: string): Promise 0 ? round1(leader.ms.p90) : undefined,
+ bestP95Ms:
+ leader.ms.p95 != null && Number.isFinite(leader.ms.p95) && leader.ms.p95 > 0
+ ? round1(leader.ms.p95)
+ : undefined,
...(() => {
// Max archive depth across ALL providers + which providers hit it.
// Fastest provider is often the pruned one (PublicNode etc), so
@@ -351,6 +291,10 @@ async function buildChain(spec: Spec, tier?: string): Promise 0 ? round1(r.ms.p90) : undefined,
+ p95Ms:
+ r.ms.p95 != null && Number.isFinite(r.ms.p95) && r.ms.p95 > 0
+ ? round1(r.ms.p95)
+ : undefined,
p99Ms: Number.isFinite(r.ms.p99) && r.ms.p99 > 0 ? round1(r.ms.p99) : undefined,
archiveDepth: r.archiveDepth,
successPct:
@@ -359,6 +303,7 @@ async function buildChain(spec: Spec, tier?: string): Promise 1
@@ -369,64 +314,6 @@ async function buildChain(spec: Spec, tier?: string): Promise();
- for (const c of chains) {
- c.providers.forEach((p, i) => {
- const entry =
- acc.get(p.provider) ?? { name: p.name, chains: {} };
- entry.chains[c.chain] = {
- p50Ms: p.p50Ms,
- rank: i + 1,
- ...(p.successPct != null ? { successPct: p.successPct } : {}),
- ...(p.sampleSize != null ? { sampleSize: p.sampleSize } : {}),
- };
- acc.set(p.provider, entry);
- });
- }
- const rows: RpcHubPivotRow[] = [...acc.entries()].map(
- ([provider, { name, chains: perChain }]) => {
- const cells = Object.values(perChain);
- // Reliability aggregates over covered chains only. Success is a
- // median (robust to one bad chain); errors are an absolute sum of
- // failed probes, same derivation as the ledger's Errors column.
- const successes = cells
- .map((c) => c.successPct)
- .filter((v): v is number => v != null);
- const errorCells = cells.filter(
- (c) => c.sampleSize != null && c.successPct != null,
- );
- const errors24h = errorCells.reduce(
- (sum, c) =>
- sum + Math.round((c.sampleSize as number) * (1 - (c.successPct as number) / 100)),
- 0,
- );
- return {
- provider,
- name,
- chainsCovered: cells.length,
- medianRank: round1(median(cells.map((c) => c.rank))),
- medianP50Ms: round1(median(cells.map((c) => c.p50Ms))),
- ...(successes.length > 0
- ? { medianSuccessPct: round2(median(successes)) }
- : {}),
- ...(errorCells.length > 0 ? { errors24h } : {}),
- chains: perChain,
- };
- },
- );
- // Coverage first (the pivot's whole point), then rank quality.
- return rows.sort(
- (a, b) =>
- b.chainsCovered - a.chainsCovered ||
- a.medianRank - b.medianRank ||
- a.medianP50Ms - b.medianP50Ms,
- );
-}
-
/**
* Build the rpc-hub snapshot from the worker-published bench blobs.
* Store reads only, zero Prom queries — safe to call from both the
@@ -478,10 +365,29 @@ export async function buildRpcHubSnapshotFresh(): Promise
(s.dimensions?.tier ?? []).some((t) => t.value === "keyed"),
);
const keyed = keyedSpecs.length > 0 ? await cohortFor(keyedSpecs, "keyed") : null;
+ // The cold-start arm, same shape, read from the `tier=keyed-cold` variant
+ // blobs. Selected on its own tier value rather than reusing keyedSpecs: a
+ // spec can declare the keyed tier without declaring the cold one, which is
+ // exactly the state the six Validation Cloud chains were in.
+ const keyedColdSpecs = rpcSpecs.filter((s) =>
+ (s.dimensions?.tier ?? []).some((t) => t.value === "keyed-cold"),
+ );
+ const keyedCold =
+ keyedColdSpecs.length > 0 ? await cohortFor(keyedColdSpecs, "keyed-cold") : null;
+
+ // The WebSocket race. Its own bench family, so it is selected from the spec
+ // list directly rather than from rpcSpecs, which only holds slugs ending in
+ // "-rpc" and therefore excludes these by construction.
+ const wsSpecs = specs
+ .filter((s) => s.slug.endsWith("-rpc-ws"))
+ .sort((a, b) => a.slug.localeCompare(b.slug));
+ const websocket = wsSpecs.length > 0 ? await cohortFor(wsSpecs) : null;
return {
...publicCohort,
...(keyed ? { keyed } : {}),
+ ...(keyedCold ? { keyedCold } : {}),
+ ...(websocket ? { websocket } : {}),
generatedAt: new Date().toISOString(),
};
}
@@ -515,7 +421,12 @@ function gateSnapshotForProd(snap: RpcHubSnapshot): RpcHubSnapshot {
};
};
const gated = gateCohort(snap);
- return snap.keyed ? { ...gated, keyed: gateCohort(snap.keyed) } : gated;
+ return {
+ ...gated,
+ ...(snap.keyed ? { keyed: gateCohort(snap.keyed) } : {}),
+ ...(snap.keyedCold ? { keyedCold: gateCohort(snap.keyedCold) } : {}),
+ ...(snap.websocket ? { websocket: gateCohort(snap.websocket) } : {}),
+ };
}
async function fetchRpcHubRaw(): Promise {
@@ -558,7 +469,12 @@ const fetchRpcHubCached = unstable_cache(
// v8 (2026-09-30): lockstep with bench-unfiltered-v88. The keyed cohort on
// the nine chain RPC benches gains GetBlock, and this snapshot holds the
// per-chain keyed provider list the hub and /products read.
- ["rpc-hub-cohort-v8", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ // v9: the snapshot carries the cold-start cohort, so a v8 entry cached
+ // before this change would hide the third pill for its whole TTL.
+ // v10: the snapshot carries the WebSocket cohort.
+ // v11: the snapshot carries p95 and the per-region tails; a v10 entry would
+ // serve a shape without them for its whole TTL.
+ ["rpc-hub-cohort-v11", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["rpc-cohort"] },
);
@@ -599,5 +515,9 @@ export async function hasRpcProviderData(slug: string): Promise {
const inCohort = (c: RpcHubChain) =>
c.providers.some((p) => p.provider === slug) ||
c.unresponsive?.some((u) => u.provider === slug);
+ // Deliberately not keyedCold: it carries the same vendors on the same
+ // chains under `-cold` slugs, which providers.ts folds onto the
+ // vendor. Counting it would add no (provider, chain) pair that `keyed`
+ // does not already have, and would hand a KPI pill to a measurement arm.
return snapshot.chains.some(inCohort) || (snapshot.keyed?.chains.some(inCohort) ?? false);
}
diff --git a/src/lib/seo-text.test.ts b/src/lib/seo-text.test.ts
index ef79da798..e132372a9 100644
--- a/src/lib/seo-text.test.ts
+++ b/src/lib/seo-text.test.ts
@@ -66,3 +66,36 @@ test("capSnippet: a stray closing parenthesis costs no character", () => {
expect(out.startsWith("Alpha) beta")).toBe(true);
expect(out.length).toBeLessThanOrEqual(155);
});
+
+describe("capSnippet uses the budget it is given", () => {
+ // The shape that shipped 62 characters of a 155 allowance: a short
+ // opening sentence, then a longer one carrying the detail. Two of the
+ // WebSocket pages lost the second sentence whole, and with it the only
+ // mention of the five providers being compared.
+ const two =
+ "QuickNode pushes BNB Chain blocks first on the private cohort. " +
+ "Per-block WebSocket lag for Chainstack, GetBlock, QuickNode, Dwellir " +
+ "and Validation Cloud, measured from 3 regions.";
+
+ test("does not cut at a sentence end far below the cap", () => {
+ const out = capSnippet(two);
+ expect(out.length).toBeGreaterThan(100);
+ expect(out.length).toBeLessThanOrEqual(156);
+ expect(out).toContain("Chainstack");
+ });
+
+ test("still cuts at the sentence end when that lands near the cap", () => {
+ const s =
+ "Chainstack leads this chain at 2.1 ms over 24 hours across three probe regions worldwide today. " +
+ "A second sentence, long enough that keeping it would overflow the budget entirely.";
+ expect(capSnippet(s).endsWith("today.")).toBe(true);
+ });
+
+ test("never exceeds the cap and never ends on punctuation noise", () => {
+ for (const n of [160, 200, 400]) {
+ const out = capSnippet("word, ".repeat(n));
+ expect(out.length).toBeLessThanOrEqual(156);
+ expect(out).not.toMatch(/[,;:\s]\.$/);
+ }
+ });
+});
diff --git a/src/lib/seo-text.ts b/src/lib/seo-text.ts
index 57ebde58e..a09210470 100644
--- a/src/lib/seo-text.ts
+++ b/src/lib/seo-text.ts
@@ -43,7 +43,14 @@ export function capSnippet(input: string | undefined, max = 155): string {
const abbrev = (t: string) => /(?:^|\s)(?:[A-Za-z]\.)+$/.test(t);
let lastDot = head.lastIndexOf(". ");
while (lastDot > 60 && abbrev(head.slice(0, lastDot + 1))) lastDot = head.lastIndexOf(". ", lastDot - 1);
- if (lastDot > 60) return head.slice(0, lastDot + 1);
+ // A sentence end is the best cut only when it lands near the budget. Two
+ // of the WebSocket pages shipped 62 characters of a 155 allowance because
+ // their opening sentence is short and the whole text crossed the cap by a
+ // few characters: the second sentence, which named all five providers,
+ // was dropped whole. Below this floor the clause and word cuts further
+ // down produce a snippet that uses the space, so fall through to them.
+ const SENTENCE_FLOOR = Math.round(max * 0.6);
+ if (lastDot > SENTENCE_FLOOR) return head.slice(0, lastDot + 1);
// The clause cut must not land inside a parenthesis: "(Orca pool, Jupiter
// route)" was cut at its inner comma and shipped as "(Orca pool." (RWA
// audit 2026-09-23). Walk back to the last ", " whose prefix has balanced
diff --git a/src/lib/sitemap-builder.ts b/src/lib/sitemap-builder.ts
index 697ded5b1..0b9d0cb0a 100644
--- a/src/lib/sitemap-builder.ts
+++ b/src/lib/sitemap-builder.ts
@@ -240,6 +240,8 @@ function staticHubRoutes(catalogTs: Date): MetadataRoute.Sitemap {
{ url: `${SITE.url}/docs`, lastModified: pageMtime("docs/page.tsx"), changeFrequency: "monthly", priority: 0.8 },
{ url: `${SITE.url}/contact`, lastModified: pageMtime("contact/page.tsx"), changeFrequency: "monthly", priority: 0.6 },
{ url: `${SITE.url}/fee-compare`, lastModified: pageMtime("fee-compare/page.tsx"), changeFrequency: "monthly", priority: 0.7 },
+ { url: `${SITE.url}/tools`, lastModified: pageMtime("tools/page.tsx"), changeFrequency: "monthly", priority: 0.7 },
+ { url: `${SITE.url}/tools/perp-cost`, lastModified: pageMtime("tools/perp-cost/page.tsx"), changeFrequency: "monthly", priority: 0.7 },
{ url: `${SITE.url}/methodology`, lastModified: pageMtime("methodology/page.tsx"), changeFrequency: "monthly", priority: 0.7 },
{ url: `${SITE.url}/contribute`, lastModified: pageMtime("contribute/page.tsx"), changeFrequency: "monthly", priority: 0.7 },
{ url: `${SITE.url}/partners`, lastModified: pageMtime("partners/page.tsx"), changeFrequency: "monthly", priority: 0.7 },
diff --git a/src/lib/spec-schema.ts b/src/lib/spec-schema.ts
index 32fcefe6f..b02668da4 100644
--- a/src/lib/spec-schema.ts
+++ b/src/lib/spec-schema.ts
@@ -75,6 +75,11 @@ const queries = z
.object({
p50: promql.optional(),
p90: promql.optional(),
+ /** Optional fifth quantile, added 2026-10-16. Unlike p50/p90/p99 it is
+ * never load-bearing: a provider whose p95 query returns nothing keeps
+ * its row and simply shows no p95, so adding it to one bench cannot
+ * make a leaderboard shrink. */
+ p95: promql.optional(),
p99: promql.optional(),
mean: promql.optional(),
success: promql.optional(),
diff --git a/src/types/benchmark.ts b/src/types/benchmark.ts
index ae3f22e84..3b34ef2c0 100644
--- a/src/types/benchmark.ts
+++ b/src/types/benchmark.ts
@@ -34,7 +34,10 @@ export type ProviderResult = {
* a bench, the bench page renders an L1/L2/All toggle pill above the
* ledger that filters rows by this field. */
layer?: ProviderLayer;
- ms: { p50: number; p90: number; p99: number; mean: number };
+ /** p95 is optional where the other four are not: it was added after
+ * hundreds of specs were written, and a bench that does not declare it
+ * must keep rendering exactly as before. */
+ ms: { p50: number; p90: number; p99: number; mean: number; p95?: number };
/** Optional slot-level companion to ms. Used by Solana-native benches
* where confirmation is a slot-level event (~400 ms granularity).
* When present, the providers table renders a "Slot delta" column
diff --git a/src/types/hl-traders.ts b/src/types/hl-traders.ts
new file mode 100644
index 000000000..7634e094c
--- /dev/null
+++ b/src/types/hl-traders.ts
@@ -0,0 +1,70 @@
+/**
+ * Shape of the trader-leaderboard audit snapshot written by the
+ * `hl-archive traders` collector (harnesses/hl-archive/script/traders_run.go).
+ *
+ * Field names mirror the Go struct tags exactly. The harness writes raw
+ * JSON under its own Upstash key rather than the `{ data, asOf }`
+ * envelope the cohort layer uses, because the payload already carries
+ * its own `updated_at`.
+ */
+
+/** Aggregates over every row of Hyperliquid's published leaderboard. */
+export type HlTradersIntegrity = {
+ accounts: number;
+ winners: number;
+ winners_pct: number;
+ agg_pnl_usd: number;
+ agg_equity_usd: number;
+ agg_vlm_usd: number;
+ /** Accounts publishing a non-zero PnL against exactly zero volume. */
+ zero_vlm_accounts: number;
+ zero_vlm_pnl_usd: number;
+ /** Can exceed 100: the losers net the denominator down. */
+ zero_vlm_pnl_pct: number;
+ top100_pnl_pct: number;
+ roi_outliers: number;
+ losers_pnl_usd: number;
+ /**
+ * Rows whose lifetime window was absent or unreadable. Expected zero.
+ * Non-zero means the published blob's shape moved, and every other
+ * figure here needs re-reading before it is quoted.
+ */
+ malformed_rows: number;
+};
+
+/**
+ * Perp vs non-perp split from the info API, over the highest-PnL
+ * accounts only. `sampled` travels with the percentage because the
+ * sample is biased by construction toward the zero-volume cohort, so the
+ * figure describes the top of the table and never the population.
+ */
+export type HlTradersDecomposition = {
+ sampled: number;
+ failed: number;
+ pnl_usd: number;
+ perp_pnl_usd: number;
+ non_perp_pnl_usd: number;
+ non_perp_pnl_pct: number;
+ accounts_with_non_perp_pnl: number;
+};
+
+export type HlTraderRow = {
+ address: string;
+ equity_usd: number;
+ pnl_usd: number;
+ roi: number;
+ vlm_usd: number;
+ pnl_day_usd: number;
+ /** Claims PnL on zero volume. Marked in place, never dropped. */
+ no_volume: boolean;
+};
+
+export type HlTradersSnapshot = {
+ updated_at: string;
+ source: string;
+ integrity: HlTradersIntegrity;
+ decomposition: HlTradersDecomposition;
+ /** Ordered by all-time PnL, capped at `row_cap`. */
+ top: HlTraderRow[];
+ row_cap: number;
+};
diff --git a/worker/index.ts b/worker/index.ts
index 45dc06857..368595b24 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -25,6 +25,7 @@ import {
filterSig,
type BenchmarkFilters,
} from "@/lib/materialize/load";
+import { mergeWithPrevious } from "./merge";
import {
MAT_SCHEMA_VERSION,
RING_CADENCE,
@@ -75,107 +76,7 @@ const VARIANT_EVERY = Number(process.env.VARIANT_EVERY ?? 5);
* query semaphore is global, so this only bounds per-bench overhead. */
const BENCH_CONCURRENCY = Number(process.env.BENCH_CONCURRENCY ?? 3);
-function ringFromSeries(
- window: keyof typeof RING_CADENCE,
- series: (number | null)[],
- now: number,
-): SeriesRing {
- const { stepSec } = RING_CADENCE[window];
- return {
- startEpoch: now - series.length * stepSec * 1000,
- stepSec,
- points: series,
- };
-}
-
-/** Merge a fresh live load with the previous snapshot: carried-forward
- * providers keep last good values/series, marked via meta. */
-function mergeWithPrevious(
- fresh: Benchmark,
- prev: MaterializedSnapshot | null,
- now: number,
- declaredSlugs?: Set,
-): { bench: Benchmark; state: WorkerState } {
- const state: WorkerState = { providers: {}, rings: {} };
- const prevState = prev?.state;
- const prevBench = prev?.bench;
-
- const liveSlugs = new Set(fresh.results.map((r) => r.slug));
-
- // Fresh providers: stamp meta, refresh ring state from live series.
- for (const r of fresh.results) {
- r.meta = { observedAt: now };
- state.providers[r.slug] = r.meta;
- const rings: Record = {};
- const windows = [
- ["24h", fresh.extras.series24h?.[r.slug]],
- ["7d", fresh.extras.series7d?.[r.slug]],
- ["30d", fresh.extras.series30d?.[r.slug]],
- ] as const;
- for (const [w, s] of windows) {
- if (s && s.length > 0) rings[w] = ringFromSeries(w, s, now);
- }
- if (Object.keys(rings).length > 0) state.rings[r.slug] = rings;
- }
- // Carried providers: present in the previous snapshot, absent from this
- // sweep's live load. Keep values + series, set staleSince once.
- if (prevBench) {
- for (const pr of prevBench.results) {
- if (liveSlugs.has(pr.slug)) continue;
- // Deliberately delisted providers (removed from the spec) must NOT
- // be carried: the staleness carry exists for transient query
- // failures, not for editorial removals (1RPC ghost row, 2026-07-09).
- if (declaredSlugs && !declaredSlugs.has(pr.slug)) continue;
- const prevMeta: StalenessMeta = prevState?.providers[pr.slug] ?? {
- observedAt: prev?.builtAt ?? now,
- };
- const meta: StalenessMeta = {
- observedAt: prevMeta.observedAt,
- staleSince: prevMeta.staleSince ?? now,
- };
- fresh.results.push({ ...pr, meta });
- state.providers[pr.slug] = meta;
- const prevRings = prevState?.rings[pr.slug];
- if (prevRings) {
- state.rings[pr.slug] = prevRings;
- const s24 = prevRings["24h"]?.points;
- const s7 = prevRings["7d"]?.points;
- const s30 = prevRings["30d"]?.points;
- if (s24) (fresh.extras.series24h ??= {})[pr.slug] = s24 as number[];
- if (s7) (fresh.extras.series7d ??= {})[pr.slug] = s7 as number[];
- if (s30) (fresh.extras.series30d ??= {})[pr.slug] = s30 as number[];
- }
- }
-
- // Panels: carry per-provider values the same way.
- for (const panel of fresh.metricPanels ?? []) {
- const prevPanel = (prevBench.metricPanels ?? []).find(
- (p: MetricPanel) => p.id === panel.id,
- );
- if (!prevPanel) continue;
- panel.valuesMeta ??= {};
- for (const slug of Object.keys(panel.values)) {
- panel.valuesMeta[slug] = { observedAt: now };
- }
- for (const [slug, v] of Object.entries(prevPanel.values)) {
- if (panel.values[slug] != null) continue;
- const pm = prevPanel.valuesMeta?.[slug];
- panel.values[slug] = v;
- panel.valuesMeta[slug] = {
- observedAt: pm?.observedAt ?? prev?.builtAt ?? now,
- staleSince: pm?.staleSince ?? now,
- };
- if (prevPanel.seriesByProvider?.[slug] && panel.seriesByProvider) {
- panel.seriesByProvider[slug] ??= prevPanel.seriesByProvider[slug];
- }
- }
- }
- }
-
- fresh.dataAsOf = now;
- return { bench: fresh, state };
-}
async function materializeOne(
spec: Spec,
@@ -204,7 +105,13 @@ async function materializeOne(
}
const now = Date.now();
- const { bench, state } = mergeWithPrevious(fresh, prev, now, new Set(spec.providers.map((p) => p.slug)));
+ const { bench, state } = mergeWithPrevious(
+ fresh,
+ prev,
+ now,
+ new Set(spec.providers.map((p) => p.slug)),
+ spec.prometheus?.expected_freshness_seconds,
+ );
const snap: MaterializedSnapshot = {
v: MAT_SCHEMA_VERSION,
diff --git a/worker/merge.ts b/worker/merge.ts
new file mode 100644
index 000000000..7f8819f5b
--- /dev/null
+++ b/worker/merge.ts
@@ -0,0 +1,150 @@
+import type { Benchmark, MetricPanel } from "@/types/benchmark";
+import {
+ RING_CADENCE,
+ type MaterializedSnapshot,
+ type SeriesRing,
+ type StalenessMeta,
+ type WorkerState,
+} from "@/lib/materialize/schema";
+
+/**
+ * Snapshot merge, split out of worker/index.ts so it can be tested.
+ *
+ * index.ts refuses to load without KV credentials, by design: a worker with
+ * nowhere to publish should not boot. That also made this merge unreachable
+ * from a test, which is how an unbounded carry-forward shipped.
+ */
+
+function ringFromSeries(
+ window: keyof typeof RING_CADENCE,
+ series: (number | null)[],
+ now: number,
+): SeriesRing {
+ const { stepSec } = RING_CADENCE[window];
+ return {
+ startEpoch: now - series.length * stepSec * 1000,
+ stepSec,
+ points: series,
+ };
+}
+
+/** Merge a fresh live load with the previous snapshot: carried-forward
+ * providers keep last good values/series, marked via meta. */
+/** How long a provider may be carried after the harness stops publishing it.
+ *
+ * The carry exists for transient query failures. With no ceiling it also
+ * survives a catalogue correction, and the page then goes on ranking a row
+ * the harness deliberately withdrew — as availability "live" at a 100%
+ * success rate, because the ledger never reads `meta`.
+ *
+ * Bench 283 had three tabs in that state at once. OnFinality led the Solana
+ * tab 22 hours after its withdrawal and dRPC sat second 115 hours after,
+ * pushing the real leader to third. Ankr and InstaNodes held second and
+ * third on the trace tab for a workload their own plans refuse, under a
+ * methodology promising a refusal "shows as its absence from the trace tab".
+ *
+ * A bench's own `expected_freshness_seconds` is the right ceiling: it is the
+ * window the bench already claims its data is good for. The fallback is a
+ * day — generous, but finite, which "never" was not. */
+const DEFAULT_MAX_CARRY_SEC = 24 * 60 * 60;
+
+export function mergeWithPrevious(
+ fresh: Benchmark,
+ prev: MaterializedSnapshot | null,
+ now: number,
+ declaredSlugs?: Set,
+ maxCarrySec?: number,
+): { bench: Benchmark; state: WorkerState } {
+ const carryMs = (maxCarrySec ?? DEFAULT_MAX_CARRY_SEC) * 1000;
+ const state: WorkerState = { providers: {}, rings: {} };
+ const prevState = prev?.state;
+ const prevBench = prev?.bench;
+
+ const liveSlugs = new Set(fresh.results.map((r) => r.slug));
+
+ // Fresh providers: stamp meta, refresh ring state from live series.
+ for (const r of fresh.results) {
+ r.meta = { observedAt: now };
+ state.providers[r.slug] = r.meta;
+ const rings: Record = {};
+ const windows = [
+ ["24h", fresh.extras.series24h?.[r.slug]],
+ ["7d", fresh.extras.series7d?.[r.slug]],
+ ["30d", fresh.extras.series30d?.[r.slug]],
+ ] as const;
+ for (const [w, s] of windows) {
+ if (s && s.length > 0) rings[w] = ringFromSeries(w, s, now);
+ }
+ if (Object.keys(rings).length > 0) state.rings[r.slug] = rings;
+ }
+
+ // Carried providers: present in the previous snapshot, absent from this
+ // sweep's live load. Keep values + series, set staleSince once.
+ if (prevBench) {
+ for (const pr of prevBench.results) {
+ if (liveSlugs.has(pr.slug)) continue;
+ // Deliberately delisted providers (removed from the spec) must NOT
+ // be carried: the staleness carry exists for transient query
+ // failures, not for editorial removals (1RPC ghost row, 2026-07-09).
+ if (declaredSlugs && !declaredSlugs.has(pr.slug)) continue;
+ const prevMeta: StalenessMeta = prevState?.providers[pr.slug] ?? {
+ observedAt: prev?.builtAt ?? now,
+ };
+ // Past the ceiling this is no longer a transient gap, it is a
+ // withdrawal. Dropping the row is what makes the harness's decision
+ // visible on the page.
+ if (now - prevMeta.observedAt > carryMs) continue;
+ const meta: StalenessMeta = {
+ observedAt: prevMeta.observedAt,
+ staleSince: prevMeta.staleSince ?? now,
+ };
+ fresh.results.push({ ...pr, meta });
+ state.providers[pr.slug] = meta;
+ const prevRings = prevState?.rings[pr.slug];
+ if (prevRings) {
+ state.rings[pr.slug] = prevRings;
+ const s24 = prevRings["24h"]?.points;
+ const s7 = prevRings["7d"]?.points;
+ const s30 = prevRings["30d"]?.points;
+ if (s24) (fresh.extras.series24h ??= {})[pr.slug] = s24 as number[];
+ if (s7) (fresh.extras.series7d ??= {})[pr.slug] = s7 as number[];
+ if (s30) (fresh.extras.series30d ??= {})[pr.slug] = s30 as number[];
+ }
+ }
+
+ // Panels: carry per-provider values the same way.
+ for (const panel of fresh.metricPanels ?? []) {
+ const prevPanel = (prevBench.metricPanels ?? []).find(
+ (p: MetricPanel) => p.id === panel.id,
+ );
+ if (!prevPanel) continue;
+ panel.valuesMeta ??= {};
+ for (const slug of Object.keys(panel.values)) {
+ panel.valuesMeta[slug] = { observedAt: now };
+ }
+ for (const [slug, v] of Object.entries(prevPanel.values)) {
+ if (panel.values[slug] != null) continue;
+ // The same two guards as the result carry above. Without them a
+ // panel value outlived its provider: bench 282 kept serving
+ // Instanodes' per-million price hours after it left the spec, and a
+ // Solana break-even for two Ethereum-only nodes 265 h after the
+ // harness stopped emitting it (audit 2026-10-16).
+ if (declaredSlugs && !declaredSlugs.has(slug)) continue;
+ const pm = prevPanel.valuesMeta?.[slug];
+ const observedAt = pm?.observedAt ?? prev?.builtAt ?? now;
+ if (now - observedAt > carryMs) continue;
+ panel.values[slug] = v;
+ panel.valuesMeta[slug] = {
+ observedAt,
+ staleSince: pm?.staleSince ?? now,
+ };
+ if (prevPanel.seriesByProvider?.[slug] && panel.seriesByProvider) {
+ panel.seriesByProvider[slug] ??= prevPanel.seriesByProvider[slug];
+ }
+ }
+ }
+ }
+
+ fresh.dataAsOf = now;
+ return { bench: fresh, state };
+}
diff --git a/worker/stale-carry.test.ts b/worker/stale-carry.test.ts
new file mode 100644
index 000000000..c4975da23
--- /dev/null
+++ b/worker/stale-carry.test.ts
@@ -0,0 +1,175 @@
+import { describe, expect, test } from "bun:test";
+import { mergeWithPrevious } from "./merge";
+import type { Benchmark } from "@/types/benchmark";
+import type { MaterializedSnapshot } from "@/lib/materialize/schema";
+
+/**
+ * The carry-forward exists so a transient Prometheus failure does not blank a
+ * row. Without a ceiling it also survives a catalogue correction, and then the
+ * page goes on ranking something the harness has deliberately withdrawn.
+ *
+ * Bench 283 had three tabs in that state at once. OnFinality led the Solana
+ * tab 22 hours after its withdrawal and dRPC sat second 115 hours after,
+ * pushing the real leader to third. Ankr and InstaNodes held second and third
+ * on the trace tab, for a workload their own plans refuse, under a
+ * methodology that promises a refusal "shows as its absence from the trace
+ * tab". Every carried row rendered availability "live" at a 100% success
+ * rate, because the ledger never reads `meta`.
+ */
+const HOUR = 3600_000;
+
+const result = (slug: string, p50: number) =>
+ ({
+ slug,
+ name: slug,
+ ms: { p50, p90: p50, p99: p50, mean: p50 },
+ availability: "live",
+ successRate: 100,
+ }) as unknown as Benchmark["results"][number];
+
+const bench = (slugs: [string, number][]): Benchmark =>
+ ({
+ slug: "rpc-free-tier",
+ results: slugs.map(([s, v]) => result(s, v)),
+ extras: { series24h: {}, regions: {} },
+ }) as unknown as Benchmark;
+
+function prevSnapshot(
+ slugs: [string, number][],
+ observedAgoMs: Record,
+ now: number,
+): MaterializedSnapshot {
+ return {
+ bench: bench(slugs),
+ builtAt: now - HOUR,
+ state: {
+ providers: Object.fromEntries(
+ slugs.map(([s]) => [s, { observedAt: now - (observedAgoMs[s] ?? 0) }]),
+ ),
+ rings: {},
+ },
+ } as unknown as MaterializedSnapshot;
+}
+
+describe("carrying a provider the harness stopped publishing", () => {
+ const now = Date.now();
+ const declared = new Set(["syndica", "onfinality", "drpc"]);
+
+ test("keeps a row through a short gap, which is what the carry is for", () => {
+ const prev = prevSnapshot(
+ [["syndica", 10_000_000], ["onfinality", 12_000_000]],
+ { onfinality: 5 * 60_000 },
+ now,
+ );
+ const { bench: out } = mergeWithPrevious(
+ bench([["syndica", 10_000_000]]),
+ prev,
+ now,
+ declared,
+ 3 * 3600, // expected_freshness_seconds on this bench
+ );
+ expect(out.results.map((r: { slug: string }) => r.slug).sort()).toEqual([
+ "onfinality",
+ "syndica",
+ ]);
+ });
+
+ test("drops a row withdrawn longer ago than the bench's own freshness window", () => {
+ // OnFinality 22h stale, dRPC 115h stale: both were ranked above the real
+ // leader on the live board.
+ const prev = prevSnapshot(
+ [
+ ["syndica", 10_000_000],
+ ["onfinality", 12_000_000],
+ ["drpc", 10_500_000],
+ ],
+ { onfinality: 22 * HOUR, drpc: 115 * HOUR },
+ now,
+ );
+ const { bench: out } = mergeWithPrevious(
+ bench([["syndica", 10_000_000]]),
+ prev,
+ now,
+ declared,
+ 3 * 3600,
+ );
+ expect(out.results.map((r: { slug: string }) => r.slug)).toEqual(["syndica"]);
+ });
+
+ test("falls back to a finite ceiling when the spec declares no freshness", () => {
+ const prev = prevSnapshot(
+ [["syndica", 1], ["onfinality", 2]],
+ { onfinality: 48 * HOUR },
+ now,
+ );
+ const { bench: out } = mergeWithPrevious(
+ bench([["syndica", 1]]),
+ prev,
+ now,
+ declared,
+ undefined,
+ );
+ expect(out.results.map((r: { slug: string }) => r.slug)).toEqual(["syndica"]);
+ });
+
+ test("still refuses to carry a provider dropped from the spec entirely", () => {
+ // The pre-existing guard, kept: an editorial removal is not a gap.
+ const prev = prevSnapshot([["syndica", 1], ["ghost", 9]], { ghost: 60_000 }, now);
+ const { bench: out } = mergeWithPrevious(
+ bench([["syndica", 1]]),
+ prev,
+ now,
+ new Set(["syndica"]),
+ 3 * 3600,
+ );
+ expect(out.results.map((r: { slug: string }) => r.slug)).toEqual(["syndica"]);
+ });
+});
+
+describe("carrying a panel value the harness stopped publishing", () => {
+ const now = Date.now();
+ const withPanel = (
+ b: Benchmark,
+ values: Record,
+ meta?: Record,
+ ) =>
+ ({
+ ...b,
+ metricPanels: [{ id: "per_million", values, valuesMeta: meta }],
+ }) as unknown as Benchmark;
+
+ // Bench 282 served Instanodes' per-million price hours after it left the
+ // spec, and AWS AMB's Solana break-even 265 hours after the harness stopped
+ // emitting it: the panel carry had neither guard the row carry has.
+ test("drops a panel value for a provider no longer in the spec", () => {
+ const prev = prevSnapshot([["dwellir", 1]], {}, now);
+ prev.bench = withPanel(prev.bench, { dwellir: 2, instanodes: 7.9 }, {
+ dwellir: { observedAt: now - HOUR },
+ instanodes: { observedAt: now - HOUR },
+ });
+ const { bench: out } = mergeWithPrevious(
+ withPanel(bench([["dwellir", 1]]), { dwellir: 2 }),
+ prev,
+ now,
+ new Set(["dwellir"]),
+ 3 * 3600,
+ );
+ expect(Object.keys(out.metricPanels?.[0]?.values ?? {})).toEqual(["dwellir"]);
+ });
+
+ test("drops a panel value older than the carry ceiling, keeps a recent one", () => {
+ const prev = prevSnapshot([["dwellir", 1]], {}, now);
+ prev.bench = withPanel(prev.bench, { "aws-amb": 9.782, zeeve: 3 }, {
+ "aws-amb": { observedAt: now - 265 * HOUR },
+ zeeve: { observedAt: now - 10 * 60_000 },
+ });
+ const { bench: out } = mergeWithPrevious(
+ withPanel(bench([["dwellir", 1]]), {}),
+ prev,
+ now,
+ new Set(["dwellir", "aws-amb", "zeeve"]),
+ 3 * 3600,
+ );
+ expect(Object.keys(out.metricPanels?.[0]?.values ?? {})).toEqual(["zeeve"]);
+ });
+});