diff --git a/benchmarks/aggregator-head-lag.yml b/benchmarks/aggregator-head-lag.yml
index 1d53d822b..927d526a2 100644
--- a/benchmarks/aggregator-head-lag.yml
+++ b/benchmarks/aggregator-head-lag.yml
@@ -61,13 +61,13 @@ abstract: |
same event.
methodology:
- - "Aggregators measured: Mobula, Codex, GeckoTerminal."
+ - "Aggregators measured: Mobula, Codex, GeckoTerminal, Serialized."
- "Chains: Base, BNB Chain, Solana, Robinhood Chain."
- "Regions: us-east, eu-west, sgp. Cross-region median reported in the headline."
- "Reference: archive nodes per chain, validated against block hashes."
- "Metric: gauge `head_lag_seconds`, sampled every 15 seconds. Aggregated over the 24-hour window using `quantile_over_time`."
- "Success rate: presence ratio. Share of expected sampling slots where a value was actually emitted (5,760 expected per provider per day at the 15s cadence). 100% means the aggregator's feed was reachable for the full window."
- - "Cardinality: 3 aggregators × 4 chains × 3 regions = 36 active series."
+ - "Cardinality: 4 aggregators × 4 chains × 3 regions = 48 active series."
findings:
- "Cross-chain average puts {{best_name}} on top at {{best_p50}} (p50, 24h) across {{count}} providers. Per-chain leaders differ: Base {{best_name:chain:base}} at {{best_p50:chain:base}}, Solana {{best_name:chain:solana}} at {{best_p50:chain:solana}}, BNB {{best_name:chain:bnb}} at {{best_p50:chain:bnb}}, Robinhood {{best_name:chain:robinhood}} at {{best_p50:chain:robinhood}}. Chains differ in block cadence (Solana 400ms vs Base/BNB 2-3s), which shifts the floor independently of provider speed."
@@ -217,6 +217,30 @@ providers:
p50: avg by (aggregator) (quantile_over_time(0.50, head_lag_seconds{aggregator="codex", region="sgp"}[24h])) * 1000
series: avg_over_time(head_lag_seconds{aggregator="codex", region="sgp"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="codex", region="sgp"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="codex", region="sgp"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{region="sgp"}[15m])) > 5 and on(aggregator, region) avg_over_time(ws_connected{aggregator="codex", region="sgp", chain=""}[15m]) > 0.8)
+ - slug: serialized
+ name: Serialized
+ tag: WebSocket trades stream
+ formula: "Median seconds between an on-chain swap on the bench pools and the same trade arriving on Serialized's WebSocket trades stream (subscribed per pool by its token side), sampled every 15s over 24h."
+ queries:
+ p50: avg by (aggregator) (quantile_over_time(0.50, head_lag_seconds{aggregator="serialized"}[24h])) * 1000
+ p90: avg by (aggregator) (quantile_over_time(0.90, head_lag_seconds{aggregator="serialized"}[24h])) * 1000
+ p99: avg by (aggregator) (quantile_over_time(0.99, head_lag_seconds{aggregator="serialized"}[24h])) * 1000
+ mean: avg by (aggregator) (avg_over_time(head_lag_seconds{aggregator="serialized"}[24h])) * 1000
+ success: clamp_max(avg by (aggregator) (count_over_time(head_lag_seconds{aggregator="serialized"}[24h]) / 5760), 1)
+ sample_size: sum(count_over_time(head_lag_seconds{aggregator="serialized"}[24h]))
+ series: avg_over_time(head_lag_seconds{aggregator="serialized"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="serialized"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="serialized"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{}[15m])) > 5 and on(aggregator, region) avg_over_time(ws_connected{aggregator="serialized", chain=""}[15m]) > 0.8)
+ live_activity: sum(changes(head_lag_seconds{aggregator="serialized"}[15m]))
+ regions:
+ - region: us-east
+ p50: avg by (aggregator) (quantile_over_time(0.50, head_lag_seconds{aggregator="serialized", region="us-east"}[24h])) * 1000
+ series: avg_over_time(head_lag_seconds{aggregator="serialized", region="us-east"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="serialized", region="us-east"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="serialized", region="us-east"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{region="us-east"}[15m])) > 5 and on(aggregator, region) avg_over_time(ws_connected{aggregator="serialized", region="us-east", chain=""}[15m]) > 0.8)
+ - region: eu-west
+ p50: avg by (aggregator) (quantile_over_time(0.50, head_lag_seconds{aggregator="serialized", region="eu-west"}[24h])) * 1000
+ series: avg_over_time(head_lag_seconds{aggregator="serialized", region="eu-west"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="serialized", region="eu-west"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="serialized", region="eu-west"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{region="eu-west"}[15m])) > 5 and on(aggregator, region) avg_over_time(ws_connected{aggregator="serialized", region="eu-west", chain=""}[15m]) > 0.8)
+ - region: ap-southeast
+ p50: avg by (aggregator) (quantile_over_time(0.50, head_lag_seconds{aggregator="serialized", region="sgp"}[24h])) * 1000
+ series: avg_over_time(head_lag_seconds{aggregator="serialized", region="sgp"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="serialized", region="sgp"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="serialized", region="sgp"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{region="sgp"}[15m])) > 5 and on(aggregator, region) avg_over_time(ws_connected{aggregator="serialized", region="sgp", chain=""}[15m]) > 0.8)
+
- slug: geckoterminal
name: GeckoTerminal
tag: REST feed
diff --git a/benchmarks/asset-registry-coverage.yml b/benchmarks/asset-registry-coverage.yml
index c29a75d7f..b252b2001 100644
--- a/benchmarks/asset-registry-coverage.yml
+++ b/benchmarks/asset-registry-coverage.yml
@@ -95,6 +95,19 @@ providers:
sample_size: networks_supported_total{provider="coingecko"}
series: networks_supported_total{provider="coingecko"}
+ - slug: serialized
+ name: Serialized
+ tag: Own indexers, 18 EVM + Solana
+ formula: "Count of chains returned as live by Serialized's `/v1/meta/chains` endpoint, refreshed every 6 hours."
+ queries:
+ p50: networks_supported_total{provider="serialized"}
+ p90: networks_supported_total{provider="serialized"}
+ p99: networks_supported_total{provider="serialized"}
+ mean: networks_supported_total{provider="serialized"}
+ success: clamp_max(networks_supported_total{provider="serialized"} > bool 0, 1)
+ sample_size: networks_supported_total{provider="serialized"}
+ series: networks_supported_total{provider="serialized"}
+
- slug: coinpaprika
name: CoinPaprika
tag: Market-data API asset registry
diff --git a/benchmarks/bandchain-rpc.yml b/benchmarks/bandchain-rpc.yml
index 67a1c01ff..249bbb896 100644
--- a/benchmarks/bandchain-rpc.yml
+++ b/benchmarks/bandchain-rpc.yml
@@ -1,35 +1,138 @@
-id: 256
+# OpenChainBench. Bench No 256
+
slug: bandchain-rpc
-title: "Band Protocol RPC"
-chain: bandchain
-description: "Latency and availability benchmark for Band Protocol public RPC endpoints"
-category: rpc
-kind: cosmos
+number: "256"
+title: Fastest free Band Protocol RPC, live no-key endpoint latency
+seo_title: "Fastest free Band Protocol RPC 2026"
+seo_description: "{{best_name}} leads free Band Protocol RPC at {{best_p50}} (block height p50, 24h). 3 no-key providers measured every 60s from 3 regions."
+subtitle: HTTP round-trip latency for Tendermint /status queries against every available public Band Protocol (laozi-mainnet) endpoint, audited every 60 seconds from 3 regions.
-providers:
- - slug: band-official
- name: "Band Protocol"
- url: "http://rpc.laozi1.bandchain.org:80"
- - slug: highstakes
- name: "High Stakes"
- url: "https://bandprotocol-rpc.highstakes.ch"
- - slug: stakewolle
- name: "Stakewolle"
- url: "https://public.stakewolle.com/cosmos/bandchain/rpc"
+category: RPCs
+status: live
+metric: RPC latency
+unit: ms
+higher_is_better: false
seo_intro: |
- Band Protocol is a cross-chain oracle network built on Cosmos SDK that aggregates and connects real-world data and APIs to smart contracts. The laozi-mainnet hosts its decentralized data oracle infrastructure, enabling DeFi protocols across multiple blockchains to access tamper-proof price feeds.
+ Band Protocol is a cross-chain decentralized oracle network built on Cosmos SDK (chain ID laozi-mainnet). It aggregates and connects real-world data and APIs to smart contracts across multiple blockchains via its Yoda oracle daemon and data feed infrastructure. The chain uses the standard Tendermint RPC interface; block height is fetched via the /status endpoint. Public keyless RPC nodes are provided by the Band Protocol official team, High Stakes, and Stakewolle. Every provider was live-verified with consecutive block-height probes at launch.
- This benchmark continuously measures RPC latency, availability and block-height freshness across public Band Protocol Tendermint endpoints from three geographic regions. Use it to select the fastest endpoint for your integration or validator setup.
+abstract: |
+ Per-chain member of the RPC latency cluster, extended to Band Protocol (laozi-mainnet).
+ We measure the round-trip latency of a Tendermint /status query against
+ every available public Band Protocol endpoint that sustains continuous probing:
+ 3 providers at launch, every 60 seconds, from us-east, eu-west and
+ Singapore. Each provider is probed via a GET /status request
+ from which the block number is extracted.
+ The harness classifies every response (ok / http_err / jsonrpc_err / stale / timeout)
+ with a Cosmos-scaled staleness gap (40 blocks, around 4 min at 6 s/block).
+ The cross-chain view lives on the parent rpc-capabilities benchmark;
+ this page is the Band Protocol-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: GET /status. The result.sync_info.latest_block_height field is extracted as the current block height."
+ - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 10 s), so percentiles are computed via Prometheus quantile_over_time over the last 24 hours."
+ - "Call-result classification: ok (parsable block height returned), http_err, jsonrpc_err, stale (block more than 40 behind the cross-provider tip), timeout."
+ - "This page is part of the per-chain RPC cluster derived from the cross-chain rpc-capabilities benchmark; the identical harness, cadence and exclusion rules apply on every chain."
+ - "Chain scope: every query on this page is pinned to chain=bandchain. Provider coverage at launch: 3 endpoints (Band Protocol official, High Stakes, Stakewolle)."
+
+findings:
+ - "{{best_name}} leads free Band Protocol RPC at {{best_p50}} (Tendermint /status p50, 24h) across 3 measured providers."
faq:
- - q: "What does this benchmark measure?"
- a: "Each probe issues a GET /status request with anti-cache headers to retrieve the latest block height. We record round-trip latency (p50/p90/p99), HTTP availability, and whether the returned block height is current. Probes run every 30 seconds from US East, EU West, and AP Southeast."
- - q: "Which endpoints are included?"
- a: "The benchmark covers the Band Protocol official endpoint (rpc.laozi1.bandchain.org), High Stakes, and Stakewolle. All three are keyless public endpoints requiring no authentication."
- - q: "Why does Band Protocol RPC performance matter?"
- a: "Band Protocol validators and oracle scripts depend on reliable RPC access to submit data requests and retrieve oracle results. DeFi protocols integrating Band price feeds need low-latency RPC for real-time data consumption."
- - q: "How are oracle scripts affected by RPC latency?"
- a: "Yoda (the oracle daemon) and Bothan (data proxy) both rely on RPC to monitor pending data requests and submit responses within the request window. High latency or downtime directly reduces oracle reliability and can cause missed rewards."
- - q: "Can I contribute an endpoint?"
- a: "Yes. Open an issue or PR at github.com/ChainBench/OpenChainBench with the endpoint URL and operator name. We verify liveness and independence before adding."
+ - q: "What is the fastest free Band Protocol RPC right now?"
+ a: "{{best_name}} currently leads at {{best_p50}} (Band Protocol block height p50 over the last 24h), measured against 3 providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples."
+ - q: "Which Band Protocol RPC endpoints work without an API key?"
+ a: "3 endpoints sustain continuous keyless probing at launch: Band Protocol official (rpc.laozi1.bandchain.org), High Stakes (bandprotocol-rpc.highstakes.ch), and Stakewolle (public.stakewolle.com/cosmos/bandchain/rpc). Every listed endpoint was live-verified before inclusion."
+ - q: "What is Band Protocol and why does its RPC latency matter?"
+ a: "Band Protocol is a cross-chain data oracle that aggregates real-world data feeds and delivers them to smart contracts via oracle scripts. Validators running the Yoda daemon and DeFi protocols consuming Band price feeds both depend on low-latency reliable RPC access to the Band chain."
+ - q: "Does the fastest Band Protocol RPC change by region?"
+ a: "Yes. The official Band node and community validators are hosted in different regions. The region tabs re-scope every number to a single probe origin so you can pick the best endpoint for your deployment geography."
+ - q: "What is the BAND token?"
+ a: "BAND is the native staking and governance token of Band Protocol (denominated as uband on-chain). It is used to stake as validators, pay for oracle data requests, and participate in on-chain governance of the laozi-mainnet."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities
+
+prometheus:
+ window: 24h
+ freshness_metric: rpc_latency_milliseconds
+
+rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="bandchain"})
+
+dimensions:
+ region:
+ - { value: all, label: All regions }
+ - { value: us-east, label: US-East }
+ - { value: eu-west, label: EU-West }
+ - { value: sgp, label: Singapore }
+
+providers:
+ - slug: band-official
+ name: Band Protocol
+ tag: Band Protocol official public RPC node, no API key required
+ formula: "50th percentile over 24h of client-side round-trip latency (ms) for a GET /status sent every 60s from 3 regions to rpc.laozi1.bandchain.org."
+ queries:
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="band-official", chain="bandchain"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="band-official", chain="bandchain"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="band-official", chain="bandchain"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="band-official", chain="bandchain"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="band-official", chain="bandchain"}) / sum(ocb:rpc_call:rate_24h{provider="band-official", chain="bandchain"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="band-official", chain="bandchain"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="band-official", chain="bandchain"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="band-official", chain="bandchain", region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="band-official", chain="bandchain", region="us-east"}[1h])
+ - region: eu-west
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="band-official", chain="bandchain", region="eu-west"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="band-official", chain="bandchain", region="eu-west"}[1h])
+ - region: ap-southeast
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="band-official", chain="bandchain", region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="band-official", chain="bandchain", region="sgp"}[1h])
+
+ - slug: highstakes
+ name: High Stakes
+ tag: High Stakes public Band Protocol RPC, no API key required
+ formula: "50th percentile over 24h of client-side round-trip latency (ms) for a GET /status sent every 60s from 3 regions to bandprotocol-rpc.highstakes.ch."
+ queries:
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="highstakes", chain="bandchain"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="highstakes", chain="bandchain"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="highstakes", chain="bandchain"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="highstakes", chain="bandchain"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="highstakes", chain="bandchain"}) / sum(ocb:rpc_call:rate_24h{provider="highstakes", chain="bandchain"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="highstakes", chain="bandchain"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="highstakes", chain="bandchain"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="highstakes", chain="bandchain", region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="highstakes", chain="bandchain", region="us-east"}[1h])
+ - region: eu-west
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="highstakes", chain="bandchain", region="eu-west"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="highstakes", chain="bandchain", region="eu-west"}[1h])
+ - region: ap-southeast
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="highstakes", chain="bandchain", region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="highstakes", chain="bandchain", region="sgp"}[1h])
+
+ - slug: stakewolle
+ name: Stakewolle
+ tag: Stakewolle public Band Protocol RPC, no API key required
+ formula: "50th percentile over 24h of client-side round-trip latency (ms) for a GET /status sent every 60s from 3 regions to public.stakewolle.com/cosmos/bandchain/rpc."
+ queries:
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="stakewolle", chain="bandchain"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="stakewolle", chain="bandchain"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="stakewolle", chain="bandchain"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="stakewolle", chain="bandchain"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="stakewolle", chain="bandchain"}) / sum(ocb:rpc_call:rate_24h{provider="stakewolle", chain="bandchain"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="stakewolle", chain="bandchain"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="stakewolle", chain="bandchain"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="stakewolle", chain="bandchain", region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="stakewolle", chain="bandchain", region="us-east"}[1h])
+ - region: eu-west
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="stakewolle", chain="bandchain", region="eu-west"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="stakewolle", chain="bandchain", region="eu-west"}[1h])
+ - region: ap-southeast
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="stakewolle", chain="bandchain", region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="stakewolle", chain="bandchain", region="sgp"}[1h])
diff --git a/benchmarks/cheqd-rpc.yml b/benchmarks/cheqd-rpc.yml
index c5d028836..19752bf17 100644
--- a/benchmarks/cheqd-rpc.yml
+++ b/benchmarks/cheqd-rpc.yml
@@ -1,35 +1,138 @@
-id: 257
+# OpenChainBench. Bench No 257
+
slug: cheqd-rpc
-title: "cheqd RPC"
-chain: cheqd
-description: "Latency and availability benchmark for cheqd public RPC endpoints"
-category: rpc
-kind: cosmos
+number: "257"
+title: Fastest free cheqd RPC, live no-key endpoint latency
+seo_title: "Fastest free cheqd RPC 2026"
+seo_description: "{{best_name}} leads free cheqd RPC at {{best_p50}} (block height p50, 24h). 3 no-key providers measured every 60s from 3 regions."
+subtitle: HTTP round-trip latency for Tendermint /status queries against every available public cheqd (cheqd-mainnet-1) endpoint, audited every 60 seconds from 3 regions.
-providers:
- - slug: cheqd-official
- name: "cheqd"
- url: "https://rpc.cheqd.net"
- - slug: publicnode
- name: "PublicNode"
- url: "https://cheqd-rpc.publicnode.com:443"
- - slug: nodestake
- name: "NodeStake"
- url: "https://rpc.cheqd.nodestake.org"
+category: RPCs
+status: live
+metric: RPC latency
+unit: ms
+higher_is_better: false
seo_intro: |
- cheqd is a purpose-built Cosmos SDK blockchain for decentralized identity, enabling self-sovereign identity (SSI) and verifiable credentials at scale. The cheqd-mainnet-1 network anchors DIDs and credential schemas used by enterprises, governments, and developers building trust infrastructure.
+ cheqd is a purpose-built Cosmos SDK blockchain (chain ID cheqd-mainnet-1) for decentralized identity, enabling self-sovereign identity (SSI) and verifiable credentials at scale. The cheqd-mainnet-1 network anchors DIDs and credential schemas used by enterprises, governments, and developers building trust infrastructure. The chain uses the standard Tendermint RPC interface; block height is fetched via the /status endpoint. Public keyless RPC nodes are provided by the cheqd official team, PublicNode, and NodeStake. Every provider was live-verified with consecutive block-height probes at launch.
- This benchmark continuously measures RPC latency, availability and block-height freshness across public cheqd Tendermint endpoints from three geographic regions. Use it to select the most reliable endpoint for identity resolution, node operation, or application integration.
+abstract: |
+ Per-chain member of the RPC latency cluster, extended to cheqd (cheqd-mainnet-1).
+ We measure the round-trip latency of a Tendermint /status query against
+ every available public cheqd endpoint that sustains continuous probing:
+ 3 providers at launch, every 60 seconds, from us-east, eu-west and
+ Singapore. Each provider is probed via a GET /status request
+ from which the block number is extracted.
+ The harness classifies every response (ok / http_err / jsonrpc_err / stale / timeout)
+ with a Cosmos-scaled staleness gap (40 blocks, around 4 min at 6 s/block).
+ The cross-chain view lives on the parent rpc-capabilities benchmark;
+ this page is the cheqd-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: GET /status. The result.sync_info.latest_block_height field is extracted as the current block height."
+ - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 10 s), so percentiles are computed via Prometheus quantile_over_time over the last 24 hours."
+ - "Call-result classification: ok (parsable block height returned), http_err, jsonrpc_err, stale (block more than 40 behind the cross-provider tip), timeout."
+ - "This page is part of the per-chain RPC cluster derived from the cross-chain rpc-capabilities benchmark; the identical harness, cadence and exclusion rules apply on every chain."
+ - "Chain scope: every query on this page is pinned to chain=cheqd. Provider coverage at launch: 3 endpoints (cheqd official, PublicNode, NodeStake)."
+
+findings:
+ - "{{best_name}} leads free cheqd RPC at {{best_p50}} (Tendermint /status p50, 24h) across 3 measured providers."
faq:
- - q: "What does this benchmark measure?"
- a: "Each probe issues a GET /status request with anti-cache headers to retrieve the latest block height. We record round-trip latency (p50/p90/p99), HTTP availability, and whether the returned block height is current. Probes run every 30 seconds from US East, EU West, and AP Southeast."
- - q: "Which endpoints are included?"
- a: "The benchmark covers the official cheqd endpoint (rpc.cheqd.net), PublicNode, and NodeStake. All three are keyless public endpoints requiring no API key."
- - q: "Why does cheqd RPC performance matter?"
- a: "DID resolution, verifiable credential anchoring, and CHEQ token transfers all depend on RPC availability. Applications using the cheqd DID method resolve identifiers via RPC, making latency directly visible to end users."
- - q: "How does RPC latency affect DID resolution?"
- a: "The Universal Resolver and cheqd-specific resolvers query RPC endpoints to fetch DID documents. Slow or unavailable endpoints increase credential verification times and can break SSI flows in production."
- - q: "Can I contribute an endpoint?"
- a: "Yes. Open an issue or PR at github.com/ChainBench/OpenChainBench with the endpoint URL and operator name. We verify liveness and independence before adding."
+ - q: "What is the fastest free cheqd RPC right now?"
+ a: "{{best_name}} currently leads at {{best_p50}} (cheqd block height p50 over the last 24h), measured against 3 providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples."
+ - q: "Which cheqd RPC endpoints work without an API key?"
+ a: "3 endpoints sustain continuous keyless probing at launch: cheqd official (rpc.cheqd.net), PublicNode (cheqd-rpc.publicnode.com), and NodeStake (rpc.cheqd.nodestake.org). Every listed endpoint was live-verified before inclusion."
+ - q: "What is cheqd and why does its RPC latency matter?"
+ a: "cheqd is a Cosmos SDK blockchain built for decentralized identity (DID) and verifiable credentials. DID resolution, verifiable credential anchoring, and CHEQ token transfers all depend on RPC availability. Applications using the cheqd DID method resolve identifiers via RPC, making latency directly visible to end users and credential verifiers."
+ - q: "Does the fastest cheqd RPC change by region?"
+ a: "Yes. The official cheqd node and community validators are hosted in different regions. The region tabs re-scope every number to a single probe origin so you can pick the best endpoint for your deployment geography."
+ - q: "What is the CHEQ token?"
+ a: "CHEQ is the native staking and governance token of the cheqd network (denominated as ncheq on-chain). It is used to pay for DID writes, credential schema anchoring, and on-chain governance participation."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities
+
+prometheus:
+ window: 24h
+ freshness_metric: rpc_latency_milliseconds
+
+rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="cheqd"})
+
+dimensions:
+ region:
+ - { value: all, label: All regions }
+ - { value: us-east, label: US-East }
+ - { value: eu-west, label: EU-West }
+ - { value: sgp, label: Singapore }
+
+providers:
+ - slug: cheqd-official
+ name: cheqd
+ tag: cheqd official public RPC node, no API key required
+ formula: "50th percentile over 24h of client-side round-trip latency (ms) for a GET /status sent every 60s from 3 regions to rpc.cheqd.net."
+ queries:
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cheqd-official", chain="cheqd"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="cheqd-official", chain="cheqd"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="cheqd-official", chain="cheqd"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="cheqd-official", chain="cheqd"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="cheqd-official", chain="cheqd"}) / sum(ocb:rpc_call:rate_24h{provider="cheqd-official", chain="cheqd"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="cheqd-official", chain="cheqd"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="cheqd-official", chain="cheqd"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cheqd-official", chain="cheqd", region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="cheqd-official", chain="cheqd", region="us-east"}[1h])
+ - region: eu-west
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cheqd-official", chain="cheqd", region="eu-west"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="cheqd-official", chain="cheqd", region="eu-west"}[1h])
+ - region: ap-southeast
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cheqd-official", chain="cheqd", region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="cheqd-official", chain="cheqd", region="sgp"}[1h])
+
+ - slug: publicnode
+ name: PublicNode
+ tag: PublicNode public cheqd RPC, no API key required
+ formula: "50th percentile over 24h of client-side round-trip latency (ms) for a GET /status sent every 60s from 3 regions to cheqd-rpc.publicnode.com."
+ queries:
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="cheqd"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="cheqd"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="cheqd"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="cheqd"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="cheqd"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="cheqd"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="cheqd"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="cheqd"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="cheqd", region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="cheqd", region="us-east"}[1h])
+ - region: eu-west
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="cheqd", region="eu-west"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="cheqd", region="eu-west"}[1h])
+ - region: ap-southeast
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="cheqd", region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="cheqd", region="sgp"}[1h])
+
+ - slug: nodestake
+ name: NodeStake
+ tag: NodeStake public cheqd RPC, no API key required
+ formula: "50th percentile over 24h of client-side round-trip latency (ms) for a GET /status sent every 60s from 3 regions to rpc.cheqd.nodestake.org."
+ queries:
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodestake", chain="cheqd"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nodestake", chain="cheqd"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="nodestake", chain="cheqd"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="nodestake", chain="cheqd"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="nodestake", chain="cheqd"}) / sum(ocb:rpc_call:rate_24h{provider="nodestake", chain="cheqd"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="nodestake", chain="cheqd"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="nodestake", chain="cheqd"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodestake", chain="cheqd", region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="nodestake", chain="cheqd", region="us-east"}[1h])
+ - region: eu-west
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodestake", chain="cheqd", region="eu-west"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="nodestake", chain="cheqd", region="eu-west"}[1h])
+ - region: ap-southeast
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodestake", chain="cheqd", region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="nodestake", chain="cheqd", region="sgp"}[1h])
diff --git a/benchmarks/dex-network-coverage.yml b/benchmarks/dex-network-coverage.yml
index 198522514..8e7649b3f 100644
--- a/benchmarks/dex-network-coverage.yml
+++ b/benchmarks/dex-network-coverage.yml
@@ -90,6 +90,19 @@ providers:
sample_size: networks_supported_total{provider="geckoterminal"}
series: networks_supported_total{provider="geckoterminal"}
+ - slug: serialized
+ name: Serialized
+ tag: Own indexers, 18 EVM + Solana
+ formula: "Count of chains returned as live by Serialized's `/v1/meta/chains` endpoint; every listed chain carries DEX pool indexing. Refreshed every 6 hours."
+ queries:
+ p50: networks_supported_total{provider="serialized"}
+ p90: networks_supported_total{provider="serialized"}
+ p99: networks_supported_total{provider="serialized"}
+ mean: networks_supported_total{provider="serialized"}
+ success: clamp_max(networks_supported_total{provider="serialized"} > bool 0, 1)
+ sample_size: networks_supported_total{provider="serialized"}
+ series: networks_supported_total{provider="serialized"}
+
- slug: codex
name: Codex
tag: Defined.fi DEX data API
diff --git a/benchmarks/icon-rpc.yml b/benchmarks/icon-rpc.yml
index f45ec814c..a04d9938b 100644
--- a/benchmarks/icon-rpc.yml
+++ b/benchmarks/icon-rpc.yml
@@ -1,35 +1,138 @@
-id: 258
+# OpenChainBench. Bench No 258
+
slug: icon-rpc
-title: "Fastest free ICON RPC, live no-key endpoint latency"
-chain: icon
-description: "Latency and availability benchmark for ICON public RPC endpoints"
-category: rpc
-kind: icon
+number: "258"
+title: Fastest free ICON RPC, live no-key endpoint latency
+seo_title: "Fastest free ICON RPC 2026"
+seo_description: "{{best_name}} leads free ICON RPC at {{best_p50}} (block height p50, 24h). 3 no-key providers measured every 60s from 3 regions."
+subtitle: HTTP round-trip latency for icx_getLastBlock queries against every available public ICON blockchain endpoint, audited every 60 seconds from 3 regions.
-providers:
- - slug: icon-solidwallet
- name: "ICON Foundation"
- url: "https://ctz.solidwallet.io"
- - slug: icon-community
- name: "ICON Community"
- url: "https://api.icon.community"
- - slug: iconblockchain
- name: "iconblockchain.xyz"
- url: "https://api.iconblockchain.xyz"
+category: RPCs
+status: live
+metric: RPC latency
+unit: ms
+higher_is_better: false
seo_intro: |
- ICON is a South Korean L1 blockchain focused on interoperability and enterprise adoption, using a Delegated Proof of Contribution (DPoC) consensus with ~2-second block finality. ICON nodes expose a JSON-RPC 2.0 API: POST /api/v3 with method icx_getLastBlock returns the latest block height. Free public endpoints are available keyless from the ICON Foundation (ctz.solidwallet.io), ICON Community (api.icon.community), and iconblockchain.xyz.
+ ICON is a South Korean L1 blockchain focused on interoperability and enterprise adoption, using a Delegated Proof of Contribution (DPoC) consensus with roughly 2-second block finality. ICON nodes expose a JSON-RPC 2.0 API: POST /api/v3 with method icx_getLastBlock returns the latest block height as a decimal integer. Free public endpoints are available keyless from the ICON Foundation (ctz.solidwallet.io), ICON Community (api.icon.community), and iconblockchain.xyz. Every provider was live-verified with consecutive block-height probes at launch.
- This benchmark continuously measures RPC latency, availability and block-height freshness across these public ICON endpoints from three geographic regions. Every provider was live-verified with consecutive keyless icx_getLastBlock probes at launch.
+abstract: |
+ Per-chain member of the RPC latency cluster, extended to ICON (ICX).
+ We measure the round-trip latency of a JSON-RPC icx_getLastBlock call against
+ every available public ICON endpoint that sustains continuous probing:
+ 3 providers at launch, every 60 seconds, from us-east, eu-west and
+ Singapore. Each provider is probed via a POST /api/v3 request
+ from which the block height is extracted from result.height.
+ The harness classifies every response (ok / http_err / jsonrpc_err / stale / timeout)
+ with an ICON-scaled staleness gap (150 blocks, around 5 min at 2 s/block).
+ The cross-chain view lives on the parent rpc-capabilities benchmark;
+ this page is the ICON-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: POST /api/v3 with method icx_getLastBlock. The result.height field (decimal integer) is extracted as the current block height."
+ - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 10 s), so percentiles are computed via Prometheus quantile_over_time over the last 24 hours."
+ - "Call-result classification: ok (parsable block height returned), http_err, jsonrpc_err, stale (block more than 150 behind the cross-provider tip, roughly 5 min at ICON's 2 s block time), timeout."
+ - "This page is part of the per-chain RPC cluster derived from the cross-chain rpc-capabilities benchmark; the identical harness, cadence and exclusion rules apply on every chain."
+ - "Chain scope: every query on this page is pinned to chain=icon. Provider coverage at launch: 3 endpoints (ICON Foundation, ICON Community, iconblockchain.xyz)."
+
+findings:
+ - "{{best_name}} leads free ICON RPC at {{best_p50}} (icx_getLastBlock p50, 24h) across 3 measured providers."
faq:
- - q: "What does this benchmark measure?"
- a: "Each probe issues a POST /api/v3 icx_getLastBlock request to retrieve the latest block height. We record round-trip latency (p50/p90/p99), HTTP availability, and whether the returned block height is current. Probes run every 60 seconds from US East, EU West, and AP Southeast."
- - q: "Which endpoints are included?"
- a: "The benchmark covers the ICON Foundation endpoint (ctz.solidwallet.io), ICON Community (api.icon.community), and iconblockchain.xyz. All three are keyless public endpoints requiring no API key."
- - q: "Why does ICON RPC performance matter?"
- a: "ICON powers ICX transfers, BTP cross-chain messages, and DApps across the ICON ecosystem. Low-latency RPC access is critical for wallets, DEX aggregators integrating ICON, and validators monitoring chain health."
- - q: "How does ICON's ~2-second block time affect staleness detection?"
- a: "With blocks every ~2 seconds, a gap of 150 blocks represents roughly 5 minutes of drift — the same threshold we use to classify other fast-finality chains as stale. A provider returning a block more than 150 behind the cross-provider tip is marked stale."
- - q: "Can I contribute an endpoint?"
- a: "Yes. Open an issue or PR at github.com/ChainBench/OpenChainBench with the endpoint URL and operator name. We verify liveness and independence before adding."
+ - q: "What is the fastest free ICON RPC right now?"
+ a: "{{best_name}} currently leads at {{best_p50}} (ICON block height p50 over the last 24h), measured against 3 providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples."
+ - q: "Which ICON RPC endpoints work without an API key?"
+ a: "3 endpoints sustain continuous keyless probing at launch: ICON Foundation (ctz.solidwallet.io), ICON Community (api.icon.community), and iconblockchain.xyz. Every listed endpoint was live-verified before inclusion."
+ - q: "What is ICON and why does its RPC latency matter?"
+ a: "ICON is a South Korean blockchain focused on enterprise interoperability, originally built for connecting financial institutions and public organizations. ICX wallets, DApp integrations, and BTP cross-chain bridging all depend on reliable RPC access. Low latency matters for real-time ICX transfers and DeFi protocols built on the ICON ecosystem."
+ - q: "Does the fastest ICON RPC change by region?"
+ a: "Yes. The ICON Foundation node and community operators are distributed globally. The region tabs re-scope every number to a single probe origin so you can pick the best endpoint for your deployment geography."
+ - q: "What is the ICX token?"
+ a: "ICX is the native staking and governance token of the ICON network. It is used to pay transaction fees, stake with validators (P-Reps), and participate in on-chain governance. ICON uses a Delegated Proof of Contribution model where P-Reps are elected by ICX holders."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities
+
+prometheus:
+ window: 24h
+ freshness_metric: rpc_latency_milliseconds
+
+rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="icon"})
+
+dimensions:
+ region:
+ - { value: all, label: All regions }
+ - { value: us-east, label: US-East }
+ - { value: eu-west, label: EU-West }
+ - { value: sgp, label: Singapore }
+
+providers:
+ - slug: icon-solidwallet
+ name: ICON Foundation
+ tag: ICON Foundation official public RPC node, no API key required
+ formula: "50th percentile over 24h of client-side round-trip latency (ms) for a POST /api/v3 icx_getLastBlock sent every 60s from 3 regions to ctz.solidwallet.io."
+ queries:
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="icon-solidwallet", chain="icon"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="icon-solidwallet", chain="icon"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="icon-solidwallet", chain="icon"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="icon-solidwallet", chain="icon"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="icon-solidwallet", chain="icon"}) / sum(ocb:rpc_call:rate_24h{provider="icon-solidwallet", chain="icon"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="icon-solidwallet", chain="icon"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="icon-solidwallet", chain="icon"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="icon-solidwallet", chain="icon", region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="icon-solidwallet", chain="icon", region="us-east"}[1h])
+ - region: eu-west
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="icon-solidwallet", chain="icon", region="eu-west"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="icon-solidwallet", chain="icon", region="eu-west"}[1h])
+ - region: ap-southeast
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="icon-solidwallet", chain="icon", region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="icon-solidwallet", chain="icon", region="sgp"}[1h])
+
+ - slug: icon-community
+ name: ICON Community
+ tag: ICON Community public RPC node, no API key required
+ formula: "50th percentile over 24h of client-side round-trip latency (ms) for a POST /api/v3 icx_getLastBlock sent every 60s from 3 regions to api.icon.community."
+ queries:
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="icon-community", chain="icon"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="icon-community", chain="icon"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="icon-community", chain="icon"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="icon-community", chain="icon"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="icon-community", chain="icon"}) / sum(ocb:rpc_call:rate_24h{provider="icon-community", chain="icon"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="icon-community", chain="icon"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="icon-community", chain="icon"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="icon-community", chain="icon", region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="icon-community", chain="icon", region="us-east"}[1h])
+ - region: eu-west
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="icon-community", chain="icon", region="eu-west"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="icon-community", chain="icon", region="eu-west"}[1h])
+ - region: ap-southeast
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="icon-community", chain="icon", region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="icon-community", chain="icon", region="sgp"}[1h])
+
+ - slug: iconblockchain
+ name: iconblockchain.xyz
+ tag: iconblockchain.xyz community-operated public ICON RPC, no API key required
+ formula: "50th percentile over 24h of client-side round-trip latency (ms) for a POST /api/v3 icx_getLastBlock sent every 60s from 3 regions to api.iconblockchain.xyz."
+ queries:
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="iconblockchain", chain="icon"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="iconblockchain", chain="icon"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="iconblockchain", chain="icon"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="iconblockchain", chain="icon"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="iconblockchain", chain="icon"}) / sum(ocb:rpc_call:rate_24h{provider="iconblockchain", chain="icon"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="iconblockchain", chain="icon"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="iconblockchain", chain="icon"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="iconblockchain", chain="icon", region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="iconblockchain", chain="icon", region="us-east"}[1h])
+ - region: eu-west
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="iconblockchain", chain="icon", region="eu-west"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="iconblockchain", chain="icon", region="eu-west"}[1h])
+ - region: ap-southeast
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="iconblockchain", chain="icon", region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="iconblockchain", chain="icon", region="sgp"}[1h])
diff --git a/benchmarks/keyed-rpc-robinhood.yml b/benchmarks/keyed-rpc-robinhood.yml
index 09e914e11..a12960dae 100644
--- a/benchmarks/keyed-rpc-robinhood.yml
+++ b/benchmarks/keyed-rpc-robinhood.yml
@@ -4,8 +4,8 @@ slug: keyed-rpc-robinhood
number: "243"
title: "Fastest Robinhood Chain RPC with API key: Chainstack vs Alchemy vs QuickNode"
seo_title: "Fastest Robinhood Chain RPC API key 2026: Chainstack vs Alchemy vs QuickNode latency"
-seo_description: "Live Robinhood Chain RPC latency: Chainstack, Alchemy, and QuickNode keyed endpoints probed every 60s from US-East. First public benchmark for this Arbitrum Orbit L2."
-subtitle: eth_getBlockByNumber latency against keyed Robinhood Chain endpoints (Chainstack, Alchemy, QuickNode), probed every 60 seconds from US-East.
+seo_description: "Live Robinhood Chain RPC latency: Chainstack, Alchemy, and QuickNode keyed endpoints probed every 60s. First public benchmark for this Arbitrum Orbit L2."
+subtitle: eth_getBlockByNumber latency against keyed Robinhood Chain endpoints (Chainstack, Alchemy, QuickNode), probed every 60 seconds.
category: RPCs
status: live
metric: RPC latency
@@ -18,39 +18,55 @@ seo_intro: |
subsidy running through September 2026. It is a financially-sensitive
chain with low tolerance for RPC latency.
This bench probes Chainstack, Alchemy, and QuickNode on their keyed
- Robinhood Chain endpoints every 60 seconds from US-East (Virginia).
- Chainstack leads from US-East. This is the only independently-run public
+ Robinhood Chain endpoints every 60 seconds from two probe regions
+ (Singapore and US-East). Chainstack posts the lowest median latency. This is the only independently-run public
latency benchmark for Robinhood Chain.
abstract: |
We measure the round-trip latency of eth_getBlockByNumber against the
keyed endpoints of Chainstack, Alchemy, and QuickNode on Robinhood Chain.
The probe uses a rotating JSON-RPC id to defeat edge caches, fires every
- 60 seconds from US-East (Virginia), and classifies each response as ok,
+ 60 seconds from each probe region, and classifies each response as ok,
http_err, jsonrpc_err, stale, or timeout. Only ok responses contribute to
the latency distribution.
methodology:
- - "Cadence: every 60 seconds per provider from US-East (Virginia). Robinhood Chain runs on the Arbitrum Orbit stack with ~100ms block times; the 60s probe cadence captures 600 blocks per hour."
+ - "Cadence: every 60 seconds per provider from each probe region (Singapore; US-East Virginia). Headline numbers cite the Singapore probe, the closest region to the chain's Asia-Pacific infrastructure; the region tabs re-scope every figure to one origin. Robinhood Chain runs on the Arbitrum Orbit stack with ~100ms block times; the 60s probe cadence captures 600 blocks per hour."
- "Payload: eth_getBlockByNumber('latest', false) with a rotating JSON-RPC id. Non-cacheable by design: the rotating id defeats body-keyed edge caches and forces the provider to serve a real Robinhood Chain tip."
- "Authentication: Chainstack Global Node key (node-scoped, dedicated to Robinhood Chain; key in Railway env, not in repo). Alchemy uses its standard multi-chain key. QuickNode uses its shared-endpoint key for Robinhood Chain. All keys in Railway env, not in repo."
- "Call-result classification: ok (HTTP 200 with usable block number), http_err, jsonrpc_err (HTTP 200 with error body), stale (block more than 20 behind the cross-provider tip), timeout. Latency is recorded only for ok responses."
- "Cohort: Chainstack, Alchemy, and QuickNode. Infura and Ankr do not currently list Robinhood Chain in their supported networks."
findings:
- - "{{name:chainstack}} leads at {{p50:chainstack}} (p50, 24h) from US-East. Its Global Node, launched first on Robinhood Chain, delivers the lowest round-trip latency in this benchmark."
- - "{{name:alchemy}} records {{p50:alchemy}} from US-East via its edge infrastructure."
+ - "{{name:chainstack}} leads at {{p50:chainstack}} (p50, 24h). Its Global Node, launched first on Robinhood Chain, delivers the lowest round-trip latency in this benchmark."
+ - "{{name:alchemy}} records {{p50:alchemy}} via its edge infrastructure."
- "{{name:quicknode}} delivers {{p50:quicknode}} via its shared-endpoint fleet."
+# 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: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="robinhood",tier="keyed"})
+
+# Headline view pinned to the Singapore probe: the default (unfiltered)
+# build, TL;DR, JSON-LD and OG cite the sgp slice; the US-East tab
+# re-scopes on click. No "all" option on purpose — an average across a
+# healthy region and a degraded one describes neither.
+aggregate_filters:
+ region: sgp
+
+dimensions:
+ region:
+ - { value: sgp, label: Singapore }
+ - { value: us-east, label: US-East }
+
faq:
- q: "Which Robinhood Chain RPC provider is fastest?"
- a: "Per the live data above: {{name:chainstack}} at {{p50:chainstack}} p50 (24h, from US-East). It is the lowest-latency keyed provider for Robinhood Chain in this benchmark."
+ a: "Per the live data above: {{name:chainstack}} at {{p50:chainstack}} p50 (24h). It is the lowest-latency keyed provider for Robinhood Chain in this benchmark."
- 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 ~100ms block times and a gas fee subsidy running through September 29, 2026. It supports EVM-compatible tooling and standard eth_ JSON-RPC methods."
- q: "Why is this the only Robinhood Chain latency benchmark?"
a: "Robinhood Chain is a new chain. OpenChainBench added it within days of Chainstack, Alchemy, and QuickNode support going live, making this the first and so far only independent latency measurement. Infura and Ankr do not currently list Robinhood Chain in their supported networks."
- - q: "Why only US-East?"
- a: "The probe runs from US-East (Virginia) where the Railway infrastructure is located. Additional probe regions may be added as the chain matures and more regional endpoints become available."
+ - q: "Which regions does this benchmark probe from?"
+ a: "Two origins: Singapore and US-East (Virginia). The headline figures cite the Singapore probe, the closest region to the chain's Asia-Pacific infrastructure; the region tabs at the top of the page re-scope every number to a single origin."
source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-keyed-latency
@@ -62,38 +78,59 @@ providers:
- slug: chainstack
name: Chainstack
tag: Global Node, keyed Robinhood Chain endpoint, plan disclosed
- formula: "p50 over 24h of round-trip latency (ms) for eth_getBlockByNumber probed every 60s from US-East on Chainstack's keyed Robinhood Chain Global Node."
+ formula: "p50 over 24h of round-trip latency (ms) for eth_getBlockByNumber probed every 60s per region on Chainstack's keyed Robinhood Chain Global Node."
queries:
- p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="robinhood",tier="keyed",region="us-east"})
- p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="chainstack",chain="robinhood",tier="keyed",region="us-east"})
- p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="chainstack",chain="robinhood",tier="keyed",region="us-east"})
- mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="chainstack",chain="robinhood",tier="keyed",region="us-east"})
- success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="robinhood",tier="keyed",region="us-east"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="robinhood",tier="keyed",region="us-east"})
- sample_size: sum(ocb:rpc_call:increase_24h{provider="chainstack",chain="robinhood",tier="keyed",region="us-east"})
- series: avg(avg_over_time(rpc_latency_milliseconds{provider="chainstack",chain="robinhood",tier="keyed",region="us-east"}[1h]))
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="robinhood",tier="keyed"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="chainstack",chain="robinhood",tier="keyed"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="chainstack",chain="robinhood",tier="keyed"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="chainstack",chain="robinhood",tier="keyed"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="robinhood",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="robinhood",tier="keyed"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="chainstack",chain="robinhood",tier="keyed"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="chainstack",chain="robinhood",tier="keyed"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="robinhood",tier="keyed",region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="chainstack",chain="robinhood",tier="keyed",region="us-east"}[1h])
+ - region: sgp
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack",chain="robinhood",tier="keyed",region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="chainstack",chain="robinhood",tier="keyed",region="sgp"}[1h])
- slug: quicknode
name: QuickNode
tag: Shared endpoint fleet, keyed Robinhood Chain endpoint, plan disclosed
- formula: "p50 over 24h of round-trip latency (ms) for eth_getBlockByNumber probed every 60s from US-East on QuickNode's keyed Robinhood Chain endpoint."
+ formula: "p50 over 24h of round-trip latency (ms) for eth_getBlockByNumber probed every 60s per region on QuickNode's keyed Robinhood Chain endpoint."
queries:
- p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="robinhood",tier="keyed",region="us-east"})
- p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="quicknode",chain="robinhood",tier="keyed",region="us-east"})
- p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="quicknode",chain="robinhood",tier="keyed",region="us-east"})
- mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="quicknode",chain="robinhood",tier="keyed",region="us-east"})
- success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="robinhood",tier="keyed",region="us-east"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="robinhood",tier="keyed",region="us-east"})
- sample_size: sum(ocb:rpc_call:increase_24h{provider="quicknode",chain="robinhood",tier="keyed",region="us-east"})
- series: avg(avg_over_time(rpc_latency_milliseconds{provider="quicknode",chain="robinhood",tier="keyed",region="us-east"}[1h]))
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="robinhood",tier="keyed"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="quicknode",chain="robinhood",tier="keyed"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="quicknode",chain="robinhood",tier="keyed"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="quicknode",chain="robinhood",tier="keyed"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="robinhood",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="robinhood",tier="keyed"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="quicknode",chain="robinhood",tier="keyed"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="quicknode",chain="robinhood",tier="keyed"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="robinhood",tier="keyed",region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="quicknode",chain="robinhood",tier="keyed",region="us-east"}[1h])
+ - region: sgp
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="quicknode",chain="robinhood",tier="keyed",region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="quicknode",chain="robinhood",tier="keyed",region="sgp"}[1h])
- slug: alchemy
name: Alchemy
tag: 30M CU/mo free, keyed Robinhood Chain endpoint
- formula: "p50 over 24h of round-trip latency (ms) for eth_getBlockByNumber probed every 60s from US-East on Alchemy's keyed Robinhood Chain endpoint."
+ formula: "p50 over 24h of round-trip latency (ms) for eth_getBlockByNumber probed every 60s per region on Alchemy's keyed Robinhood Chain endpoint."
queries:
- p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="robinhood",tier="keyed",region="us-east"})
- p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="alchemy",chain="robinhood",tier="keyed",region="us-east"})
- p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="alchemy",chain="robinhood",tier="keyed",region="us-east"})
- mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="alchemy",chain="robinhood",tier="keyed",region="us-east"})
- success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="robinhood",tier="keyed",region="us-east"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="robinhood",tier="keyed",region="us-east"})
- sample_size: sum(ocb:rpc_call:increase_24h{provider="alchemy",chain="robinhood",tier="keyed",region="us-east"})
- series: avg(avg_over_time(rpc_latency_milliseconds{provider="alchemy",chain="robinhood",tier="keyed",region="us-east"}[1h]))
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="robinhood",tier="keyed"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="alchemy",chain="robinhood",tier="keyed"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="alchemy",chain="robinhood",tier="keyed"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="alchemy",chain="robinhood",tier="keyed"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="robinhood",tier="keyed"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="robinhood",tier="keyed"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="alchemy",chain="robinhood",tier="keyed"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="alchemy",chain="robinhood",tier="keyed"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="robinhood",tier="keyed",region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="alchemy",chain="robinhood",tier="keyed",region="us-east"}[1h])
+ - region: sgp
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="alchemy",chain="robinhood",tier="keyed",region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="alchemy",chain="robinhood",tier="keyed",region="sgp"}[1h])
diff --git a/benchmarks/metadata-coverage.yml b/benchmarks/metadata-coverage.yml
index 0381ff2f3..41f764c48 100644
--- a/benchmarks/metadata-coverage.yml
+++ b/benchmarks/metadata-coverage.yml
@@ -156,6 +156,19 @@ providers:
sample_size: sum(increase(metadata_coverage_checks_total{provider="codex"}[24h]))
series: 100 * sum(rate(metadata_coverage_success_total{provider="codex"}[1h])) / sum(rate(metadata_coverage_checks_total{provider="codex"}[1h]))
+ - slug: serialized
+ name: Serialized
+ tag: REST `/v1/token/metadata`
+ formula: "Median of hourly coverage rate (populated logo/description/twitter/website fields divided by total field checks) on Serialized `/v1/token/metadata` for fresh tokens, p50 over 24h."
+ queries:
+ p50: quantile_over_time(0.50, (100 * sum(rate(metadata_coverage_success_total{provider="serialized"}[1h])) / sum(rate(metadata_coverage_checks_total{provider="serialized"}[1h])))[24h:1h])
+ p90: quantile_over_time(0.90, (100 * sum(rate(metadata_coverage_success_total{provider="serialized"}[1h])) / sum(rate(metadata_coverage_checks_total{provider="serialized"}[1h])))[24h:1h])
+ p99: quantile_over_time(0.99, (100 * sum(rate(metadata_coverage_success_total{provider="serialized"}[1h])) / sum(rate(metadata_coverage_checks_total{provider="serialized"}[1h])))[24h:1h])
+ mean: 100 * sum(rate(metadata_coverage_success_total{provider="serialized"}[24h])) / sum(rate(metadata_coverage_checks_total{provider="serialized"}[24h]))
+ success: clamp_max(sum(rate(metadata_coverage_checks_total{provider="serialized"}[24h])) / scalar(sum(rate(metadata_coverage_checks_total{provider="mobula"}[24h]))), 1)
+ sample_size: sum(increase(metadata_coverage_checks_total{provider="serialized"}[24h]))
+ series: 100 * sum(rate(metadata_coverage_success_total{provider="serialized"}[1h])) / sum(rate(metadata_coverage_checks_total{provider="serialized"}[1h]))
+
- slug: jupiter
name: Jupiter
tag: REST `/v6/tokens` (Solana)
diff --git a/benchmarks/solana-dex-volume.yml b/benchmarks/solana-dex-volume.yml
index 2c926edfc..5d7264322 100644
--- a/benchmarks/solana-dex-volume.yml
+++ b/benchmarks/solana-dex-volume.yml
@@ -16,6 +16,10 @@ higher_is_better: true
disclaimer: |
Volume and revenue data sourced from DeFiLlama's DEX and fees APIs. DeFiLlama attributes volume via on-chain referral tags and program IDs; platforms without standard tags may be undercounted. Figures are 24-hour and 7-day rolling totals updated every 30 minutes. Revenue uses DeFiLlama's dailyRevenue dataType, stripping LP fees. Terminal volume (Axiom, GMGN, Fomo) and launchpad volume (pump.fun) use different attribution methods and are not directly additive.
+provider_notes:
+ gmgn: "Solana only"
+ fomo: "on-chain swaps only"
+
seo_intro: |
The Solana DEX market in summer 2026 is split between pump.fun's launchpad,
which dominates early-stage token trading, and a set of competing terminals
@@ -38,9 +42,10 @@ abstract: |
defillama_dex_take_rate and defillama_dex_health.
methodology:
- - "Source: DeFiLlama public DEX API (api.llama.fi/summary/dexs/{slug}) and fees API (api.llama.fi/summary/fees/{slug}?dataType=dailyRevenue). No authentication required."
- - "Poll cadence: harness fetches each platform every 30 minutes and updates Prometheus gauges immediately."
- - "Volume: total24h and total7d fields from DeFiLlama's DEX endpoint, sum of all swap notional values in USD attributed to this platform."
+ - "Source: DeFiLlama public chain overview APIs (api.llama.fi/overview/dexs/solana and overview/fees/solana?dataType=dailyRevenue), which report SOLANA-scoped totals per protocol. Multichain platforms such as GMGN therefore show their Solana volume only, keeping every row comparable. No authentication required."
+ - "Poll cadence: harness fetches every 30 minutes and updates Prometheus gauges immediately."
+ - "Fomo caveat: DeFiLlama attributes Fomo volume from on-chain Solana swaps matched to its fee wallet, while its revenue figure also includes relay fees the Fomo team self-reports. Fomo's displayed revenue-to-volume ratio (~5%) versus its stated 0.5% fee implies substantially higher total activity than the on-chain volume shown here; its Hyperliquid perps flow is tracked separately on the hyperliquid-frontends bench."
+ - "Volume: total24h and total7d fields, sum of swap notional values in USD attributed to this platform on Solana. DeFiLlama aggregates in daily buckets with roughly a 10-hour indexing delay, so intraday values are flat by construction and step once per day."
- "Revenue: total24h and total7d from the fees endpoint with dataType=dailyRevenue. This strips LP fees that stay in pool accounts and returns only the protocol's own share. Note: dailyRevenue also nets out referral paybacks (e.g. GMGN nets ~34% back to affiliates) and for pump.fun reflects the buyback-adjusted split introduced in 2026. Use dailyFees if comparing what users pay, not what protocols retain."
- "Take rate: protocol revenue divided by trading volume (defillama_dex_fees_24h_usd / defillama_dex_volume_24h_usd). Measures how much of each dollar traded the platform captures."
- "Attribution: DeFiLlama uses on-chain referral tags, program IDs and memo fields. Platforms without standard tagging may be undercounted."
diff --git a/benchmarks/wallet-labels-coverage.yml b/benchmarks/wallet-labels-coverage.yml
index 2cfe912e6..b3a02038e 100644
--- a/benchmarks/wallet-labels-coverage.yml
+++ b/benchmarks/wallet-labels-coverage.yml
@@ -194,6 +194,19 @@ providers:
sample_size: sum(increase(wallet_labels_checks_total{provider="mobula"}[24h]))
series: 100 * sum(rate(wallet_labels_success_total{provider="mobula"}[1h])) / sum(rate(wallet_labels_checks_total{provider="mobula"}[1h]))
+ - slug: serialized
+ name: Serialized
+ tag: Identity graph, 18 EVM chains + Solana, API key required
+ formula: "Share of anchor addresses for which Serialized /v1/wallet/profile returns a non-generic identity name, success_total ÷ checks_total over 24h."
+ queries:
+ p50: 100 * sum(increase(wallet_labels_success_total{provider="serialized"}[24h])) / sum(increase(wallet_labels_checks_total{provider="serialized"}[24h]))
+ p90: 100 * sum(increase(wallet_labels_success_total{provider="serialized"}[24h])) / sum(increase(wallet_labels_checks_total{provider="serialized"}[24h]))
+ p99: 100 * sum(increase(wallet_labels_success_total{provider="serialized"}[24h])) / sum(increase(wallet_labels_checks_total{provider="serialized"}[24h]))
+ mean: 100 * sum(increase(wallet_labels_success_total{provider="serialized"}[24h])) / sum(increase(wallet_labels_checks_total{provider="serialized"}[24h]))
+ success: clamp_min(1 - (sum(increase(wallet_labels_fetch_errors_total{provider="serialized"}[24h])) or vector(0)) / clamp_min(sum(increase(wallet_labels_checks_total{provider="serialized"}[24h])), 1), 0)
+ sample_size: sum(increase(wallet_labels_checks_total{provider="serialized"}[24h]))
+ series: 100 * sum(rate(wallet_labels_success_total{provider="serialized"}[1h])) / sum(rate(wallet_labels_checks_total{provider="serialized"}[1h]))
+
- slug: helius
name: Helius
tag: Solana specialist, native program graph, API key required
diff --git a/docs/methodology/serialized-onboarding-audit.md b/docs/methodology/serialized-onboarding-audit.md
new file mode 100644
index 000000000..6715b5476
--- /dev/null
+++ b/docs/methodology/serialized-onboarding-audit.md
@@ -0,0 +1,535 @@
+# Provider onboarding audit — Serialized (serialized.xyz)
+
+> **Pre-onboarding evaluation.** Run before Serialized is wired into any live harness, so the
+> decision to include or exclude them on each bench is documented and reproducible.
+>
+> **Version:** v1.3, 2026-09-06 (§8 corrected in v1.1; §16.3 root cause corrected and §17 added in v1.2; §18 added in v1.3: Serialized wired into bench 001). Author: internal. Key used: tenant `OpenChainBench`,
+> plan `starter`, keyId `d5511a080aaa`, issued 2026-09-04.
+
+---
+
+## 1. What this document is
+
+Serialized is a candidate provider for several existing OpenChainBench benchmarks. This file
+records the apples-to-apples tests run against them, the exact methodology of each test, the
+numbers that came back, and the methodology problems those tests exposed in **our own benches**.
+
+Every test below replicates the scoring rule of the target bench rather than inventing a new one,
+so the numbers are directly comparable to the published leaderboards.
+
+## 2. Test harness and vantage point
+
+| Property | Value |
+|---|---|
+| Host | `ocb-par-main` (the VPS that runs the production harnesses) |
+| Rationale | Same egress, same region, same network path as the live monitors. A latency or coverage number taken from a laptop is not comparable to a published bench value. |
+| Incumbent credentials | Read from the running `ocb-metadata-coverage` container env, never copied off the box |
+| Scripts | `~/serbench/ab.py`, `ab2.py`, `ab3.py`, `ab4.py`, `wsab.py` |
+| Date of run | 2026-09-05 |
+
+**Throttling matters.** Serialized enforces a hard burst cap of 40 in-flight requests per second.
+An unthrottled 8-worker pool produced 60 `429 RATE_LIMITED` responses out of 100 anchors and made
+their coverage look like 37%. The same test throttled to 12 rps produced 0 errors and 77%. Any
+harness that talks to them must rate-limit client-side, and any measurement that does not is wrong.
+
+## 3. Provider surface
+
+19 chains: 18 EVM plus Solana. `evm:1`, `evm:56`, `evm:130`, `evm:143`, `evm:196`, `evm:988`,
+`evm:1514`, `evm:2741`, `evm:4217`, `evm:4326`, `evm:4663`, `evm:5042`, `evm:8453`, `evm:9745`,
+`evm:42161`, `evm:43114`, `evm:57073`, `evm:645749`, `solana`. Audit engine covers the 18 EVM chains.
+
+Auth is a raw `Authorization` header, no `Bearer` prefix (same convention as Mobula). The
+documented `demo.serialized.xyz` server returns 403 outside their docs playground, so there is no
+keyless path for a harness.
+
+## 4. Rate limits and quota (measured, not quoted)
+
+| Property | Documented | Measured |
+|---|---|---|
+| Monthly credits (starter) | 150,000 | **1,000,000** on our key |
+| Per-minute rate | 1,200 | 1,200 (`x-ratelimit-limit` header) |
+| Burst | 40 req/s | Exactly 40. 60/100/150 concurrent all yielded exactly 40× `200` and the rest `429`. Deterministic, no jitter. |
+| Sustained | not stated | 891/891 `200` over 60 s at 15 rps, p50 38 ms, p99 67 ms |
+
+Response headers expose `x-ratelimit-limit`, `x-ratelimit-remaining`, `x-ratelimit-reset` and
+`x-credits-remaining`. Good enough to instrument a harness without guessing.
+
+Streams bill 1 credit per connection-minute. Limits are 5 concurrent connections, 20 subscriptions
+per connection and 50 distinct tokens or pools per key. Bench 001 runs 3 regions × 4 chains, which
+does not fit inside one key's 5-connection budget: it needs one key per region.
+
+## 5. Bench 008 — wallet-labels-coverage
+
+**Replica rule.** Identical to `harnesses/wallet-labels`: the same 178-anchor curated list, filtered
+to the 5 chains Serialized covers (100 anchors, 59 contract / 41 EOA); a "hit" is any non-generic
+name, using the harness's exact `genericLabel` exclusion set; Mobula queried through
+`POST /api/1/wallet/labels` with the same field-precedence (`entityName` → `entityLabels` → `labels`).
+Serialized queried through `GET /v1/wallet/profile`, taking the first non-generic of
+`displayName` → `ensName` → `basename` → `solName`.
+
+**Added dimension (not in the bench today):** accuracy. A hit is counted accurate when the returned
+label shares a meaningful token with the curated `Hint` for that anchor.
+
+| Provider | Coverage | Contract | EOA | Accurate | Accurate given hit | p50 |
+|---|---|---|---|---|---|---|
+| **Serialized** | **77.0%** | 76.3% | 78.0% | **58.0%** | 75.3% | 45 ms |
+| Mobula | 59.0% | 54.2% | 65.9% | 44.0% | 74.6% | 34 ms |
+
+Per chain (coverage / accuracy):
+
+| Chain | n | Serialized | Mobula |
+|---|---|---|---|
+| ethereum | 32 | 96.9% / 78.1% | 62.5% / 53.1% |
+| bnb | 15 | 80.0% / 60.0% | 80.0% / 46.7% |
+| base | 17 | 76.5% / 58.8% | 70.6% / 47.1% |
+| arbitrum | 17 | 58.8% / 52.9% | 47.1% / 41.2% |
+| solana | 19 | 57.9% / 26.3% | 36.8% / 26.3% |
+
+**Verdict: include.** Serialized leads on coverage and on absolute accuracy on every chain in scope.
+
+**Bench flaw this exposed.** 25% of Serialized's hits are wrong (19 of 77). Mobula's ratio is
+almost identical (74.6% accurate given hit). The bench scores presence of a non-generic string, so a
+personal ENS or `.sol` name registered against a well-known contract counts as a correct entity
+label. Concrete cases: Permit2 → `dex.davywoodfi.eth`, Uniswap V3 Router 2 → `factory.vibebet.eth`,
+Base USDC → `jakie.base.eth`, Raydium Authority → `bonklanatoken.sol`, BSC USDT → `Fake_Phishing6512`,
+OKX 1 → `Bittrex 3`, Bitfinex → `Polygon`.
+
+This is a pre-existing, provider-neutral gameability hole. It should be fixed **before** Serialized
+is published, not after, otherwise the fix looks like a reaction to a new entrant beating the
+incumbent. Recommended fix: score against the curated `Hint` (the harness already carries it and
+already ignores it), or exclude name-service strings from the hit rule.
+
+## 6. Bench 004 — metadata-coverage
+
+**Replica rule.** Same 4 fields as the bench (`logo`, `description`, `twitter`, `website`). Discovery
+via GeckoTerminal `new_pools` (an independent third source, so neither provider's own discovery
+biases the sample). Both providers queried on the **same token set**, and only tokens that **both**
+resolved are scored, so the denominator is identical.
+
+| Chain | paired n | Serialized | Mobula |
+|---|---|---|---|
+| solana | 48 | 10.4% | 34.4% |
+| base | 37 | 19.6% | 34.5% |
+| bsc | 38 | 60.5% | 80.9% |
+| **total** | 123 | **28.7%** | **48.8%** |
+
+Field breakdown:
+
+| Chain | Field | Serialized | Mobula |
+|---|---|---|---|
+| solana | logo | 22.9% | 100.0% |
+| solana | description | 8.3% | 25.0% |
+| solana | twitter | 8.3% | 8.3% |
+| solana | website | 2.1% | 4.2% |
+| base | logo | 37.8% | 100.0% |
+| base | description | 13.5% | 13.5% |
+| base | twitter | 18.9% | 16.2% |
+| base | website | 8.1% | 8.1% |
+| bsc | logo | 78.9% | 100.0% |
+| bsc | description | 78.9% | 71.1% |
+| bsc | twitter | 78.9% | 76.3% |
+| bsc | website | 5.3% | 76.3% |
+
+**Verdict: include, but fix the logo field first.**
+
+**Bench flaw this exposed.** Mobula returns `logo` = 100% on all three chains. That is not a data
+advantage, it is a URL-shape artifact: Mobula rewrites every logo onto `metadata.mobula.io` at a
+deterministic path (`/assets/logos/__`), so the field is never empty
+regardless of whether an image exists. Serialized returns the upstream source URL instead
+(`ipfs.io`, `gmgn.ai`, `axiomtrading.axiom-cdn.io`, `pbs.twimg.com`, `flap.sh`). A HEAD check on 12
+distinct Mobula logo URLs resolved 11 and 404'd 1.
+
+The bench currently measures *"is the field non-empty"*, which any provider can win by construction
+by rewriting to its own CDN. It should measure *"does the logo resolve"* (HEAD 200 with an image
+content type). Mobula is our own product and it is the beneficiary of the current rule, so this needs
+fixing on fairness grounds before a competitor is added to the same leaderboard.
+
+Excluding the logo field entirely, on the remaining three fields Serialized is level with Mobula on
+Base, ahead on BSC description and twitter, and behind on Solana and on BSC website.
+
+## 7. Benches 005 / 090 — chain-count coverage
+
+| Bench | Incumbents | Serialized |
+|---|---|---|
+| 005 asset-registry | CoinGecko 465, CoinPaprika 310, CoinStats 149, Mobula 81 | **19** |
+| 090 dex-network | GeckoTerminal 247, Codex 123, Sim by Dune 64, DexPaprika 35 | **19** |
+
+**Verdict: exclude for now.** Serialized would rank last by a wide margin on both. The metric is
+breadth, their product is deliberately narrow-and-deep. Adding them here produces a true but
+uninformative row and gives them a reason to refuse every other bench. Revisit only if they ask.
+
+Note: the GeckoTerminal count returned 100 in this run because the ad-hoc pager stopped early on
+rate limit. The production harness value of 247 is the correct one.
+
+## 8. Bench 001 — aggregator-head-lag
+
+**Replica rule.** Single process on `ocb-par-main`, two WebSocket connections open simultaneously,
+subscribed to the **same three tokens** (BONK / Solana, DEGEN / Base, CAKE / BNB). Serialized:
+`wss://api.serialized.xyz/v1/stream`, `subscribe` on channel `trades` with `{chain, address}`.
+Mobula: `wss://api.mobula.io`, `fast-trade` with `assetMode: true`. Trades matched by transaction
+hash, so every comparison is the same on-chain event seen by both pipelines. 240 s window.
+
+Note on protocol shape: Serialized's `params.pools` is a comma-separated **string**, not an array,
+and `address` is required even when `pools` is supplied. Their trade events carry the hash inside
+`data.id` as `:`, not as a `txHash` field, despite the docs naming `txHash` as the
+dedup key.
+
+### Relative arrival, the only comparison free of self-reported timestamps
+
+| Chain | matched n | p10 | p50 | p90 | Serialized first |
+|---|---|---|---|---|---|
+| solana | 74 | −288 ms | **−0 ms** | +29 ms | 51% |
+| base | 13 | −43 ms | +88 ms | +177 ms | 23% |
+| bnb | 0 | — | — | — | Mobula returned no CAKE events in this window |
+| **all** | 87 | | **+2 ms** | | **47%** |
+
+Negative means Serialized delivered the trade first. **It is a dead heat.** Across 87 matched
+trades the median difference is 2 ms and the two feeds trade the lead roughly half the time. On
+Base, Mobula was actually ahead on 77% of trades despite Serialized running a preconfirmation feed.
+
+### The finding that matters: providers disagree about when the trade happened
+
+For the **same transaction hash**, the two providers' own on-chain timestamps differ:
+
+| Chain | serialized `at` minus mobula `date` | p10 | p50 | p90 |
+|---|---|---|---|---|
+| solana | | −1,620 ms | **−707 ms** | −353 ms |
+| base | | +1,000 ms | **+1,000 ms** | +2,000 ms |
+
+Consequence, measured directly:
+
+| Chain | Provider | Self-reported lag p50 | Actually delivered first |
+|---|---|---|---|
+| solana | Mobula | +0.04 s | 49% |
+| solana | Serialized | +0.75 s | 51% |
+| base | Serialized | −0.33 s (13/13 negative) | 23% |
+| base | Mobula | +0.78 s | 77% |
+
+Read those two tables together. On Solana, Mobula's self-reported lag is 19× better than
+Serialized's, and the two arrive at the same instant. On Base, Serialized's self-reported lag is
+negative while Mobula beats it to the wire on three trades out of four. **Any head-lag number built
+on a provider's own timestamp is not a latency measurement, it is a measurement of where that
+provider chooses to put its clock.**
+
+**Correction, 2026-09-05 (v1.1).** An earlier draft of this file claimed bench 001 already
+references archive nodes and was therefore unaffected. That was wrong: it repeated the spec's
+methodology instead of reading the harness. `harnesses/aggregator-head-lag` contains no archive-node
+reference at all (`grep -rl "archive|eth_getBlockByNumber|getBlockTime|blockTimestamp"` over
+`cmd/` returns nothing). The gauge that feeds the leaderboard is computed from each provider's own
+self-reported timestamp:
+
+```go
+// head_lag_monitor.go:211 (Mobula)
+onChainTime := time.UnixMilli(trade.Date) // Mobula's own field
+totalLagMs := receiveTime.Sub(onChainTime)
+// head_lag_monitor.go:707 (Codex)
+onChainTime := time.Unix(event.Timestamp, 0) // Codex's own field
+```
+
+The published spec says otherwise in three places: `methodology[7]` ("Reference: archive nodes per
+chain, validated against block hashes"), the FAQ ("The harness holds a live WebSocket subscription
+to canonical-tip archive nodes on each chain"), and the per-chain explainers ("Measured against a
+canonical archive node"). The documentation and the code disagree, on a live bench that is publicly
+cited. That is a defect independent of Serialized and should be resolved before any provider is
+added.
+
+Second code-level issue, `head_lag_monitor.go:219`:
+
+```go
+if totalLagMs < 0 || totalLagMs > 30000 { continue }
+```
+
+Negative lags are dropped silently. Serialized's Base feed was negative on 13 of 13 sampled trades,
+so under this filter its entire Base preconfirmed population would be discarded and its Base sample
+would retain only its slowest trades. This is a measurable bias, not a policy question.
+
+Recommended resolution: make the harness hold its own node subscription per chain and timestamp each
+swap on receipt, matching by transaction hash. That is what the spec already claims, so no published
+text changes, and it makes the three providers comparable for the first time.
+
+**Blocking issue: Base preconfirmations.** Serialized emits Base trades from flashblocks
+preconfirmations, ahead of the block timestamp they attach to the event. Measured on their stream,
+Base events arrive with a **negative** lag versus their own `at` field (p50 −1.86 s, 3/3 negative in
+the first sample). Their docs state this explicitly (~2.5 s ahead).
+
+Measured on Base against Mobula on matched hashes: Serialized reports 13/13 negative self-lag while
+losing the actual race 77% of the time. So the preconfirmation feed does **not** currently translate
+into earlier delivery on Base, it only translates into an earlier timestamp. That distinction has to
+survive into whatever the bench publishes. Options, in order of preference:
+
+1. Add a `confirmation` dimension (`confirmed` / `preconfirmed`) and rank within it.
+2. Clamp negative lag to 0 and footnote it.
+3. Exclude Base for Serialized.
+
+Option 3 is the least honest, because their preconfirmed feed is a real product advantage for a
+trading UI. Option 1 is the one that survives a public dispute.
+
+## 9. Bench 067 — portfolio-chain-coverage
+
+`GET /v1/wallet/positions` returned `200` on all 19 chains with zero errors. Rows came back on 6
+chains (ethereum 238, bsc 121, hyperevm 23, solana 13, arbitrum 12, avalanche 9) and 0 rows on the
+other 13.
+
+**This test is inconclusive and must not be quoted.** The zero-row chains reflect probe addresses
+that hold nothing there, not unsupported chains. Bench 067 compares self-declared coverage against
+probe-verified coverage, which requires a curated funded address per chain. That curation is the
+work item; the endpoint itself is ready.
+
+## 10. Bench 102 / 033 — not applicable
+
+Serialized is not a swap router and returns no quotes. `token-quote-coverage` (102) and
+`evm-quote-latency` (033) cannot include them. Their `/v1/pulse` endpoint tracks ~90 launchpads and
+is usable as an **alternative discovery source** for bench 102, which is a separate question.
+
+## 11. Latency, head to head
+
+REST, identical call shape, 20 samples each, from `ocb-par-main`:
+
+| Chain | Serialized p50 / p90 | Mobula p50 / p90 |
+|---|---|---|
+| base | 72 ms / 180 ms | 74 ms / 364 ms |
+| solana | 50 ms / 58 ms | 39 ms / 180 ms |
+| ethereum | 43 ms / 57 ms | 40 ms / 186 ms |
+
+Median is a tie. The tail is not: Serialized's p90 is 2× to 3× tighter on every chain. That
+consistency is the more defensible claim, and it is not currently measured by any bench.
+
+## 12. Stretch tests
+
+| Test | Result |
+|---|---|
+| Burst threshold | Hard cap at exactly 40 concurrent. 20 and 40 pass clean; 60/100/150 return 40× `200` and the remainder `429` with an explicit `Burst limit: max 40 requests per second` message |
+| Sustained 60 s @ 15 rps | 891/891 `200`, p50 38 ms, p99 67 ms, zero degradation |
+| `POST /v1/token` batch | 25 items → `200`, 526 ms, 25 rows |
+| `POST /v1/token/price` batch | 100 items → `200`, 38 ms, 100 rows |
+| Batch over cap | 200 items → `400 INVALID_PARAM`, "must NOT have more than 100 items". Enforced, not silently truncated |
+| OHLCV page cap | `limit` ≤ 500, enforced with a clear `400` |
+| OHLCV history depth | 1s → 0.01 d, 1m → 0.35 d, 5m → 1.73 d, 1h → 20.8 d, 1d → 499 d (back to 2025-04-24), 1w → 973 d (back to 2024-01-04) |
+| Trades pagination | 10 cursor pages, 1,000 trades in 6.4 s, no gaps or repeats |
+| Error contract | `INVALID_CHAIN`, `INVALID_PARAM`, `NOT_FOUND`, `UNAUTHORIZED`, `RATE_LIMITED` all machine-readable and correct for the case |
+
+## 13. Defects found
+
+| # | Endpoint | Symptom |
+|---|---|---|
+| 1 | `GET /v1/wallet/equity/history` | `503 UPSTREAM_ERROR` after a 10 s hang, reproduced twice |
+| 2 | `GET /v1/wallet/transfers` | 6.5 s response on a routine call. Not benchmarkable as-is |
+| 3 | `GET /v1/token/trades`, `/stats`, `/dev-tokens` | `404` for the native wrapped mint (`So111…112`). Native is treated as a quote asset, never as a token. Any harness iterating a standard basket will hit this |
+| 4 | Parameter naming | Three conventions on one API: `/v1/pulse` takes `chains` (plural), `/v1/wallet/*` takes `wallet`, `/v1/wallet/profile` takes `address` |
+
+## 14. Third-party sourcing
+
+Worth knowing before any commercial discussion, neutral observation either way:
+
+- Their token `iconUrl` values are upstream URLs from `cdn.dexscreener.com`, `ipfs.io`,
+ `raw.githubusercontent.com`, `arweave.net`, `gmgn.ai`, `axiomtrading.axiom-cdn.io`.
+- Their wallet-profile entity avatars are served from `metadata.mobula.io`, our own CDN.
+
+## 15. Recommended sequence
+
+1. Fix the bench 004 logo rule (resolve-check instead of presence-check) and the bench 008 hit rule
+ (score against the curated hint). Both are provider-neutral fairness fixes and both should land
+ before a new entrant appears on those leaderboards.
+2. Onboard Serialized to bench 004 and bench 008. Both are 1:1 endpoint mappings.
+3. Bench 001: they are level with Mobula on wall-clock delivery (p50 +2 ms over 87 matched trades),
+ so they belong on the leaderboard. Decide the Base preconfirmation policy first, and keep the
+ archive-node reference: this audit showed self-reported timestamps disagree by up to 1.6 s on the
+ same transaction.
+4. Curate funded probe addresses per chain for bench 067, then onboard.
+5. Leave 005 and 090 alone unless they ask.
+6. Consider a new token-security bench, where their `/v1/token/security` (18 fields) and
+ `/v1/audit/contract` are a genuine differentiator rather than a last-place row.
+
+Every onboarding needs a `docker build --no-cache` of the materialize-worker on `ocb-par-main`
+after the harness change, or the new provider will not appear.
+
+
+## 16. Follow-up tests, 2026-09-05
+
+### 16.1 Bench 067, now conclusive
+
+The earlier §9 result was inconclusive because it used the wrong probe address. The harness already
+pins canonical ones in `registry.go`: EVM `0xF977...aceC` (Binance 8), Solana `9WzDX...WWM`, with a
+$1 USD floor. Re-run verbatim against those:
+
+| Metric | Serialized |
+|---|---|
+| listed (`/v1/meta/chains`) | 19 |
+| verified (returned a > $1 balance) | **5** |
+| errors | 0 |
+| total probe latency, 19 calls | 1,696 ms |
+
+Verified: ethereum (177 positions, $72.7M), bsc (93, $43.9M), base (27, $5.1M), arbitrum (12,
+$3.5M), solana (1, $12). The other 14 chains returned zero rows because Binance 8 holds nothing
+there, which is the harness's own "untestable residue" (`listed - probed`), not an indexer failure.
+
+Published leaderboard: CoinStats 127, Mobula 50, Zerion 42, Moralis 15. Serialized would rank
+last at 5. Verdict: addable and now measurable, but it is a third breadth metric and a third last
+place. Their `verified / probed` ratio is 5/5, which the bench exposes as a separate series and is
+the only flattering read available.
+
+### 16.2 Negative capability probes
+
+Confirmed by request rather than by reading docs. Every path returns `404 NOT_FOUND`:
+`/v1/wallet/nfts`, `/v1/nft/collection`, `/v1/nfts`, `/v1/swap/quote`, `/v1/quote`, `/v1/route`,
+`/v1/bridge/quote`. Benches 033, 102, `nft-collection-metadata`, `bridge-fee` and
+`bridge-quote-latency` are definitively out.
+
+### 16.3 A real pricing defect: BONK is 5.2x wrong
+
+| Source | BONK price |
+|---|---|
+| Mobula | 3.3097e-06 |
+| DexScreener (Orca, $305,835 liquidity) | 3.309e-06 |
+| GeckoTerminal | 3.309731e-06 |
+| **Serialized** | **6.3314e-07** |
+
+Three independent sources agree; Serialized is low by a factor of 5.2, and reports a $55.7M market
+cap against a real ~$290M.
+
+**Correction (v1.2).** An earlier revision said the deep Orca pool was absent from their list. It is
+not: it is present, ranked second. The defect is the ranking, and it is sharper than "missing pool".
+Their `/v1/token/pools` returns 50 pools; the top 8 with liquidity converted to USD at their own
+`/v1/prices/native` SOL price of $103.85:
+
+| rank | pair | liquidity, raw | liquidity, USD | priceNative | implied USD |
+|---|---|---|---|---|---|
+| **1** | Bonk/USDC | 4,346.7 USDC | **$4,347** | 6.0867e-09 | $6.32e-07 |
+| 2 | Bonk/SOL | 728.7 SOL | **$75,679** | 3.1853e-08 | $3.31e-06 |
+| 3 | Bonk/USDC | 528.3 USDC | $528 | 3.1794e-08 | $3.30e-06 |
+| 6 | Bonk/SOL | 298.7 SOL | $31,017 | 3.1843e-08 | $3.31e-06 |
+
+Seven of eight pools agree at ~$3.31e-06. Rank 1 is the lone outlier, and `/v1/token/price` prices
+from it. Rank 1 holds the largest **raw** `liquidityNative` number (4,346.7) but the pool it beats
+holds 728.7 SOL, which is $75,679, or 17.4x deeper.
+
+The ranking compares `liquidityNative` across different quote assets without converting to USD, so a
+pool quoted in USDC outranks a deeper pool quoted in SOL purely because 4,346 > 728. Not a decimals
+bug (`decimals: 5` is correct for BONK), not a discovery gap: a unit bug in the pool ranking.
+
+This matters beyond pricing. `lpBurnedPct`, `lpLockedPct` and `lpSource` are all properties of the
+selected pool, so a wrong rank-1 selection also describes the LP safety of the wrong market. On BONK
+those fields degrade to `null` / `unknown`, but on a token where the thin pool has a burned LP and
+the real market does not, the security verdict would be wrong in the dangerous direction.
+
+Worth raising with them directly: a top-100 token mispriced 5x is a bigger problem for their
+prospects than any leaderboard position.
+
+### 16.4 Cross-API price accuracy as a new bench: not proven
+
+Two attempts, neither conclusive, recorded so nobody repeats them:
+
+1. Basket from a DexScreener search returned eight distinct addresses all symbolled "SOL", i.e.
+ impostor tokens rather than eight real assets. Result discarded.
+2. Basket from GeckoTerminal top pools (28 distinct tokens) gated on DexScreener and GeckoTerminal
+ agreeing within 200 bps. Only 3 tokens survived, because GeckoTerminal returned no price for 25
+ of them. n=3 proves nothing.
+
+The idea remains the most promising new bench for this vertical, and the BONK case shows the signal
+is real. But it cannot be built on another aggregator as reference: the reference has to be computed
+from on-chain reserves of the deepest pool over an RPC we control, which is the actual work and the
+actual reason the bench would be defensible.
+
+
+## 17. Token-security bench: feasibility testing
+
+### 17.1 The cohort exists
+
+Six providers expose a live security endpoint, four of them keyless: Serialized, Mobula
+(`/api/2/token/security`), GoPlus (EVM and Solana), RugCheck (Solana), Honeypot.is. A leaderboard
+has enough rows on day one.
+
+### 17.2 Latency, ready to publish as-is (20 tokens, 4 chains)
+
+| Provider | p50 | p90 | max |
+|---|---|---|---|
+| RugCheck | 91 ms | 217 ms | 217 ms |
+| GoPlus | 249 ms | 376 ms | 597 ms |
+| Serialized | 306 ms | 847 ms | 2,416 ms |
+| **Mobula** | **5,240 ms** | 5,691 ms | **30,041 ms** |
+
+Mobula is 17x slower than Serialized and 57x slower than RugCheck. That is our own product at the
+bottom of a leaderboard we would be publishing, and it should be weighed before shipping this axis.
+
+### 17.3 Three design traps, all measured
+
+1. **Tax fields carry no signal.** Agreement on buy/sell tax: Serialized vs GoPlus 100% (n=22),
+ Serialized vs Mobula 90% (n=30), Mobula vs GoPlus 91% (n=22). A bench scoring tax accuracy is a
+ four-way tie.
+2. **LP fields are not comparable across vendors.** `serialized.lpBurnedPct` is LP burned,
+ `mobula.burnedHoldingsPercentage` is *token* burned, `rugcheck.lpLockedPct` is LP *locked*.
+ Three different quantities under similar names. Putting them in one column manufactures a false
+ ranking. The harness must define canonical fields and map each vendor explicitly.
+3. **Coverage alone is gameable.** A provider that always returns a number wins. Null rates measured
+ (share of nulls): Serialized top10 0%, taxes 25%, honeypot 100% (no such field);
+ Mobula taxes 5%, honeypot 30%, top10 100%; GoPlus taxes 40-50%; RugCheck LP 0%, rest 100%.
+
+### 17.4 Retrospective backtest: does the signal exist?
+
+Cohort of 128 tokens taken from `/v1/pulse?view=graduated` on Solana, Base and BNB. Cohort is defined
+by an event (graduation), not by survival, so no survivorship bias in selection. Split by current
+liquidity: 77 below $5k ("dead"), 19 above $50k ("alive"), 32 in between discarded.
+
+Median values, dead vs alive: `top10HoldersPct` 20.3 vs 10.0, `bundlersHoldingsPct` 51.6 vs 19.5,
+`holdersCount` 24 vs 239, `devHoldingsPct` 0.0 vs 79.3, `snipersHoldingsPct` 0.01 vs 98.4.
+
+**Age confound ruled out**: median age 0.8 h (dead) vs 1.0 h (alive), ratio 0.86.
+
+**But the test does not answer the prospective question.** Both cohorts are roughly one hour old, so
+"dead" means "never grew past $5k in its first hour", not "rugged over seven days". A retrospective
+query cannot substitute for snapshotting a verdict at mint and resolving the outcome later.
+
+### 17.5 Which fields are worth snapshotting (n=70)
+
+| Field | Distinct values | Read |
+|---|---|---|
+| `top10HoldersPct` | 68 / 70 (97%) | Genuine continuous measurement. Use it. |
+| `bundlersHoldingsPct` | 46 / 70 (66%) | Bimodal: 13 tokens at exactly 100.0, 13 at exactly 0.0. The 100.0 cluster tracked the dead group. Strongest candidate signal. |
+| `snipersHoldingsPct` | 45 / 70 (64%) | 26 zeros. Middling. |
+| `devHoldingsPct` | 21 / 70 (30%) | 42 zeros plus a 79.31 cluster appearing 7 times, a launchpad template signature. Low discriminative power on graduated tokens. |
+
+### 17.6 Cost constraint
+
+`token/security` costs 10 credits, `audit/contract` costs 750. At 200 fresh tokens a day the security
+endpoint costs ~60k credits a month, inside our 1M allowance. The audit endpoint is not benchmarkable
+at any useful cadence and must be excluded from the design and said so in the methodology.
+
+### 17.7 Recommended shape
+
+Layer 1, ship first: latency. No ground truth needed, 57x spread already measured.
+Layer 2: coverage against canonical fields we define, with the per-vendor mapping documented.
+Layer 3, the real bench: snapshot every provider's verdict at mint, resolve on-chain at T+7d
+(liquidity below 5% of peak, or LP pulled), publish recall and false-positive rate per provider.
+Snapshot `top10HoldersPct` and `bundlersHoldingsPct` as the primary signals per §17.5.
+
+
+## 18. Bench 001: Serialized is in (v1.3, 2026-09-06)
+
+Earlier sections called 001 "blocked on a policy decision". That was the wrong framing, and it hid a
+practical question nobody had tested: does Serialized's stream cover the four bench pools at all?
+
+Their trades stream is keyed by **token** with an optional `pools` filter, while the bench is keyed
+by **pool**. Subscribing by the pool's native side (SOL, WETH, WBNB) acknowledges and delivers
+nothing, consistent with their REST 404 on `So111...112`: the chain native is a quote asset to them,
+never a token. Their own `GET /v1/pool` names the other side under `token` (USDC on Solana and Base,
+BUSD on BNB, USDG on Robinhood). Subscribing by that address with `pools=` delivers the
+tape for exactly that market.
+
+| Chain | pool | subscribe by | events with `txHash` |
+|---|---|---|---|
+| solana | 7qbRF6... | USDC `EPjF...` | 4 in 75 s (1,694 token-wide) |
+| base | 0xd0b5... | USDC `0x8335...` | 10 in 90 s (173 token-wide) |
+| bnb | 0x58f8... | BUSD `0xe9e7...` | 2 in 90 s |
+| robinhood | 0x69bf... | USDG `0x5fc5...` | 79 in 90 s (3,888 token-wide) |
+
+One constraint from their official docs shaped the implementation: **5 concurrent connections per
+key**. The harness runs in three regions off one key, so the monitor opens one connection per
+process and multiplexes the four pools as subscriptions. A first test that opened eight connections
+was refused with close code 1008 ("connection limit (5 per key)"), which is also why an earlier
+Solana attempt looked like a failure.
+
+`harnesses/aggregator-head-lag/cmd/script/serialized_head_lag_monitor.go` records both series:
+`head_lag_seconds` from their own `at` (same treatment as Mobula and Codex, same negative filter), and
+`head_lag_ref_seconds` against the node reference clock matched by `txHash`, which is the one that can
+rank providers. Every event carries `txHash`, `block`, `poolAddress` and a `preconfirmed` boolean per
+their docs, so Base flashblocks are visible rather than inferred.
diff --git a/harnesses/aggregator-head-lag/cmd/script/config.go b/harnesses/aggregator-head-lag/cmd/script/config.go
index a3fd51143..57fe834e2 100644
--- a/harnesses/aggregator-head-lag/cmd/script/config.go
+++ b/harnesses/aggregator-head-lag/cmd/script/config.go
@@ -10,6 +10,7 @@ import (
type Config struct {
CoinGeckoAPIKey string
MobulaAPIKey string
+ SerializedAPIKey string
DefinedSessionCookie string
MonitorRegion string // Deployment region: us-west, us-east, singapore, etc.
MobulaWSURL string // Mobula fast-trade WebSocket endpoint (allows staging to use EU-specific cluster)
@@ -21,6 +22,7 @@ func loadEnv() (*Config, error) {
// First, try to load from environment variables (for production/Railway)
config.CoinGeckoAPIKey = strings.TrimSpace(os.Getenv("COINGECKO_API_KEY"))
config.MobulaAPIKey = strings.TrimSpace(os.Getenv("MOBULA_API_KEY"))
+ config.SerializedAPIKey = strings.TrimSpace(os.Getenv("SERIALIZED_API_KEY"))
config.DefinedSessionCookie = strings.TrimSpace(os.Getenv("DEFINED_SESSION_COOKIE"))
config.MonitorRegion = strings.TrimSpace(os.Getenv("MONITOR_REGION"))
config.MobulaWSURL = strings.TrimSpace(os.Getenv("MOBULA_WS_URL"))
diff --git a/harnesses/aggregator-head-lag/cmd/script/head_lag_monitor.go b/harnesses/aggregator-head-lag/cmd/script/head_lag_monitor.go
index a912795ee..e17f06bf6 100644
--- a/harnesses/aggregator-head-lag/cmd/script/head_lag_monitor.go
+++ b/harnesses/aggregator-head-lag/cmd/script/head_lag_monitor.go
@@ -214,6 +214,18 @@ func connectAndMonitorMobula(config *Config, stopChan <-chan struct{}) error {
// Total lag: on-chain → WebSocket receipt
totalLagMs := receiveTime.Sub(onChainTime).Milliseconds()
+ // Reference lag: same trade, but timed against the node
+ // subscription we hold ourselves rather than against the
+ // timestamp Mobula sent us. Recorded before the legacy
+ // filter below so a preconfirmed emission is counted rather
+ // than dropped. See reference_monitor.go.
+ refChainName := getChainNameFromBlockchain(trade.Blockchain)
+ if refAt, ok := reference.lookup(refChainName, trade.Hash); ok {
+ RecordHeadLagRef("mobula", refChainName, receiveTime.Sub(refAt).Seconds(), config.MonitorRegion)
+ } else {
+ RecordHeadLagRefMiss("mobula", refChainName, config.MonitorRegion)
+ }
+
// Drop WebSocket replays / clock-skew events: not real indexation latency
// (Mobula WS occasionally replays old trades on reconnect; those would otherwise fire alerts)
if totalLagMs < 0 || totalLagMs > 30000 {
@@ -711,6 +723,14 @@ func connectAndMonitorCodex(config *Config, stopChan <-chan struct{}) error {
// Get chain name
chainName := getChainNameFromNetworkID(networkID)
+ // Reference lag against our own node subscription, matched
+ // by transaction hash. See reference_monitor.go.
+ if refAt, ok := reference.lookup(chainName, event.TransactionHash); ok {
+ RecordHeadLagRef("codex", chainName, receiveTime.Sub(refAt).Seconds(), config.MonitorRegion)
+ } else {
+ RecordHeadLagRefMiss("codex", chainName, config.MonitorRegion)
+ }
+
lastEventMu.Lock()
lastEventByChain[chainName] = time.Now()
lastEventMu.Unlock()
@@ -776,6 +796,11 @@ func runHeadLagMonitor(config *Config, stopChan <-chan struct{}) {
// Start Mobula fast-trade monitor
wg.Add(1)
+ // The reference clock must be up before the provider monitors, so the
+ // first emissions have something to match against. It is never fatal:
+ // a chain with no endpoint simply leaves the ref series empty.
+ runReferenceMonitor(stopChan)
+
go runMobulaHeadLagMonitor(config, stopChan, &wg)
// Start Codex monitor
@@ -786,6 +811,9 @@ func runHeadLagMonitor(config *Config, stopChan <-chan struct{}) {
wg.Add(1)
go runGeckoTerminalHeadLagMonitor(config, stopChan, &wg)
+ wg.Add(1)
+ go runSerializedHeadLagMonitor(config, stopChan, &wg)
+
// Wait for all to finish
wg.Wait()
fmt.Println("[HEAD-LAG] All monitors stopped")
diff --git a/harnesses/aggregator-head-lag/cmd/script/metrics.go b/harnesses/aggregator-head-lag/cmd/script/metrics.go
index 55b14b407..3b6c7cc2e 100644
--- a/harnesses/aggregator-head-lag/cmd/script/metrics.go
+++ b/harnesses/aggregator-head-lag/cmd/script/metrics.go
@@ -2,10 +2,10 @@ package main
import (
"fmt"
- "net/http"
- "sync"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
+ "net/http"
+ "sync"
)
var (
@@ -29,11 +29,14 @@ var (
metadataAPILatency *prometheus.HistogramVec
// Head lag metrics
- headLagBlocks *prometheus.GaugeVec
- headLagSeconds *prometheus.GaugeVec
- blockchainHead *prometheus.GaugeVec
- aggregatorHead *prometheus.GaugeVec
- headLagErrors *prometheus.CounterVec
+ headLagBlocks *prometheus.GaugeVec
+ headLagSeconds *prometheus.GaugeVec
+ blockchainHead *prometheus.GaugeVec
+ aggregatorHead *prometheus.GaugeVec
+ headLagErrors *prometheus.CounterVec
+ headLagRefSeconds *prometheus.GaugeVec
+ headLagRefMatches *prometheus.CounterVec
+ refClockEntries prometheus.Gauge
// Fast-trade latency (for comparison with Pulse V2)
fastTradeLatency *prometheus.GaugeVec
@@ -187,6 +190,39 @@ func init() {
)
prometheus.MustRegister(headLagSeconds)
+ // Companion to head_lag_seconds, measured against our own node
+ // subscription instead of the timestamp each provider sends us. Same
+ // labels so the two are directly comparable. See reference_monitor.go
+ // for why the legacy series cannot be trusted as an absolute number.
+ headLagRefSeconds = prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Name: "head_lag_ref_seconds",
+ Help: "Indexation latency in seconds, measured from a node subscription we hold ourselves, matched by transaction hash.",
+ },
+ []string{"aggregator", "chain", "region"},
+ )
+ prometheus.MustRegister(headLagRefSeconds)
+
+ // How many provider emissions we could and could not match against the
+ // reference clock. A high miss rate means the reference subscription is
+ // lagging or disconnected and the ref series must not be trusted.
+ headLagRefMatches = prometheus.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "head_lag_ref_matches_total",
+ Help: "Provider trade emissions matched against the node reference clock, by outcome.",
+ },
+ []string{"aggregator", "chain", "region", "outcome"},
+ )
+ prometheus.MustRegister(headLagRefMatches)
+
+ refClockEntries = prometheus.NewGauge(
+ prometheus.GaugeOpts{
+ Name: "head_lag_ref_clock_entries",
+ Help: "Transactions currently held in the reference clock window.",
+ },
+ )
+ prometheus.MustRegister(refClockEntries)
+
// Blockchain head block number (source of truth)
blockchainHead = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
@@ -397,6 +433,51 @@ func RecordHeadLag(aggregator string, chain string, lagBlocks int64, lagSeconds
// tx_hash is logged but not stored as a metric label to avoid cardinality explosion
}
+// RecordHeadLagRef records head lag measured against our own node
+// subscription. Only called when the trade was actually seen by the
+// reference clock; an unmatched emission is counted as a miss and
+// deliberately produces no lag value, because falling back to the
+// provider's own timestamp is the defect this series exists to remove.
+//
+// The value is SIGNED and negatives are kept. Validated end to end
+// before shipping, on trades matched by hash at a 100% match rate:
+// against public endpoints (publicnode on Base, mainnet-beta on Solana)
+// Mobula delivers the trade BEFORE our subscription sees it, p50 -1.20 s
+// on Base and -0.32 s on Solana. That is not a provider being fast
+// enough to time travel, it is the public node being slower than the
+// provider's pipeline.
+//
+// The consequence for how this series must be read: the reference node's
+// own latency sits in every sample as a roughly constant offset, so the
+// ABSOLUTE number is not a head lag. The RELATIVE comparison is sound,
+// because every provider is measured against the same clock on the same
+// transaction, which is exactly what the legacy series cannot claim
+// (measured: the legacy method is off by 1,946 ms on Base and 331 ms on
+// Solana versus this one). Point REF_WS_URL_ at a paid or
+// colocated node to collapse the offset and make the absolute number
+// meaningful too.
+func RecordHeadLagRef(aggregator, chain string, lagSeconds float64, region string) {
+ if lagSeconds > 120 || lagSeconds < -120 {
+ headLagRefMatches.WithLabelValues(aggregator, chain, region, "out_of_range").Inc()
+ return
+ }
+ outcome := "matched"
+ if lagSeconds < 0 {
+ outcome = "ahead_of_reference"
+ }
+ headLagRefMatches.WithLabelValues(aggregator, chain, region, outcome).Inc()
+ headLagRefSeconds.WithLabelValues(aggregator, chain, region).Set(lagSeconds)
+}
+
+// RecordHeadLagRefMiss counts a provider emission the reference clock
+// never saw, so the match rate is auditable from the metrics alone.
+func RecordHeadLagRefMiss(aggregator, chain, region string) {
+ headLagRefMatches.WithLabelValues(aggregator, chain, region, "unmatched").Inc()
+}
+
+// RecordRefClockSize publishes the reference window occupancy.
+func RecordRefClockSize(n int) { refClockEntries.Set(float64(n)) }
+
// RecordBlockchainHead records the current blockchain head block number
func RecordBlockchainHead(chain string, blockNumber int64, region string) {
blockchainHead.WithLabelValues(chain, region).Set(float64(blockNumber))
diff --git a/harnesses/aggregator-head-lag/cmd/script/reference_monitor.go b/harnesses/aggregator-head-lag/cmd/script/reference_monitor.go
new file mode 100644
index 000000000..979ba5f64
--- /dev/null
+++ b/harnesses/aggregator-head-lag/cmd/script/reference_monitor.go
@@ -0,0 +1,290 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+// Reference clock for bench 001.
+//
+// The published methodology says, in three places, that head lag is
+// measured against canonical-tip archive nodes ("Reference: archive nodes
+// per chain, validated against block hashes"). The harness never did that:
+// both provider paths compute `receiveTime - `. Measured consequence, on trades matched by hash: the
+// same swap carries timestamps 707 ms apart on Solana and 1,000 ms apart
+// on Base depending on which provider you ask, so the leaderboard partly
+// ranks where each vendor places its clock rather than how fast its
+// pipeline is.
+//
+// This file supplies the missing reference. One WebSocket subscription per
+// monitored pool, straight to a node, timestamping every swap the instant
+// it reaches us. Provider emissions are then matched by transaction hash
+// against that single clock, so every provider is measured with the same
+// ruler.
+//
+// It publishes a NEW series (head_lag_ref_seconds) next to the existing
+// one rather than replacing it. The old series keeps its history and the
+// leaderboard keeps working while the two are compared; switching the
+// headline is a separate, documented change.
+
+// refWSURL returns the node endpoint for a chain, env-overridable so a
+// paid endpoint can replace the public one without a rebuild.
+func refWSURL(chainName string) string {
+ env := "REF_WS_URL_" + strings.ToUpper(chainName)
+ if v := strings.TrimSpace(os.Getenv(env)); v != "" {
+ return v
+ }
+ switch chainName {
+ case "base":
+ return "wss://base-rpc.publicnode.com"
+ case "bnb":
+ return "wss://bsc-rpc.publicnode.com"
+ case "solana":
+ // Measured 2026-09-05 before shipping: publicnode acknowledges
+ // logsSubscribe and then delivers nothing (0 events in 60 s on a
+ // pool the EVM equivalents were streaming), and drpc rejects the
+ // method outright on the free plan ("method is not available on
+ // free plan", code 35). mainnet-beta answers and delivers. It is
+ // rate limited, so a paid endpoint via REF_WS_URL_SOLANA is the
+ // right long-term answer.
+ return "wss://api.mainnet-beta.solana.com"
+ default:
+ // robinhood and anything else: no public endpoint we trust.
+ // Leaving it empty disables the reference for that chain rather
+ // than silently measuring against something arbitrary.
+ return ""
+ }
+}
+
+type refEntry struct {
+ at time.Time
+}
+
+type refClock struct {
+ mu sync.RWMutex
+ seen map[string]refEntry // "chain|lowercased tx hash" -> our observation time
+}
+
+var reference = &refClock{seen: map[string]refEntry{}}
+
+const (
+ refTTL = 10 * time.Minute
+ refMaxEntries = 200000
+ refSweepPeriod = 2 * time.Minute
+)
+
+func refKey(chain, hash string) string {
+ return chain + "|" + strings.ToLower(strings.TrimSpace(hash))
+}
+
+func (r *refClock) observe(chain, hash string, at time.Time) {
+ if hash == "" {
+ return
+ }
+ r.mu.Lock()
+ // Keep the FIRST observation. A log subscription can redeliver on
+ // reconnect and a later duplicate would understate every provider's
+ // lag on that trade.
+ k := refKey(chain, hash)
+ if _, ok := r.seen[k]; !ok {
+ r.seen[k] = refEntry{at: at}
+ }
+ r.mu.Unlock()
+}
+
+// lookup returns our observation time for a trade, and whether we saw it
+// at all. A miss is a miss: the caller must skip the sample rather than
+// fall back to the provider's own timestamp, which is the exact defect
+// this file exists to remove.
+func (r *refClock) lookup(chain, hash string) (time.Time, bool) {
+ r.mu.RLock()
+ e, ok := r.seen[refKey(chain, hash)]
+ r.mu.RUnlock()
+ return e.at, ok
+}
+
+func (r *refClock) sweep() {
+ cutoff := time.Now().Add(-refTTL)
+ r.mu.Lock()
+ if len(r.seen) > refMaxEntries {
+ r.seen = map[string]refEntry{}
+ r.mu.Unlock()
+ return
+ }
+ for k, e := range r.seen {
+ if e.at.Before(cutoff) {
+ delete(r.seen, k)
+ }
+ }
+ r.mu.Unlock()
+}
+
+func (r *refClock) size() int {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ return len(r.seen)
+}
+
+// runReferenceMonitor starts one subscription per monitored pool plus a
+// TTL sweeper. Never fatal: a chain without an endpoint, or a node that
+// refuses us, simply leaves head_lag_ref_seconds unpopulated for that
+// chain while the legacy series keeps running.
+func runReferenceMonitor(stopChan <-chan struct{}) {
+ fmt.Println("[HEAD-LAG][REF] starting node reference subscriptions")
+ go func() {
+ t := time.NewTicker(refSweepPeriod)
+ defer t.Stop()
+ for {
+ select {
+ case <-stopChan:
+ return
+ case <-t.C:
+ reference.sweep()
+ RecordRefClockSize(reference.size())
+ }
+ }
+ }()
+
+ for _, p := range headLagPools {
+ url := refWSURL(p.ChainName)
+ if url == "" {
+ fmt.Printf("[HEAD-LAG][REF][%s] no endpoint configured (set REF_WS_URL_%s), reference disabled for this chain\n",
+ p.ChainName, strings.ToUpper(p.ChainName))
+ continue
+ }
+ go refLoop(p, url, stopChan)
+ }
+}
+
+func refLoop(p HeadLagPool, url string, stopChan <-chan struct{}) {
+ backoff := 2 * time.Second
+ for {
+ select {
+ case <-stopChan:
+ return
+ default:
+ }
+ err := refConnect(p, url, stopChan)
+ if err != nil {
+ fmt.Printf("[HEAD-LAG][REF][%s] %v — reconnect in %v\n", p.ChainName, err, backoff)
+ }
+ select {
+ case <-stopChan:
+ return
+ case <-time.After(backoff):
+ }
+ if backoff < 60*time.Second {
+ backoff *= 2
+ }
+ }
+}
+
+func refConnect(p HeadLagPool, url string, stopChan <-chan struct{}) error {
+ // Deliberately NOT getProxyDialer: the reference clock must not share
+ // the scraping proxy. A saturated proxy would add its own latency to
+ // the reference and silently flatter every provider.
+ dialer := &websocket.Dialer{HandshakeTimeout: 15 * time.Second}
+ conn, _, err := dialer.Dial(url, nil)
+ if err != nil {
+ return fmt.Errorf("dial: %w", err)
+ }
+ defer conn.Close()
+
+ var sub any
+ if p.ChainName == "solana" {
+ sub = map[string]any{
+ "jsonrpc": "2.0", "id": 1, "method": "logsSubscribe",
+ "params": []any{
+ map[string]any{"mentions": []string{p.Address}},
+ map[string]any{"commitment": "confirmed"},
+ },
+ }
+ } else {
+ sub = map[string]any{
+ "jsonrpc": "2.0", "id": 1, "method": "eth_subscribe",
+ "params": []any{"logs", map[string]any{"address": p.Address}},
+ }
+ }
+ if err := conn.WriteJSON(sub); err != nil {
+ return fmt.Errorf("subscribe: %w", err)
+ }
+ fmt.Printf("[HEAD-LAG][REF][%s] subscribed to %s on %s\n", p.ChainName, p.Address, url)
+
+ go func() {
+ t := time.NewTicker(25 * time.Second)
+ defer t.Stop()
+ for {
+ select {
+ case <-stopChan:
+ return
+ case <-t.C:
+ if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil {
+ return
+ }
+ }
+ }
+ }()
+
+ for {
+ select {
+ case <-stopChan:
+ return nil
+ default:
+ }
+ _, msg, err := conn.ReadMessage()
+ if err != nil {
+ return fmt.Errorf("read: %w", err)
+ }
+ now := time.Now().UTC()
+
+ var env struct {
+ Method string `json:"method"`
+ Params struct {
+ Result json.RawMessage `json:"result"`
+ } `json:"params"`
+ }
+ if json.Unmarshal(msg, &env) != nil || len(env.Params.Result) == 0 {
+ continue
+ }
+
+ if p.ChainName == "solana" {
+ var r struct {
+ Value struct {
+ Signature string `json:"signature"`
+ Err any `json:"err"`
+ } `json:"value"`
+ }
+ if json.Unmarshal(env.Params.Result, &r) != nil {
+ continue
+ }
+ // Failed transactions never become a swap any provider will
+ // emit; counting them would create reference entries that are
+ // matched by nobody.
+ if r.Value.Err != nil || r.Value.Signature == "" {
+ continue
+ }
+ reference.observe(p.ChainName, r.Value.Signature, now)
+ continue
+ }
+
+ var r struct {
+ TransactionHash string `json:"transactionHash"`
+ Removed bool `json:"removed"`
+ }
+ if json.Unmarshal(env.Params.Result, &r) != nil {
+ continue
+ }
+ // A reorged-out log is not a trade.
+ if r.Removed || r.TransactionHash == "" {
+ continue
+ }
+ reference.observe(p.ChainName, r.TransactionHash, now)
+ }
+}
diff --git a/harnesses/aggregator-head-lag/cmd/script/serialized_head_lag_monitor.go b/harnesses/aggregator-head-lag/cmd/script/serialized_head_lag_monitor.go
new file mode 100644
index 000000000..29a350a58
--- /dev/null
+++ b/harnesses/aggregator-head-lag/cmd/script/serialized_head_lag_monitor.go
@@ -0,0 +1,345 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+// Serialized head-lag monitor.
+//
+// Serialized streams trades over one WebSocket, keyed by TOKEN with an
+// optional pool filter. That shape matters for this bench, which is keyed
+// by POOL: subscribing by the pool's native/wrapped side (SOL, WETH, WBNB)
+// acknowledges and then delivers nothing, because Serialized treats the
+// chain native as a quote asset and never as a token (their REST surface
+// 404s on So111...112 the same way). Subscribing by the pool's `token`
+// side as reported by their own GET /v1/pool, with `pools=`,
+// delivers the tape for exactly that market. Verified before shipping on
+// all four bench pools: base 10, bnb 2, robinhood 79 events with txHash
+// in 90 s. So the token address is resolved from /v1/pool at startup and
+// falls back to a pinned map only if that call fails.
+//
+// One connection per process, all pools multiplexed as subscriptions:
+// Serialized caps a key at 5 concurrent connections, and this harness
+// runs in three regions off one key.
+//
+// Lag is recorded twice, on purpose. `head_lag_seconds` uses the
+// provider's own `at`, exactly like the Mobula and Codex paths, so
+// Serialized gets the same treatment as the incumbents on the legacy
+// series (including its filter that drops negatives). `head_lag_ref_
+// seconds` uses the node reference clock matched by txHash, which is the
+// series that can actually compare providers. See reference_monitor.go.
+const serializedStreamURL = "wss://api.serialized.xyz/v1/stream"
+
+// serializedPinnedToken is the fallback when /v1/pool is unreachable at
+// boot. Values are the `token` side of each bench pool as Serialized
+// reports it (2026-09-06).
+var serializedPinnedToken = map[string]string{
+ "solana": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC
+ "base": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", // USDC
+ "bnb": "0xe9e7cea3dedca5984780bafc599bd69add087d56", // BUSD
+ "robinhood": "0x5fc5360d040013d5cba0d1de2a9c7e6c4c16b83c", // USDG (best effort; runtime resolve preferred)
+}
+
+type serializedStreamEvent struct {
+ Op string `json:"op"`
+ ID string `json:"id"`
+ Channel string `json:"channel"`
+ Error *struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ } `json:"error,omitempty"`
+ Data *struct {
+ TxHash string `json:"txHash"`
+ ID string `json:"id"`
+ At int64 `json:"at"`
+ Block int64 `json:"block"`
+ Preconfirmed bool `json:"preconfirmed"`
+ PoolAddress string `json:"poolAddress"`
+ } `json:"data,omitempty"`
+}
+
+// serializedResolveToken asks Serialized which side of the pool it treats
+// as the token. Their /v1/pool is free (0 credits).
+func serializedResolveToken(apiKey string, pool HeadLagPool) string {
+ client := &http.Client{Timeout: 10 * time.Second}
+ req, err := http.NewRequest("GET",
+ fmt.Sprintf("https://api.serialized.xyz/v1/pool?chain=%s&address=%s", pool.Blockchain, pool.Address), nil)
+ if err == nil {
+ req.Header.Set("Authorization", apiKey)
+ req.Header.Set("Accept", "application/json")
+ if resp, err := client.Do(req); err == nil {
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ var out struct {
+ Data struct {
+ Token struct {
+ Address string `json:"address"`
+ } `json:"token"`
+ } `json:"data"`
+ }
+ if resp.StatusCode == 200 && json.Unmarshal(body, &out) == nil && out.Data.Token.Address != "" {
+ return out.Data.Token.Address
+ }
+ }
+ }
+ return serializedPinnedToken[pool.ChainName]
+}
+
+func runSerializedHeadLagMonitor(config *Config, stopChan <-chan struct{}, wg *sync.WaitGroup) {
+ defer wg.Done()
+
+ if config.SerializedAPIKey == "" {
+ fmt.Println("[HEAD-LAG][SERIALIZED] SERIALIZED_API_KEY not set — monitor disabled")
+ RecordWSConnected("serialized", config.MonitorRegion, false)
+ return
+ }
+ fmt.Println("[HEAD-LAG][SERIALIZED] Starting WebSocket monitor...")
+
+ // pool address (lowercased) -> chain name, for events that carry a
+ // poolAddress we did not subscribe to (should not happen with the
+ // pools filter, but a misrouted event must never be scored on the
+ // wrong chain).
+ poolChain := map[string]string{}
+ tokens := map[string]string{}
+ for _, p := range headLagPools {
+ poolChain[strings.ToLower(p.Address)] = p.ChainName
+ tokens[p.ChainName] = serializedResolveToken(config.SerializedAPIKey, p)
+ fmt.Printf("[HEAD-LAG][SERIALIZED] %s: token side %s for pool %s\n", p.ChainName, tokens[p.ChainName], p.Address)
+ }
+
+ const baseDelay = 5 * time.Second
+ const maxDelay = 60 * time.Second
+ delay := baseDelay
+ attempt := 0
+
+ for {
+ select {
+ case <-stopChan:
+ return
+ default:
+ }
+ attempt++
+ err := serializedConnectAndStream(config, tokens, poolChain, stopChan)
+ RecordWSConnected("serialized", config.MonitorRegion, false)
+ if err != nil {
+ RecordWSReconnect("serialized", config.MonitorRegion)
+ log.Printf("[HEAD-LAG][SERIALIZED] ❌ attempt #%d ended: %v — reconnect in %v", attempt, err, delay)
+ msg := err.Error()
+ switch {
+ case strings.Contains(msg, "4401"):
+ log.Printf("[HEAD-LAG][SERIALIZED] 🔑 auth rejected (4401): key missing, invalid or revoked")
+ delay = maxDelay
+ case strings.Contains(msg, "4402"):
+ log.Printf("[HEAD-LAG][SERIALIZED] 💳 monthly quota exhausted (4402)")
+ delay = maxDelay
+ case strings.Contains(msg, "connection limit"):
+ log.Printf("[HEAD-LAG][SERIALIZED] 🚦 5-connections-per-key cap hit: another process is holding sockets on this key")
+ delay = maxDelay
+ default:
+ delay *= 2
+ if delay > maxDelay {
+ delay = maxDelay
+ }
+ }
+ } else {
+ delay = baseDelay
+ }
+ select {
+ case <-stopChan:
+ return
+ case <-time.After(delay):
+ }
+ }
+}
+
+func serializedConnectAndStream(config *Config, tokens map[string]string, poolChain map[string]string, stopChan <-chan struct{}) error {
+ // Plain dialer, not getProxyDialer: the scraping proxy is only for
+ // Defined.fi and would add its own latency to this feed.
+ dialer := &websocket.Dialer{HandshakeTimeout: 15 * time.Second}
+ conn, _, err := dialer.Dial(serializedStreamURL, nil)
+ if err != nil {
+ return fmt.Errorf("dial: %w", err)
+ }
+ defer conn.Close()
+
+ var writeMu sync.Mutex
+ send := func(v any) error {
+ writeMu.Lock()
+ defer writeMu.Unlock()
+ return conn.WriteJSON(v)
+ }
+
+ // Auth must be the first frame within 10 s.
+ if err := send(map[string]string{"op": "auth", "apiKey": config.SerializedAPIKey}); err != nil {
+ return fmt.Errorf("auth send: %w", err)
+ }
+ _ = conn.SetReadDeadline(time.Now().Add(15 * time.Second))
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ return fmt.Errorf("auth read: %w", err)
+ }
+ var ack serializedStreamEvent
+ if json.Unmarshal(raw, &ack) != nil || ack.Op != "auth.ok" {
+ return fmt.Errorf("auth not acknowledged: %s", strings.TrimSpace(string(raw)))
+ }
+
+ subscribed := 0
+ for _, p := range headLagPools {
+ tok := tokens[p.ChainName]
+ if tok == "" {
+ log.Printf("[HEAD-LAG][SERIALIZED] %s: no token side known for pool %s — skipped", p.ChainName, p.Address)
+ continue
+ }
+ if err := send(map[string]any{
+ "op": "subscribe", "channel": "trades", "id": p.ChainName,
+ "params": map[string]string{"chain": p.Blockchain, "address": tok, "pools": p.Address},
+ }); err != nil {
+ return fmt.Errorf("subscribe %s: %w", p.ChainName, err)
+ }
+ subscribed++
+ }
+ if subscribed == 0 {
+ return fmt.Errorf("no pool could be subscribed")
+ }
+ RecordWSConnected("serialized", config.MonitorRegion, true)
+ fmt.Printf("[HEAD-LAG][SERIALIZED] ✅ connected, %d pool subscriptions sent\n", subscribed)
+
+ // Keepalive: {"op":"ping"} every 25 s; server closes idle sockets at 60 s.
+ done := make(chan struct{})
+ defer close(done)
+ go func() {
+ t := time.NewTicker(25 * time.Second)
+ defer t.Stop()
+ for {
+ select {
+ case <-done:
+ return
+ case <-stopChan:
+ return
+ case <-t.C:
+ if err := send(map[string]string{"op": "ping"}); err != nil {
+ return
+ }
+ }
+ }
+ }()
+
+ // Per-chain flow watchdog, same policy as the Codex path: a bench pool
+ // silent for 10 min means the subscription is dead even if pongs keep
+ // the socket alive. Purge the gauge so the page never shows a frozen
+ // value, then force a redial.
+ var lastMu sync.Mutex
+ lastEvent := map[string]time.Time{}
+ for _, p := range headLagPools {
+ lastEvent[p.ChainName] = time.Now()
+ }
+ const flowSilence = 10 * time.Minute
+ go func() {
+ t := time.NewTicker(time.Minute)
+ defer t.Stop()
+ for {
+ select {
+ case <-done:
+ return
+ case <-t.C:
+ lastMu.Lock()
+ tripped := ""
+ for chain, last := range lastEvent {
+ if time.Since(last) > flowSilence {
+ tripped = chain
+ break
+ }
+ }
+ lastMu.Unlock()
+ if tripped != "" {
+ log.Printf("[HEAD-LAG][SERIALIZED] 🪦 %s silent for >%s — purging gauge and forcing reconnect", tripped, flowSilence)
+ DeleteHeadLagSeries("serialized", tripped, config.MonitorRegion)
+ _ = conn.Close()
+ return
+ }
+ }
+ }
+ }()
+
+ for {
+ select {
+ case <-stopChan:
+ return nil
+ default:
+ }
+ _ = conn.SetReadDeadline(time.Now().Add(90 * time.Second))
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ return fmt.Errorf("read: %w", err)
+ }
+ receiveTime := time.Now().UTC()
+
+ var ev serializedStreamEvent
+ if json.Unmarshal(raw, &ev) != nil {
+ continue
+ }
+ switch ev.Op {
+ case "pong", "subscribed", "auth.ok":
+ continue
+ case "error":
+ if ev.Error != nil {
+ log.Printf("[HEAD-LAG][SERIALIZED] ⚠️ stream error on %q: %s %s", ev.ID, ev.Error.Code, ev.Error.Message)
+ }
+ continue
+ case "event":
+ default:
+ continue
+ }
+ if ev.Data == nil {
+ continue
+ }
+ d := ev.Data
+ txHash := d.TxHash
+ if txHash == "" && d.ID != "" {
+ // Older events carry the hash only inside id as ":".
+ txHash = strings.SplitN(d.ID, ":", 2)[0]
+ }
+ if txHash == "" || d.At == 0 {
+ continue
+ }
+
+ chainName := ev.ID
+ if c, ok := poolChain[strings.ToLower(d.PoolAddress)]; ok && d.PoolAddress != "" {
+ chainName = c
+ }
+ if chainName == "" {
+ continue
+ }
+
+ lastMu.Lock()
+ lastEvent[chainName] = time.Now()
+ lastMu.Unlock()
+
+ // Legacy series: provider's own clock, identical treatment to the
+ // incumbents (RecordHeadLag drops negatives and >120 s itself).
+ lagSeconds := receiveTime.Sub(time.UnixMilli(d.At)).Seconds()
+ RecordHeadLag("serialized", chainName, 0, lagSeconds, config.MonitorRegion, txHash)
+
+ // Reference series: our node clock, matched by hash.
+ if refAt, ok := reference.lookup(chainName, txHash); ok {
+ RecordHeadLagRef("serialized", chainName, receiveTime.Sub(refAt).Seconds(), config.MonitorRegion)
+ } else {
+ RecordHeadLagRefMiss("serialized", chainName, config.MonitorRegion)
+ }
+ if d.Preconfirmed {
+ // Flashblocks preconfirmation on Base. Kept visible in the log;
+ // the ref series counts it under ahead_of_reference when it lands
+ // before our node sees the block.
+ log.Printf("[HEAD-LAG][SERIALIZED] ⚡ preconfirmed trade %s on %s (lag %.3fs vs own clock)", txHash[:10], chainName, lagSeconds)
+ }
+ }
+}
diff --git a/harnesses/metadata-coverage/cmd/script/config.go b/harnesses/metadata-coverage/cmd/script/config.go
index a3fd51143..57fe834e2 100644
--- a/harnesses/metadata-coverage/cmd/script/config.go
+++ b/harnesses/metadata-coverage/cmd/script/config.go
@@ -10,6 +10,7 @@ import (
type Config struct {
CoinGeckoAPIKey string
MobulaAPIKey string
+ SerializedAPIKey string
DefinedSessionCookie string
MonitorRegion string // Deployment region: us-west, us-east, singapore, etc.
MobulaWSURL string // Mobula fast-trade WebSocket endpoint (allows staging to use EU-specific cluster)
@@ -21,6 +22,7 @@ func loadEnv() (*Config, error) {
// First, try to load from environment variables (for production/Railway)
config.CoinGeckoAPIKey = strings.TrimSpace(os.Getenv("COINGECKO_API_KEY"))
config.MobulaAPIKey = strings.TrimSpace(os.Getenv("MOBULA_API_KEY"))
+ config.SerializedAPIKey = strings.TrimSpace(os.Getenv("SERIALIZED_API_KEY"))
config.DefinedSessionCookie = strings.TrimSpace(os.Getenv("DEFINED_SESSION_COOKIE"))
config.MonitorRegion = strings.TrimSpace(os.Getenv("MONITOR_REGION"))
config.MobulaWSURL = strings.TrimSpace(os.Getenv("MOBULA_WS_URL"))
diff --git a/harnesses/metadata-coverage/cmd/script/logo_resolve.go b/harnesses/metadata-coverage/cmd/script/logo_resolve.go
new file mode 100644
index 000000000..61a0065bb
--- /dev/null
+++ b/harnesses/metadata-coverage/cmd/script/logo_resolve.go
@@ -0,0 +1,150 @@
+package main
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+// The bench scores the logo field as "did the provider return a non-empty
+// string". That is not the same question as "does this token have a logo",
+// and the difference is not academic: Mobula rewrites every logo onto
+// metadata.mobula.io at a deterministic path derived from chain and
+// address, so its logo field is non-empty for every token by construction,
+// whether or not an image exists behind the URL. Providers that return the
+// upstream source URL (ipfs.io, cdn.dexscreener.com, launchpad CDNs, twimg)
+// are scored on whether the upstream actually has the asset.
+//
+// Measured when this was written: Mobula 100% logo on all three chains,
+// against 22.9% / 37.8% / 78.9% for a provider returning upstream URLs. A
+// HEAD sweep of 12 distinct Mobula logo URLs resolved 11 and 404'd 1.
+//
+// Any provider can win the current rule by rewriting to its own CDN, and
+// the current beneficiary is our own product, so this is published as a
+// SEPARATE `logo_resolved` field rather than silently redefining `logo`.
+// The existing series and its history stay intact; the stricter one builds
+// alongside until there is enough of it to move the headline in a
+// documented change.
+
+var logoHTTPClient = &http.Client{
+ Timeout: 4 * time.Second,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 4 {
+ return http.ErrUseLastResponse
+ }
+ return nil
+ },
+}
+
+type logoCacheEntry struct {
+ ok bool
+ at time.Time
+}
+
+var (
+ logoCacheMu sync.Mutex
+ logoCache = map[string]logoCacheEntry{}
+)
+
+const (
+ logoCacheTTL = 6 * time.Hour
+ logoCacheMaxSize = 20000
+)
+
+// logoResolves reports whether the URL actually serves an image. Empty
+// URLs are false without a request. Results are cached because launchpad
+// and CDN URLs repeat heavily across fresh tokens and we should not hammer
+// third-party hosts from a monitor.
+func logoResolves(rawURL string) bool {
+ u := strings.TrimSpace(rawURL)
+ if u == "" {
+ return false
+ }
+ if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
+ // data: URIs and relative paths are not verifiable from here.
+ // Count them as unresolved rather than silently passing.
+ return false
+ }
+
+ logoCacheMu.Lock()
+ if e, ok := logoCache[u]; ok && time.Since(e.at) < logoCacheTTL {
+ logoCacheMu.Unlock()
+ return e.ok
+ }
+ logoCacheMu.Unlock()
+
+ ok := probeLogo(u)
+
+ logoCacheMu.Lock()
+ if len(logoCache) >= logoCacheMaxSize {
+ logoCache = map[string]logoCacheEntry{}
+ }
+ logoCache[u] = logoCacheEntry{ok: ok, at: time.Now()}
+ logoCacheMu.Unlock()
+ return ok
+}
+
+func probeLogo(u string) bool {
+ ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodHead, u, nil)
+ if err != nil {
+ return false
+ }
+ req.Header.Set("User-Agent", "OpenChainBench-logo-probe/1.0 (+https://openchainbench.com)")
+ resp, err := logoHTTPClient.Do(req)
+ if err == nil {
+ defer resp.Body.Close()
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return imageish(resp.Header.Get("Content-Type"))
+ }
+ // A number of CDNs reject HEAD with 403/405 while serving GET
+ // fine. Retry those with a 1-byte ranged GET rather than
+ // recording a false negative.
+ if resp.StatusCode != http.StatusMethodNotAllowed && resp.StatusCode != http.StatusForbidden {
+ return false
+ }
+ }
+
+ ctx2, cancel2 := context.WithTimeout(context.Background(), 4*time.Second)
+ defer cancel2()
+ req2, err := http.NewRequestWithContext(ctx2, http.MethodGet, u, nil)
+ if err != nil {
+ return false
+ }
+ req2.Header.Set("User-Agent", "OpenChainBench-logo-probe/1.0 (+https://openchainbench.com)")
+ req2.Header.Set("Range", "bytes=0-0")
+ resp2, err := logoHTTPClient.Do(req2)
+ if err != nil {
+ return false
+ }
+ defer resp2.Body.Close()
+ if resp2.StatusCode >= 200 && resp2.StatusCode < 300 {
+ return imageish(resp2.Header.Get("Content-Type"))
+ }
+ return false
+}
+
+// imageish accepts anything that plausibly renders in an . An empty
+// Content-Type is accepted because several IPFS gateways omit it on
+// ranged responses; a hard reject there would penalise providers that
+// return honest upstream URLs, which is the opposite of the point.
+func imageish(ct string) bool {
+ c := strings.ToLower(strings.TrimSpace(ct))
+ if c == "" {
+ return true
+ }
+ if i := strings.IndexByte(c, ';'); i >= 0 {
+ c = strings.TrimSpace(c[:i])
+ }
+ switch {
+ case strings.HasPrefix(c, "image/"):
+ return true
+ case c == "binary/octet-stream", c == "application/octet-stream":
+ return true
+ }
+ return false
+}
diff --git a/harnesses/metadata-coverage/cmd/script/metadata_coverage_monitor.go b/harnesses/metadata-coverage/cmd/script/metadata_coverage_monitor.go
index 106898ecd..d17018169 100644
--- a/harnesses/metadata-coverage/cmd/script/metadata_coverage_monitor.go
+++ b/harnesses/metadata-coverage/cmd/script/metadata_coverage_monitor.go
@@ -65,18 +65,20 @@ type ProviderCoverage struct {
// MetadataCoverageStats holds overall stats
type MetadataCoverageStats struct {
- mu sync.Mutex
- Mobula ProviderCoverage
- Codex ProviderCoverage
- Jupiter ProviderCoverage
- LastPrint time.Time
+ mu sync.Mutex
+ Mobula ProviderCoverage
+ Codex ProviderCoverage
+ Jupiter ProviderCoverage
+ Serialized ProviderCoverage
+ LastPrint time.Time
}
var (
coverageStats = &MetadataCoverageStats{
- Mobula: ProviderCoverage{Provider: "mobula"},
- Codex: ProviderCoverage{Provider: "codex"},
- Jupiter: ProviderCoverage{Provider: "jupiter"},
+ Mobula: ProviderCoverage{Provider: "mobula"},
+ Codex: ProviderCoverage{Provider: "codex"},
+ Jupiter: ProviderCoverage{Provider: "jupiter"},
+ Serialized: ProviderCoverage{Provider: "serialized"},
}
tokenQueue = make(chan TokenToCheck, 500)
metadataClient = &http.Client{Timeout: 10 * time.Second}
@@ -194,12 +196,12 @@ type CodexTokenResponse struct {
// CodexEnhancedToken matches the EnhancedToken type from Codex API
type CodexEnhancedToken struct {
- Address string `json:"address"`
- Name string `json:"name"`
- Symbol string `json:"symbol"`
- Decimals int `json:"decimals"`
- NetworkID int `json:"networkId"`
- Info *CodexTokenInfo `json:"info"`
+ Address string `json:"address"`
+ Name string `json:"name"`
+ Symbol string `json:"symbol"`
+ Decimals int `json:"decimals"`
+ NetworkID int `json:"networkId"`
+ Info *CodexTokenInfo `json:"info"`
SocialLinks *CodexSocialLinks `json:"socialLinks"`
}
@@ -215,11 +217,11 @@ type CodexTokenInfo struct {
// CodexSocialLinks contains social media links for the token
type CodexSocialLinks struct {
- Twitter string `json:"twitter"`
- Website string `json:"website"`
- Telegram string `json:"telegram"`
- Discord string `json:"discord"`
- Github string `json:"github"`
+ Twitter string `json:"twitter"`
+ Website string `json:"website"`
+ Telegram string `json:"telegram"`
+ Discord string `json:"discord"`
+ Github string `json:"github"`
}
func getCodexNetworkID(chainID string) int {
@@ -553,6 +555,8 @@ func updateStats(provider string, fields MetadataFields) {
stats = &coverageStats.Codex
case "jupiter":
stats = &coverageStats.Jupiter
+ case "serialized":
+ stats = &coverageStats.Serialized
default:
return
}
@@ -601,7 +605,7 @@ func printCoverageStats() {
fmt.Printf("║ Provider │ Checks │ Logo │ Name │ Symbol│ Desc │Twitter│Website│Telegram│ Errors │\n")
fmt.Printf("╠══════════════════════════════════════════════════════════════════════════════╣\n")
- for _, stats := range []*ProviderCoverage{&coverageStats.Mobula, &coverageStats.Codex, &coverageStats.Jupiter} {
+ for _, stats := range []*ProviderCoverage{&coverageStats.Mobula, &coverageStats.Codex, &coverageStats.Jupiter, &coverageStats.Serialized} {
if stats.TotalChecks == 0 {
fmt.Printf("║ %-8s │ %6d │ - │ - │ - │ - │ - │ - │ - │ %6d ║\n",
stats.Provider, stats.TotalChecks, stats.ErrorCount)
@@ -657,6 +661,7 @@ func checkTokenMetadata(token TokenToCheck, config *Config) {
// Record Prometheus metrics for Mobula
RecordMetadataCoverage("mobula", chainName, "logo", mobulaResult.HasLogo, config.MonitorRegion)
+ RecordMetadataCoverage("mobula", chainName, "logo_resolved", mobulaResult.HasLogo && logoResolves(mobulaResult.LogoURL), config.MonitorRegion)
RecordMetadataCoverage("mobula", chainName, "description", mobulaResult.HasDescription, config.MonitorRegion)
RecordMetadataCoverage("mobula", chainName, "twitter", mobulaResult.HasTwitter, config.MonitorRegion)
RecordMetadataCoverage("mobula", chainName, "website", mobulaResult.HasWebsite, config.MonitorRegion)
@@ -672,6 +677,7 @@ func checkTokenMetadata(token TokenToCheck, config *Config) {
// Record Prometheus metrics for Codex
RecordMetadataCoverage("codex", chainName, "logo", codexResult.HasLogo, config.MonitorRegion)
+ RecordMetadataCoverage("codex", chainName, "logo_resolved", codexResult.HasLogo && logoResolves(codexResult.LogoURL), config.MonitorRegion)
RecordMetadataCoverage("codex", chainName, "description", codexResult.HasDescription, config.MonitorRegion)
RecordMetadataCoverage("codex", chainName, "twitter", codexResult.HasTwitter, config.MonitorRegion)
RecordMetadataCoverage("codex", chainName, "website", codexResult.HasWebsite, config.MonitorRegion)
@@ -685,12 +691,31 @@ func checkTokenMetadata(token TokenToCheck, config *Config) {
// Record Prometheus metrics for Jupiter
RecordMetadataCoverage("jupiter", chainName, "logo", jupiterResult.HasLogo, config.MonitorRegion)
+ RecordMetadataCoverage("jupiter", chainName, "logo_resolved", jupiterResult.HasLogo && logoResolves(jupiterResult.LogoURL), config.MonitorRegion)
RecordMetadataCoverage("jupiter", chainName, "description", jupiterResult.HasDescription, config.MonitorRegion)
RecordMetadataCoverage("jupiter", chainName, "twitter", jupiterResult.HasTwitter, config.MonitorRegion)
RecordMetadataCoverage("jupiter", chainName, "website", jupiterResult.HasWebsite, config.MonitorRegion)
RecordMetadataLatency("jupiter", chainName, jupiterResult.ResponseTimeMs, config.MonitorRegion)
}
+ // Check Serialized (18 EVM chains + Solana; skipped elsewhere)
+ var serializedResult MetadataFields
+ if _, supported := serializedChainID(token.ChainID); supported {
+ serializedResult = checkSerializedMetadata(token, config.SerializedAPIKey)
+ if serializedResult.Error != "" {
+ fmt.Printf("[META][SERIALIZED][%s] %s | %s | err=%s\n",
+ chainName, token.Symbol, token.Address, serializedResult.Error)
+ }
+ updateStats("serialized", serializedResult)
+
+ RecordMetadataCoverage("serialized", chainName, "logo", serializedResult.HasLogo, config.MonitorRegion)
+ RecordMetadataCoverage("serialized", chainName, "logo_resolved", serializedResult.HasLogo && logoResolves(serializedResult.LogoURL), config.MonitorRegion)
+ RecordMetadataCoverage("serialized", chainName, "description", serializedResult.HasDescription, config.MonitorRegion)
+ RecordMetadataCoverage("serialized", chainName, "twitter", serializedResult.HasTwitter, config.MonitorRegion)
+ RecordMetadataCoverage("serialized", chainName, "website", serializedResult.HasWebsite, config.MonitorRegion)
+ RecordMetadataLatency("serialized", chainName, serializedResult.ResponseTimeMs, config.MonitorRegion)
+ }
+
// Single condensed log line
boolToIcon := func(b bool) string {
if b {
@@ -709,11 +734,17 @@ func checkTokenMetadata(token TokenToCheck, config *Config) {
// without cross-referencing logs. Address goes after symbol; 4 boolean
// columns per provider so website is visible alongside logo/desc/twitter
// (the page renders 4 fields, the prior 3-column line hid that one).
- fmt.Printf("[META] %s/%s %s | M:%s%s%s%s | C:%s%s%s%s | J:%s\n",
+ serializedCols := "----"
+ if _, supported := serializedChainID(token.ChainID); supported {
+ serializedCols = boolToIcon(serializedResult.HasLogo) + boolToIcon(serializedResult.HasDescription) +
+ boolToIcon(serializedResult.HasTwitter) + boolToIcon(serializedResult.HasWebsite)
+ }
+
+ fmt.Printf("[META] %s/%s %s | M:%s%s%s%s | C:%s%s%s%s | J:%s | S:%s\n",
token.Symbol, chainName, token.Address,
boolToIcon(mobulaResult.HasLogo), boolToIcon(mobulaResult.HasDescription), boolToIcon(mobulaResult.HasTwitter), boolToIcon(mobulaResult.HasWebsite),
boolToIcon(codexResult.HasLogo), boolToIcon(codexResult.HasDescription), boolToIcon(codexResult.HasTwitter), boolToIcon(codexResult.HasWebsite),
- jupiterLogo)
+ jupiterLogo, serializedCols)
// Print stats every 50 checks (reduced from 10)
coverageStats.mu.Lock()
@@ -808,4 +839,3 @@ func runMetadataCoverageMonitor(config *Config, stopChan <-chan struct{}) {
}
}
}
-
diff --git a/harnesses/metadata-coverage/cmd/script/serialized_rest_monitor.go b/harnesses/metadata-coverage/cmd/script/serialized_rest_monitor.go
new file mode 100644
index 000000000..812b134ea
--- /dev/null
+++ b/harnesses/metadata-coverage/cmd/script/serialized_rest_monitor.go
@@ -0,0 +1,162 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+)
+
+// ============================================================================
+// Serialized — token metadata coverage
+//
+// GET /v1/token/metadata?chain=&address= returns the four
+// canonical fields this bench scores, under different names than Mobula
+// and Codex:
+//
+// logo -> iconUrl
+// description -> description
+// twitter -> twitterUrl
+// website -> websiteUrl
+//
+// Chain ids are already in the bench's own shape ("solana", "evm:56",
+// "evm:8453"), so no translation table is needed beyond normalising the
+// legacy "solana:solana" form that Pulse V2 sometimes emits.
+//
+// One asymmetry worth knowing when reading the leaderboard: Serialized
+// returns the *upstream* icon URL (ipfs.io, cdn.dexscreener.com, twimg,
+// launchpad CDNs) while Mobula rewrites every logo onto its own CDN at a
+// deterministic path, so Mobula's logo field is non-empty by construction.
+// The bench currently scores "field non-empty", not "image resolves".
+// See docs/methodology/serialized-onboarding-audit.md §6.
+// ============================================================================
+
+const serializedTokenMetadataURL = "https://api.serialized.xyz/v1/token/metadata"
+
+// Serialized enforces a hard burst cap of 40 requests per second per key
+// and returns 429 above it. The queue-driven monitor can burst well past
+// that during a launch spike, which would show up as coverage loss rather
+// than as a rate-limit error. Pace the calls at a fixed floor instead.
+var (
+ serializedMetaMu sync.Mutex
+ serializedMetaLast time.Time
+)
+
+const serializedMetaMinInterval = 60 * time.Millisecond // ~16 rps against a 40 rps cap
+
+func serializedMetaThrottle() {
+ serializedMetaMu.Lock()
+ defer serializedMetaMu.Unlock()
+ if wait := time.Until(serializedMetaLast.Add(serializedMetaMinInterval)); wait > 0 {
+ time.Sleep(wait)
+ }
+ serializedMetaLast = time.Now()
+}
+
+// serializedChainID normalises the bench's chain id to what Serialized
+// accepts. Returns false when the chain is outside their coverage, so the
+// caller skips the check instead of recording a miss.
+func serializedChainID(chainID string) (string, bool) {
+ c := chainID
+ if c == "solana:solana" {
+ c = "solana"
+ }
+ if c == "solana" {
+ return c, true
+ }
+ if !strings.HasPrefix(c, "evm:") {
+ return "", false
+ }
+ // 18 EVM chains, live as of onboarding (2026-09-05).
+ switch c {
+ case "evm:1", "evm:56", "evm:130", "evm:143", "evm:196", "evm:988",
+ "evm:1514", "evm:2741", "evm:4217", "evm:4326", "evm:4663",
+ "evm:5042", "evm:8453", "evm:9745", "evm:42161", "evm:43114",
+ "evm:57073", "evm:645749":
+ return c, true
+ }
+ return "", false
+}
+
+type SerializedTokenMetadataResponse struct {
+ Data struct {
+ Name string `json:"name"`
+ Symbol string `json:"symbol"`
+ IconURL string `json:"iconUrl"`
+ Description string `json:"description"`
+ TwitterURL string `json:"twitterUrl"`
+ WebsiteURL string `json:"websiteUrl"`
+ TelegramURL string `json:"telegramUrl"`
+ } `json:"data"`
+}
+
+func checkSerializedMetadata(token TokenToCheck, apiKey string) MetadataFields {
+ result := MetadataFields{}
+
+ chain, ok := serializedChainID(token.ChainID)
+ if !ok {
+ result.Error = "chain_unsupported"
+ return result
+ }
+ if apiKey == "" {
+ result.Error = "no_api_key"
+ return result
+ }
+
+ serializedMetaThrottle()
+
+ params := url.Values{}
+ params.Add("chain", chain)
+ params.Add("address", token.Address)
+
+ req, err := http.NewRequest("GET", fmt.Sprintf("%s?%s", serializedTokenMetadataURL, params.Encode()), nil)
+ if err != nil {
+ result.Error = fmt.Sprintf("request_create_error: %v", err)
+ return result
+ }
+ // Raw key, no Bearer prefix — a prefixed key is rejected with 401.
+ req.Header.Set("Authorization", apiKey)
+ req.Header.Set("Accept", "application/json")
+
+ startTime := time.Now()
+ resp, err := metadataClient.Do(req)
+ result.ResponseTimeMs = float64(time.Since(startTime).Milliseconds())
+ if err != nil {
+ result.Error = fmt.Sprintf("request_error: %v", err)
+ return result
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ result.Error = fmt.Sprintf("status_%d", resp.StatusCode)
+ return result
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ result.Error = fmt.Sprintf("read_error: %v", err)
+ return result
+ }
+
+ var response SerializedTokenMetadataResponse
+ if err := json.Unmarshal(body, &response); err != nil {
+ result.Error = fmt.Sprintf("parse_error: %v", err)
+ return result
+ }
+
+ d := response.Data
+ result.HasName = d.Name != ""
+ result.HasSymbol = d.Symbol != ""
+ result.HasLogo = d.IconURL != ""
+ result.LogoURL = d.IconURL
+ result.HasDescription = d.Description != ""
+ result.HasTwitter = d.TwitterURL != ""
+ result.HasWebsite = d.WebsiteURL != ""
+ result.HasTelegram = d.TelegramURL != ""
+
+ return result
+}
diff --git a/harnesses/network-coverage/cmd/script/config.go b/harnesses/network-coverage/cmd/script/config.go
index 83d27ab8d..99c7310f2 100644
--- a/harnesses/network-coverage/cmd/script/config.go
+++ b/harnesses/network-coverage/cmd/script/config.go
@@ -13,6 +13,7 @@ type Config struct {
CodexSessionCookie string // fallback path: mint JWT from Defined.fi cookie
DefinedTokenURL string // optional: pre-minted JWT sidecar
CoinStatsAPIKey string
+ SerializedAPIKey string
SimDuneAPIKey string // optional — Sim's public endpoint works keyless, but a key avoids rate limits
HTTPProxy string
RefreshInterval time.Duration
@@ -26,6 +27,7 @@ func loadConfig() *Config {
CodexSessionCookie: os.Getenv("DEFINED_SESSION_COOKIE"),
DefinedTokenURL: os.Getenv("DEFINED_TOKEN_SERVICE_URL"),
CoinStatsAPIKey: os.Getenv("COINSTATS_API_KEY"),
+ SerializedAPIKey: os.Getenv("SERIALIZED_API_KEY"),
SimDuneAPIKey: os.Getenv("SIM_DUNE_API_KEY"),
HTTPProxy: os.Getenv("HTTP_PROXY"),
RefreshInterval: 6 * time.Hour,
@@ -47,8 +49,8 @@ func loadConfig() *Config {
} else if c.CodexSessionCookie != "" {
codexAuth = "cookie+mint"
}
- fmt.Printf("Config: refresh=%v, testnets=%v, mobula_key=%v, codex=%s, coinstats_key=%v, sim_dune_key=%v\n",
+ fmt.Printf("Config: refresh=%v, testnets=%v, mobula_key=%v, codex=%s, coinstats_key=%v, sim_dune_key=%v, serialized_key=%v\n",
c.RefreshInterval, c.IncludeTestnets, c.MobulaAPIKey != "", codexAuth,
- c.CoinStatsAPIKey != "", c.SimDuneAPIKey != "")
+ c.CoinStatsAPIKey != "", c.SimDuneAPIKey != "", c.SerializedAPIKey != "")
return c
}
diff --git a/harnesses/network-coverage/cmd/script/main.go b/harnesses/network-coverage/cmd/script/main.go
index 885a9ffad..20693522d 100644
--- a/harnesses/network-coverage/cmd/script/main.go
+++ b/harnesses/network-coverage/cmd/script/main.go
@@ -76,6 +76,7 @@ func fetchAll(cfg *Config) {
{"coinstats", fetchCoinStats},
{"coingecko", fetchCoinGecko},
{"dexpaprika", fetchDexPaprika},
+ {"serialized", fetchSerialized},
}
var wg sync.WaitGroup
diff --git a/harnesses/network-coverage/cmd/script/serialized.go b/harnesses/network-coverage/cmd/script/serialized.go
new file mode 100644
index 000000000..9ad325b64
--- /dev/null
+++ b/harnesses/network-coverage/cmd/script/serialized.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+// Serialized publishes its chain list at GET /v1/meta/chains. The endpoint
+// is free (0 credits) and returns one row per chain with a `status` field.
+//
+// The same list answers both benches this harness feeds: Serialized runs its
+// own indexers and does not separate "chains where we know tokens" from
+// "chains where we index DEX pools" — every listed chain carries both. So
+// the count is identical on bench 005 and bench 090 by construction, which
+// is worth knowing when reading the two leaderboards side by side.
+const serializedChainsURL = "https://api.serialized.xyz/v1/meta/chains"
+
+type serializedChain struct {
+ Chain string `json:"chain"` // "evm:8453" or "solana"
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Family string `json:"family"`
+ Status string `json:"status"`
+}
+
+type serializedChainsResponse struct {
+ Data []serializedChain `json:"data"`
+}
+
+func fetchSerialized(cfg *Config) ProviderResult {
+ res := ProviderResult{Provider: "serialized"}
+ if cfg.SerializedAPIKey == "" {
+ res.Err = "missing_api_key"
+ return res
+ }
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ req, _ := http.NewRequest("GET", serializedChainsURL, nil)
+ // Raw key, no Bearer prefix — a prefixed key is rejected with 401.
+ req.Header.Set("Authorization", cfg.SerializedAPIKey)
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ res.Err = fmt.Sprintf("request_error: %v", err)
+ return res
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+
+ if resp.StatusCode != 200 {
+ res.Err = fmt.Sprintf("status_%d", resp.StatusCode)
+ return res
+ }
+
+ var parsed serializedChainsResponse
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ res.Err = fmt.Sprintf("parse_error: %v", err)
+ return res
+ }
+
+ for _, c := range parsed.Data {
+ // Only chains the provider declares live. Everything on this
+ // endpoint is mainnet, so no testnet filter is needed, but a
+ // future "beta"/"deprecated" status must not inflate the count.
+ if c.Status != "live" {
+ continue
+ }
+ res.Networks = append(res.Networks, Network{
+ ChainID: c.Chain,
+ Slug: c.Slug,
+ Name: c.Name,
+ })
+ }
+
+ return res
+}
diff --git a/harnesses/solana-dex-volume/cmd/script/main.go b/harnesses/solana-dex-volume/cmd/script/main.go
index e04d20498..18d017c9a 100644
--- a/harnesses/solana-dex-volume/cmd/script/main.go
+++ b/harnesses/solana-dex-volume/cmd/script/main.go
@@ -3,7 +3,11 @@
// Polls the DeFiLlama DEX API every 30 minutes for each tracked platform
// and exposes per-platform 24h/7d volume and protocol revenue as Prometheus gauges.
//
-// No API key required. Endpoint: https://api.llama.fi/summary/dexs/{slug}
+// No API key required. Primary source: https://api.llama.fi/overview/dexs/solana
+// (chain-scoped, so multichain platforms like GMGN report their SOLANA
+// volume only, keeping the bench apples-to-apples). Falls back to the
+// per-protocol summary endpoint for any platform missing from the
+// overview.
//
// Metrics on :2112/metrics:
//
@@ -22,6 +26,7 @@ import (
"net/http"
"os"
"os/signal"
+ "strings"
"syscall"
"time"
@@ -36,16 +41,17 @@ const (
)
var platforms = []struct {
- slug string
- label string
+ slug string // per-protocol summary slug (fallback path)
+ label string // prometheus label
+ names []string // lowercase name candidates in the chain overview
}{
- {"pump.fun", "pump-fun"},
- {"gmgn", "gmgn"},
- {"axiom", "axiom"},
- {"fomo-wallet", "fomo"},
- {"trojan", "trojan"},
- {"photon", "photon"},
- {"bullx", "bullx"},
+ {"pump.fun", "pump-fun", []string{"pump.fun"}},
+ {"gmgn", "gmgn", []string{"gmgn"}},
+ {"axiom", "axiom", []string{"axiom"}},
+ {"fomo-wallet", "fomo", []string{"fomo", "fomo-wallet"}},
+ {"trojan", "trojan", []string{"trojan"}},
+ {"photon", "photon", []string{"photon"}},
+ {"bullx", "bullx", []string{"bullx"}},
}
var (
@@ -85,6 +91,60 @@ type llamaResponse struct {
Total7d float64 `json:"total7d"`
}
+type overviewResponse struct {
+ Protocols []struct {
+ Name string `json:"name"`
+ DisplayName string `json:"displayName"`
+ Module string `json:"module"`
+ Total24h float64 `json:"total24h"`
+ Total7d float64 `json:"total7d"`
+ } `json:"protocols"`
+}
+
+// fetchOverview returns Solana-scoped per-protocol totals indexed by
+// lowercased name/displayName/module. One call covers every platform.
+func fetchOverview(endpoint, query string) (map[string]llamaResponse, error) {
+ url := fmt.Sprintf("%s/overview/%s/solana?excludeTotalDataChart=true&excludeTotalDataChartBreakdown=true", baseURL, endpoint)
+ if query != "" {
+ url += "&" + query
+ }
+ resp, err := http.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ var r overviewResponse
+ if err := json.Unmarshal(body, &r); err != nil {
+ return nil, err
+ }
+ out := map[string]llamaResponse{}
+ for _, p := range r.Protocols {
+ v := llamaResponse{Total24h: p.Total24h, Total7d: p.Total7d}
+ for _, k := range []string{p.Name, p.DisplayName, p.Module} {
+ if k != "" {
+ out[strings.ToLower(k)] = v
+ }
+ }
+ }
+ return out, nil
+}
+
+func lookup(ov map[string]llamaResponse, names []string) (llamaResponse, bool) {
+ for _, n := range names {
+ if v, ok := ov[n]; ok {
+ return v, true
+ }
+ }
+ return llamaResponse{}, false
+}
+
func fetch(endpoint, slug, query string) (llamaResponse, error) {
url := fmt.Sprintf("%s/summary/%s/%s", baseURL, endpoint, slug)
if query != "" {
@@ -110,17 +170,37 @@ func fetch(endpoint, slug, query string) (llamaResponse, error) {
}
func runOnce() {
+ // Chain-scoped overviews first: one request each, Solana-only totals
+ // for every protocol. Errors degrade to the per-protocol fallback.
+ ovDex, errD := fetchOverview("dexs", "")
+ if errD != nil {
+ fmt.Printf("[poll] overview dexs: %v (falling back to summaries)\n", errD)
+ }
+ ovFees, errF := fetchOverview("fees", "dataType=dailyRevenue")
+ if errF != nil {
+ fmt.Printf("[poll] overview fees: %v (falling back to summaries)\n", errF)
+ }
+
for _, p := range platforms {
- vol, err := fetch("dexs", p.slug, "")
- if err != nil {
- fmt.Printf("[poll] volume %s: %v\n", p.slug, err)
- health.WithLabelValues(p.label).Set(0)
- continue
+ vol, volOK := lookup(ovDex, p.names)
+ if !volOK {
+ v, err := fetch("dexs", p.slug, "")
+ if err != nil {
+ fmt.Printf("[poll] volume %s: %v\n", p.slug, err)
+ health.WithLabelValues(p.label).Set(0)
+ continue
+ }
+ fmt.Printf("[poll] %s: absent from solana overview, using all-chain summary\n", p.label)
+ vol = v
}
- rev, err := fetch("fees", p.slug, "dataType=dailyRevenue")
- if err != nil {
- fmt.Printf("[poll] fees %s: %v (volume ok)\n", p.slug, err)
+ rev, revOK := lookup(ovFees, p.names)
+ if !revOK {
+ r, err := fetch("fees", p.slug, "dataType=dailyRevenue")
+ if err != nil {
+ fmt.Printf("[poll] fees %s: %v (volume ok)\n", p.slug, err)
+ }
+ rev = r
}
volume24h.WithLabelValues(p.label).Set(vol.Total24h)
diff --git a/harnesses/wallet-labels/.env.example b/harnesses/wallet-labels/.env.example
index dc1a7d7da..0d1d17414 100644
--- a/harnesses/wallet-labels/.env.example
+++ b/harnesses/wallet-labels/.env.example
@@ -10,6 +10,9 @@ MORALIS_API_KEY=
# Helius (Solana)
HELIUS_API_KEY=
+# Serialized (18 EVM chains + Solana). Raw key, no Bearer prefix.
+SERIALIZED_API_KEY=
+
# Tuning
WALLET_LABELS_CHECK_DELAY_SECONDS=30
WALLET_LABELS_WORKERS=8
diff --git a/harnesses/wallet-labels/cmd/script/accuracy.go b/harnesses/wallet-labels/cmd/script/accuracy.go
new file mode 100644
index 000000000..c785b8dfa
--- /dev/null
+++ b/harnesses/wallet-labels/cmd/script/accuracy.go
@@ -0,0 +1,117 @@
+package main
+
+import "strings"
+
+// The bench scores a "hit" whenever a provider returns any non-generic
+// name. That rule cannot tell a curated entity label from a personal
+// name-service record: a provider that resolves `dex.davywoodfi.eth`
+// against Permit2, or `bonklanatoken.sol` against the Raydium authority,
+// scores exactly like one that answers "Permit2" and "Raydium".
+//
+// Measured at the time this was written, on the 100 anchors covered by
+// the two API-key providers: 25% of Serialized's hits and 25.4% of
+// Mobula's named something other than the curated entity. The bias is
+// symmetric, so this is a property of the scoring rule rather than of
+// any one vendor.
+//
+// accurateLabel adds the check the harness could always have made: the
+// anchor list already carries a curated Hint for every address and the
+// scoring path ignored it. This is published as a SEPARATE series
+// (wallet_labels_accurate_total) rather than folded into
+// wallet_labels_success_total, so the existing leaderboard and its
+// history stay intact while the stricter number builds up alongside.
+
+// normalizeLabel lowercases and strips everything that is not
+// alphanumeric, so "Uniswap: Universal Router" and "uniswap universal
+// router" compare equal.
+func normalizeLabel(s string) string {
+ var b strings.Builder
+ for _, r := range strings.ToLower(s) {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
+
+// genericHintWords are words that appear in curated hints but carry no
+// entity signal on their own. Without this list "USDC (Base native)"
+// would match "jakie.base.eth" through the word "base", scoring a
+// personal Basename as a correct label for a token contract.
+var genericHintWords = map[string]bool{
+ "base": true, "solana": true, "ethereum": true, "polygon": true, "arbitrum": true,
+ "optimism": true, "avalanche": true, "bitcoin": true, "stellar": true, "native": true,
+ "token": true, "contract": true, "wallet": true, "hot": true, "cold": true,
+ "chain": true, "mint": true, "address": true, "factory": true, "proxy": true,
+ "deployer": true, "treasury": true, "bridge": true, "pool": true, "vault": true,
+ "router": true, "exchange": true, "protocol": true, "official": true, "main": true,
+}
+
+// hintTokens splits a curated hint into the words that carry entity
+// signal. Bare indices are dropped so "Binance 14" matches on "binance"
+// and never on "14", otherwise "Bitstamp 14" would score as a correct
+// answer. Generic and chain words are dropped for the same reason. The
+// length floor is 3 and not 4: "OKX" is a real entity name.
+func hintTokens(hint string) []string {
+ repl := strings.NewReplacer(":", " ", "-", " ", "/", " ", "(", " ", ")", " ", ".", " ", "_", " ")
+ var out []string
+ for _, w := range strings.Fields(strings.ToLower(repl.Replace(hint))) {
+ if len(w) < 3 || allDigits(w) || genericHintWords[w] {
+ continue
+ }
+ out = append(out, w)
+ }
+ return out
+}
+
+// labelTokens splits a returned label the same way, so matching happens
+// on whole words. Substring matching would let "base" inside
+// "jakie.base.eth" pass, which is exactly the false positive this
+// series exists to avoid.
+func labelTokens(label string) []string {
+ repl := strings.NewReplacer(":", " ", "-", " ", "/", " ", "(", " ", ")", " ", ".", " ", "_", " ")
+ return strings.Fields(strings.ToLower(repl.Replace(label)))
+}
+
+func allDigits(s string) bool {
+ for _, r := range s {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return len(s) > 0
+}
+
+// accurateLabel reports whether the returned label plausibly names the
+// entity the anchor was curated for. Permissive on form ("Binance"
+// matches "Binance 14", "OKX 7" matches "OKX 1") and strict on identity
+// ("Bittrex 3" does not match "OKX 1", "jakie.base.eth" does not match
+// "USDC (Base native)").
+func accurateLabel(hint, label string) bool {
+ if hint == "" || label == "" {
+ return false
+ }
+ h, l := normalizeLabel(hint), normalizeLabel(label)
+ if h == "" || l == "" {
+ return false
+ }
+ if h == l {
+ return true
+ }
+ ht := hintTokens(hint)
+ if len(ht) == 0 {
+ // Hint carried no signal word (e.g. "Binance 14" reduced to
+ // nothing would be a bug, but "1" alone would not). Fall back to
+ // whole-string containment rather than matching everything.
+ return strings.Contains(l, h) || strings.Contains(h, l)
+ }
+ lt := labelTokens(label)
+ for _, hw := range ht {
+ for _, lw := range lt {
+ if hw == lw || strings.HasPrefix(lw, hw) || strings.HasPrefix(hw, lw) {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/harnesses/wallet-labels/cmd/script/accuracy_test.go b/harnesses/wallet-labels/cmd/script/accuracy_test.go
new file mode 100644
index 000000000..f8e08b949
--- /dev/null
+++ b/harnesses/wallet-labels/cmd/script/accuracy_test.go
@@ -0,0 +1,38 @@
+package main
+
+import "testing"
+
+func TestAccurateLabel(t *testing.T) {
+ cases := []struct {
+ hint, label string
+ want bool
+ why string
+ }{
+ {"Binance 14", "Binance", true, "curated entity with an index, provider returns the bare name"},
+ {"Uniswap V3 Router 2", "Uniswap: Universal Router", true, "same protocol, different punctuation"},
+ {"vitalik.eth", "vitalik.eth", true, "exact"},
+ {"OKX 1", "Bittrex 3", false, "different exchange must not pass"},
+ {"OKX 1", "OKX 7", true, "same entity, different hot wallet index"},
+ {"Bitfinex", "Polygon", false, "unrelated"},
+ {"Permit2", "dex.davywoodfi.eth", false, "personal ENS on a known contract"},
+ {"USDC (Base native)", "jakie.base.eth", false, "personal basename on a token contract"},
+ {"Raydium Authority", "bonklanatoken.sol", false, "personal .sol on a program authority"},
+ {"USDT (BSC)", "Fake_Phishing6512", false, "explorer warning tag is not the entity"},
+ {"Binance 14", "", false, "no label"},
+ {"", "Binance", false, "no hint"},
+ {"Binance 8", "Binance 8", true, "exact with index"},
+ {"Coinbase 1", "Coinbase 10", true, "same entity"},
+ }
+ for _, c := range cases {
+ if got := accurateLabel(c.hint, c.label); got != c.want {
+ t.Errorf("accurateLabel(%q, %q) = %v, want %v (%s)", c.hint, c.label, got, c.want, c.why)
+ }
+ }
+}
+
+func TestHintTokensDropsIndices(t *testing.T) {
+ got := hintTokens("Binance 14")
+ if len(got) != 1 || got[0] != "binance" {
+ t.Fatalf("hintTokens(\"Binance 14\") = %v, want [binance]; a bare index must never be a match token", got)
+ }
+}
diff --git a/harnesses/wallet-labels/cmd/script/anchor_feeder.go b/harnesses/wallet-labels/cmd/script/anchor_feeder.go
index 851fb2dce..14c3b3391 100644
--- a/harnesses/wallet-labels/cmd/script/anchor_feeder.go
+++ b/harnesses/wallet-labels/cmd/script/anchor_feeder.go
@@ -36,6 +36,7 @@ func runAnchorFeeder(ctx context.Context, q *queue) {
address: a.Address,
chain: a.Chain,
kind: a.Kind,
+ hint: a.Hint,
discoveredAt: time.Now(),
}) {
// queue full — wait a bit so workers can catch up.
diff --git a/harnesses/wallet-labels/cmd/script/config.go b/harnesses/wallet-labels/cmd/script/config.go
index 134cc7417..2bb00dd97 100644
--- a/harnesses/wallet-labels/cmd/script/config.go
+++ b/harnesses/wallet-labels/cmd/script/config.go
@@ -11,27 +11,29 @@ import (
// printed in full — only their length, so misconfigured deploys
// fail loudly without leaking material.
type Config struct {
- MobulaAPIKey string
- MoralisAPIKey string
- HeliusAPIKey string
+ MobulaAPIKey string
+ MoralisAPIKey string
+ HeliusAPIKey string
+ SerializedAPIKey string
- CheckDelay time.Duration
- Workers int
- QueueSize int
- PromListen string
- LogsToken string
+ CheckDelay time.Duration
+ Workers int
+ QueueSize int
+ PromListen string
+ LogsToken string
}
func loadConfig() *Config {
c := &Config{
- MobulaAPIKey: os.Getenv("MOBULA_API_KEY"),
- MoralisAPIKey: os.Getenv("MORALIS_API_KEY"),
- HeliusAPIKey: os.Getenv("HELIUS_API_KEY"),
- CheckDelay: parseDurationSec("WALLET_LABELS_CHECK_DELAY_SECONDS", 30),
- Workers: parseInt("WALLET_LABELS_WORKERS", 8),
- QueueSize: parseInt("WALLET_LABELS_QUEUE_SIZE", 2000),
- PromListen: envDefault("PROM_LISTEN_ADDR", ":2112"),
- LogsToken: os.Getenv("LOGS_TOKEN"),
+ MobulaAPIKey: os.Getenv("MOBULA_API_KEY"),
+ MoralisAPIKey: os.Getenv("MORALIS_API_KEY"),
+ HeliusAPIKey: os.Getenv("HELIUS_API_KEY"),
+ SerializedAPIKey: os.Getenv("SERIALIZED_API_KEY"),
+ CheckDelay: parseDurationSec("WALLET_LABELS_CHECK_DELAY_SECONDS", 30),
+ Workers: parseInt("WALLET_LABELS_WORKERS", 8),
+ QueueSize: parseInt("WALLET_LABELS_QUEUE_SIZE", 2000),
+ PromListen: envDefault("PROM_LISTEN_ADDR", ":2112"),
+ LogsToken: os.Getenv("LOGS_TOKEN"),
}
fmt.Println("=== Wallet Labels Coverage Monitor ===")
@@ -42,6 +44,7 @@ func loadConfig() *Config {
fmt.Printf(" Mobula key set: %v (len=%d)\n", c.MobulaAPIKey != "", len(c.MobulaAPIKey))
fmt.Printf(" Moralis key set: %v (len=%d)\n", c.MoralisAPIKey != "", len(c.MoralisAPIKey))
fmt.Printf(" Helius key set: %v (len=%d)\n", c.HeliusAPIKey != "", len(c.HeliusAPIKey))
+ fmt.Printf(" Serialized key set: %v (len=%d)\n", c.SerializedAPIKey != "", len(c.SerializedAPIKey))
fmt.Println()
return c
diff --git a/harnesses/wallet-labels/cmd/script/main.go b/harnesses/wallet-labels/cmd/script/main.go
index 0e3d5071c..abdfea2c2 100644
--- a/harnesses/wallet-labels/cmd/script/main.go
+++ b/harnesses/wallet-labels/cmd/script/main.go
@@ -53,6 +53,7 @@ func buildProviders(cfg *Config) []Provider {
NewMobulaProvider(cfg.MobulaAPIKey),
NewMoralisProvider(cfg.MoralisAPIKey),
NewHeliusProvider(cfg.HeliusAPIKey),
+ NewSerializedProvider(cfg.SerializedAPIKey),
NewBlockscoutProvider(),
NewOLIProvider(),
NewTonAPIProvider(),
diff --git a/harnesses/wallet-labels/cmd/script/metrics.go b/harnesses/wallet-labels/cmd/script/metrics.go
index cc9d05ec9..c5a200244 100644
--- a/harnesses/wallet-labels/cmd/script/metrics.go
+++ b/harnesses/wallet-labels/cmd/script/metrics.go
@@ -26,6 +26,21 @@ var (
ConstLabels: commonLabels,
}, []string{"provider", "chain", "kind"})
+ // Companion series to successTotal. Same denominator, stricter rule:
+ // the label must actually name the curated entity for the anchor, not
+ // merely be non-generic. See accuracy.go for why this exists.
+ accuracyChecksTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+ Name: "wallet_labels_accuracy_checks_total",
+ Help: "Label checks scored for accuracy against the curated anchor hint.",
+ ConstLabels: commonLabels,
+ }, []string{"provider", "chain", "kind"})
+
+ accurateTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+ Name: "wallet_labels_accurate_total",
+ Help: "Checks where the returned label names the curated entity for that anchor.",
+ ConstLabels: commonLabels,
+ }, []string{"provider", "chain", "kind"})
+
apiLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "wallet_labels_api_latency_milliseconds",
Help: "Provider API response time in milliseconds.",
@@ -67,6 +82,19 @@ func recordSkipped(provider, chain string) {
skippedTotal.WithLabelValues(provider, chain).Inc()
}
+// recordAccuracy feeds the companion series that scores a label against
+// the curated Hint rather than against "is it non-generic". Same label
+// set as checksTotal so the two ratios share a denominator.
+func recordAccuracy(provider, chain, kind string, accurate bool) {
+ if kind == "" {
+ kind = "unknown"
+ }
+ accuracyChecksTotal.WithLabelValues(provider, chain, kind).Inc()
+ if accurate {
+ accurateTotal.WithLabelValues(provider, chain, kind).Inc()
+ }
+}
+
func recordCheck(provider, chain, kind string, hasLabel bool, latencyMs float64, err error) {
if kind == "" {
kind = "unknown"
diff --git a/harnesses/wallet-labels/cmd/script/monitor.go b/harnesses/wallet-labels/cmd/script/monitor.go
index 9ca9e027d..0bb2fa20c 100644
--- a/harnesses/wallet-labels/cmd/script/monitor.go
+++ b/harnesses/wallet-labels/cmd/script/monitor.go
@@ -12,6 +12,7 @@ type sample struct {
address string
chain string
kind string // "contract" | "eoa" — carried into Prom labels so the bench can split by anchor kind
+ hint string // curated entity name for this anchor; scores the accuracy series, never the hit rule
discoveredAt time.Time
}
@@ -105,6 +106,12 @@ func lookupAll(ctx context.Context, providers []Provider, s sample) {
continue
}
recordCheck(r.Provider, r.Chain, s.kind, r.HasLabel, float64(r.LatencyMs), r.Err)
+ // Stricter companion series: a hit only counts when the label
+ // actually names the curated entity. Published alongside, never
+ // folded into, the hit rate. See accuracy.go.
+ if r.Err == nil {
+ recordAccuracy(r.Provider, r.Chain, s.kind, r.HasLabel && accurateLabel(s.hint, r.Label))
+ }
recordDebug(debugEntry{
Provider: r.Provider, Chain: r.Chain, Address: r.Address,
HasLabel: r.HasLabel, LatencyMs: r.LatencyMs,
diff --git a/harnesses/wallet-labels/cmd/script/serialized.go b/harnesses/wallet-labels/cmd/script/serialized.go
new file mode 100644
index 000000000..b35c79545
--- /dev/null
+++ b/harnesses/wallet-labels/cmd/script/serialized.go
@@ -0,0 +1,129 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+)
+
+// Serialized exposes an identity graph rather than a pure entity-label
+// service: GET /v1/wallet/profile returns a display name, ENS / Basename /
+// .sol resolution, socials and linked wallets for an address.
+//
+// We read it with the same precedence rule every other provider gets —
+// first non-generic name signal wins — so the bench compares like with
+// like. Note for whoever reads the leaderboard: because `displayName`
+// can resolve to a personal name-service record rather than a curated
+// entity, a share of Serialized's hits name the *holder* of an address
+// rather than the *entity* behind it (a measured ~25% of hits at
+// onboarding time, against ~25% for Mobula on the same sample). That is
+// a property of the bench's hit rule, not of this provider, and the fix
+// belongs in the scoring rule for everyone at once. See
+// docs/methodology/serialized-onboarding-audit.md §5.
+type SerializedProvider struct {
+ apiKey string
+}
+
+func NewSerializedProvider(key string) *SerializedProvider {
+ return &SerializedProvider{apiKey: key}
+}
+
+func (p *SerializedProvider) Name() string { return "serialized" }
+
+// serializedChains are the anchor-list chains Serialized indexes. Their
+// full surface is 18 EVM chains plus Solana; the ones below are the
+// intersection with the curated anchor sample. Chains outside this set
+// are skipped rather than counted as misses, same as every other
+// chain-restricted provider in this harness.
+var serializedChains = map[string]bool{
+ "ethereum": true,
+ "bnb": true,
+ "base": true,
+ "arbitrum": true,
+ "solana": true,
+}
+
+func (p *SerializedProvider) Supports(chain string) bool { return serializedChains[chain] }
+
+// Serialized enforces a hard burst cap of 40 requests per second per key
+// and answers anything above it with 429. The harness runs 8 workers with
+// sub-100ms responses, which clears that cap easily and silently turns
+// coverage into a rate-limit artifact (measured: 60 of 100 anchors lost to
+// 429, dropping apparent coverage from 77% to 37%). Serialize the calls at
+// a conservative fixed interval instead of relying on worker count.
+var (
+ serializedMu sync.Mutex
+ serializedLast time.Time
+)
+
+const serializedMinInterval = 60 * time.Millisecond // ~16 rps, well under the 40 rps cap
+
+func serializedThrottle() {
+ serializedMu.Lock()
+ defer serializedMu.Unlock()
+ if wait := time.Until(serializedLast.Add(serializedMinInterval)); wait > 0 {
+ time.Sleep(wait)
+ }
+ serializedLast = time.Now()
+}
+
+func (p *SerializedProvider) Lookup(ctx context.Context, chain, address string) LabelResult {
+ res := LabelResult{Provider: p.Name(), Chain: chain, Address: address}
+ if !p.Supports(chain) || p.apiKey == "" {
+ return res
+ }
+
+ serializedThrottle()
+
+ start := time.Now()
+ req, _ := http.NewRequestWithContext(ctx, "GET",
+ "https://api.serialized.xyz/v1/wallet/profile?address="+address, nil)
+ // Raw key, no Bearer prefix — a prefixed key is rejected with 401.
+ req.Header.Set("Authorization", p.apiKey)
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := httpClient.Do(req)
+ res.LatencyMs = time.Since(start).Milliseconds()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ res.Err = fmt.Errorf("status_%d", resp.StatusCode)
+ return res
+ }
+
+ var body struct {
+ Data struct {
+ Profile *struct {
+ DisplayName string `json:"displayName"`
+ ENSName string `json:"ensName"`
+ Basename string `json:"basename"`
+ SolName string `json:"solName"`
+ } `json:"profile"`
+ } `json:"data"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ res.Err = fmt.Errorf("parse: %w", err)
+ return res
+ }
+ if body.Data.Profile == nil {
+ return res
+ }
+
+ prof := body.Data.Profile
+ for _, candidate := range []string{prof.DisplayName, prof.ENSName, prof.Basename, prof.SolName} {
+ if !genericLabel(candidate) {
+ res.Label = candidate
+ res.HasLabel = true
+ res.Raw = map[string]any{"label": candidate, "source": "wallet_profile"}
+ break
+ }
+ }
+ return res
+}
diff --git a/next.config.ts b/next.config.ts
index 3a23ca7b3..f42ba984e 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -112,6 +112,25 @@ const nextConfig: NextConfig = {
source: "/:path*",
headers: SECURITY_HEADERS,
},
+ {
+ // The RPC speed test fires fetch() at user-supplied endpoints
+ // straight from the browser — the whole product. The site-wide
+ // connect-src allowlist blocked every probe (surfaced as a fake
+ // "CORS" failure on endpoints that are actually CORS-open, e.g.
+ // publicnode). Open connect-src to any https origin on THIS
+ // route only; every other directive keeps the tight policy.
+ source: "/speedtest-rpc",
+ headers: [
+ ...SECURITY_HEADERS.filter((h) => h.key !== "Content-Security-Policy"),
+ {
+ key: "Content-Security-Policy",
+ value: CSP.replace(
+ /connect-src [^;]+/,
+ `connect-src 'self' https: ${RELAY_WS}`,
+ ),
+ },
+ ],
+ },
{
// Badges are designed to be embedded as in third-party
// READMEs and blogs. Override frame-ancestors so SVG embedding
diff --git a/public/logos/serialized-wordmark.svg b/public/logos/serialized-wordmark.svg
new file mode 100644
index 000000000..7f3d83a9b
--- /dev/null
+++ b/public/logos/serialized-wordmark.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/public/logos/serialized.svg b/public/logos/serialized.svg
new file mode 100644
index 000000000..3198acb25
--- /dev/null
+++ b/public/logos/serialized.svg
@@ -0,0 +1,5 @@
+
diff --git a/scripts/generate-world-path.mjs b/scripts/generate-world-path.mjs
new file mode 100644
index 000000000..6b82a7e5e
--- /dev/null
+++ b/scripts/generate-world-path.mjs
@@ -0,0 +1,56 @@
+/**
+ * Generates src/lib/speedtest/world-path.ts: the land outline of the
+ * world as SVG path data on an equirectangular projection, decoded from
+ * world-atlas land-110m (Natural Earth, public domain). Zero runtime
+ * deps: minimal topojson arc decoding lives here, at generation time.
+ * Rerun: node scripts/generate-world-path.mjs
+ */
+const W = 1000, H = 500;
+const res = await fetch("https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/land-110m.json");
+const topo = await res.json();
+const { transform, arcs } = topo;
+const decodeArc = (arc) => {
+ let x = 0, y = 0;
+ return arc.map(([dx, dy]) => {
+ x += dx; y += dy;
+ return [x * transform.scale[0] + transform.translate[0], y * transform.scale[1] + transform.translate[1]];
+ });
+};
+const decoded = arcs.map(decodeArc);
+const ring = (arcIdxs) => {
+ let pts = [];
+ for (const i of arcIdxs) {
+ const a = i >= 0 ? decoded[i] : [...decoded[~i]].reverse();
+ pts = pts.length ? pts.concat(a.slice(1)) : pts.concat(a);
+ }
+ return pts;
+};
+const proj = ([lon, lat]) => [
+ ((lon + 180) / 360) * W,
+ ((90 - lat) / 180) * H,
+];
+let d = "";
+const land = topo.objects.land;
+const geoms = land.type === "GeometryCollection" ? land.geometries : [land];
+for (const g of geoms) {
+ const polys = g.type === "Polygon" ? [g.arcs] : g.arcs; // MultiPolygon
+ for (const poly of polys) {
+ for (const r of poly) {
+ const pts = ring(r).map(proj);
+ d += "M" + pts.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join("L") + "Z";
+ }
+ }
+}
+const out = `/**
+ * GENERATED by scripts/generate-world-path.mjs. World land outline as a
+ * single SVG path on an equirectangular projection (viewBox 0 0 ${W} ${H}).
+ * Source: world-atlas land-110m (Natural Earth, public domain).
+ */
+export const WORLD_VIEWBOX = "0 0 ${W} ${H}";
+export const WORLD_W = ${W};
+export const WORLD_H = ${H};
+export const WORLD_PATH = ${JSON.stringify(d)};
+`;
+import { writeFileSync } from "node:fs";
+writeFileSync("src/lib/speedtest/world-path.ts", out);
+console.log("written, path length:", d.length);
diff --git a/src/app/alternatives/opengraph-image.tsx b/src/app/alternatives/opengraph-image.tsx
new file mode 100644
index 000000000..e7e60e023
--- /dev/null
+++ b/src/app/alternatives/opengraph-image.tsx
@@ -0,0 +1,15 @@
+import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template";
+
+export const runtime = "nodejs";
+export const alt = "Alternatives to crypto infrastructure products, ranked by live OpenChainBench benchmarks.";
+export const size = OG_SIZE;
+export const contentType = "image/png";
+
+export default function OG() {
+ return renderHubOG({
+ kicker: "Alternatives",
+ headline: "Alternatives, by the numbers.",
+ subline:
+ "Benchmark-ranked alternatives to every major crypto infrastructure product. Same data, reframed per product, no verdict.",
+ });
+}
diff --git a/src/app/answers/[slug]/opengraph-image.tsx b/src/app/answers/[slug]/opengraph-image.tsx
new file mode 100644
index 000000000..3e6b77dba
--- /dev/null
+++ b/src/app/answers/[slug]/opengraph-image.tsx
@@ -0,0 +1,28 @@
+import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template";
+import { loadAllAnswers } from "@/lib/answers";
+
+export const runtime = "nodejs";
+export const size = OG_SIZE;
+export const contentType = "image/png";
+
+export default async function OG({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+ const all = await loadAllAnswers();
+ const answer = all.find((a) => a.slug === slug);
+
+ if (!answer) {
+ return renderHubOG({
+ kicker: "Answers",
+ headline: "Questions, answered with data.",
+ subline: "Common questions about crypto infrastructure, answered from live OpenChainBench benchmarks.",
+ });
+ }
+
+ const question = answer.question.endsWith("?") ? answer.question : `${answer.question}?`;
+
+ return renderHubOG({
+ kicker: "Answered by data",
+ headline: question.length > 60 ? question.slice(0, 57) + "..." : question,
+ subline: answer.seo_description ?? answer.short_answer.slice(0, 120),
+ });
+}
diff --git a/src/app/answers/opengraph-image.tsx b/src/app/answers/opengraph-image.tsx
new file mode 100644
index 000000000..f9084db07
--- /dev/null
+++ b/src/app/answers/opengraph-image.tsx
@@ -0,0 +1,15 @@
+import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template";
+
+export const runtime = "nodejs";
+export const alt = "OpenChainBench answers. Common questions about crypto infrastructure, answered with live benchmark data.";
+export const size = OG_SIZE;
+export const contentType = "image/png";
+
+export default function OG() {
+ return renderHubOG({
+ kicker: "Answers",
+ headline: "Questions, answered with data.",
+ subline:
+ "Common questions about crypto infrastructure performance, answered directly from live OpenChainBench benchmark results.",
+ });
+}
diff --git a/src/app/api/fee-compare/route.ts b/src/app/api/fee-compare/route.ts
index d6d533019..3af347efa 100644
--- a/src/app/api/fee-compare/route.ts
+++ b/src/app/api/fee-compare/route.ts
@@ -84,7 +84,7 @@ let gainsFeeCache: {
ts: number;
} | null = null;
-type RateCacheEntry = { rate: number; note: string; ts: number };
+type RateCacheEntry = { rate: number; makerRate: number; note: string; ts: number };
const rateCache: Partial> = {};
type CarryRates = {
@@ -112,7 +112,7 @@ type HlFill = {
type HlFundingEvent = {
time: number;
- delta: { usdc: string };
+ delta: { usdc: string; coin?: string };
};
type GainsApiTrade = {
@@ -188,6 +188,8 @@ type GainsWalletData = {
netCostUsdc: number;
positionSizeUsdc: number;
avgFeeRateBps: number;
+ gainsExclusiveFeesUsdc?: number; // fees on coins not available on the other venue
+ comparableNotionalUsdc?: number; // notional of HL-comparable trades only
recentTrades: Array<{
date: string;
pair: string;
@@ -196,7 +198,8 @@ type GainsWalletData = {
tradingFee: number;
fundingFee: number;
borrowingFee: number;
- equivFee?: number; // equivalent fee on the other venue
+ equivFee?: number;
+ hlComparable?: boolean; // false = coin not listed on HL
pnl_net: number;
}>;
};
@@ -210,7 +213,9 @@ type RawGmxTrade = {
fundingFeeAmount: string | null;
pnlUsd: string | null;
orderType: number;
- indexToken: { symbol: string } | null;
+ // The subsquid schema dropped the indexToken relation; coins resolve from
+ // marketAddress via GMX_MARKETS instead.
+ marketAddress: string | null;
};
type GmxWalletData = {
@@ -369,7 +374,7 @@ async function fetchGainsFeeRates(): Promise<{
return gainsFeeCache;
}
-async function fetchHlRate(): Promise<{ rate: number; note: string }> {
+async function fetchHlRate(): Promise<{ rate: number; makerRate: number; note: string }> {
const cached = rateCache["hyperliquid"];
if (cached && Date.now() - cached.ts < RATE_CACHE_TTL_MS) return cached;
const res = await fetch(HL_API, {
@@ -378,25 +383,28 @@ async function fetchHlRate(): Promise<{ rate: number; note: string }> {
body: JSON.stringify({ type: "userFees", user: "0x0000000000000000000000000000000000000000" }),
signal: AbortSignal.timeout(8000),
});
- const data = (await res.json()) as { userCrossRate?: string };
+ const data = (await res.json()) as { userCrossRate?: string; userAddRate?: string };
const rate = parseFloat(data.userCrossRate ?? String(HL_TAKER_FALLBACK));
- const entry = { rate, note: `${(rate * 10000).toFixed(2)} bps taker (live from HL fee schedule)`, ts: Date.now() };
+ // userAddRate = maker (add-liquidity) rate; base tier is ~1.0 bps
+ const makerRate = parseFloat(data.userAddRate ?? String(rate));
+ const entry = { rate, makerRate, note: `${(rate * 10000).toFixed(2)} bps taker (live from HL fee schedule)`, ts: Date.now() };
rateCache["hyperliquid"] = entry;
return entry;
}
-async function fetchParadexRate(): Promise<{ rate: number; note: string }> {
+async function fetchParadexRate(): Promise<{ rate: number; makerRate: number; note: string }> {
const cached = rateCache["paradex"];
if (cached && Date.now() - cached.ts < RATE_CACHE_TTL_MS) return cached;
const res = await fetch("https://api.prod.paradex.trade/v1/markets?market=BTC-USD-PERP", {
signal: AbortSignal.timeout(8000),
});
const data = (await res.json()) as {
- results?: Array<{ fee_config?: { api_fee?: { taker_fee?: { fee?: string } } } }>;
+ results?: Array<{ fee_config?: { api_fee?: { taker_fee?: { fee?: string }; maker_fee?: { fee?: string } } } }>;
};
- const rawRate = data.results?.[0]?.fee_config?.api_fee?.taker_fee?.fee ?? "0.0002";
- const rate = parseFloat(rawRate);
- const entry = { rate, note: `${(rate * 10000).toFixed(2)} bps taker (live from Paradex)`, ts: Date.now() };
+ const feeCfg = data.results?.[0]?.fee_config?.api_fee;
+ const rate = parseFloat(feeCfg?.taker_fee?.fee ?? "0.0002");
+ const makerRate = parseFloat(feeCfg?.maker_fee?.fee ?? String(rate));
+ const entry = { rate, makerRate, note: `${(rate * 10000).toFixed(2)} bps taker (live from Paradex)`, ts: Date.now() };
rateCache["paradex"] = entry;
return entry;
}
@@ -427,40 +435,62 @@ async function fetchDydxCarryRates(): Promise {
async function fetchParadexCarryRates(): Promise {
const cached = carryRateCache["paradex"];
if (cached && Date.now() - cached.ts < RATE_CACHE_TTL_MS) return cached;
- const res = await fetch("https://api.prod.paradex.trade/v1/markets", {
- signal: AbortSignal.timeout(8000),
- next: { revalidate: 3600 },
- });
- const data = (await res.json()) as {
- results: Array<{ symbol: string; interest_rate?: string; funding_period_hours?: number | string }>;
+ // markets/summary carries the SIGNED per-period funding_rate (positive = longs pay).
+ // The plain /markets interest_rate is unsigned — feeding it into the signed carry
+ // model would systematically credit shorts, so it must not be used here.
+ const [summaryRes, marketsRes] = await Promise.all([
+ fetch("https://api.prod.paradex.trade/v1/markets/summary?market=ALL", {
+ signal: AbortSignal.timeout(8000),
+ next: { revalidate: 3600 },
+ }),
+ fetch("https://api.prod.paradex.trade/v1/markets", {
+ signal: AbortSignal.timeout(8000),
+ next: { revalidate: 3600 },
+ }),
+ ]);
+ const summary = (await summaryRes.json()) as {
+ results: Array<{ symbol: string; funding_rate?: string }>;
+ };
+ const markets = (await marketsRes.json()) as {
+ results: Array<{ symbol: string; funding_period_hours?: number | string }>;
};
+ const periodBySymbol: Record = {};
+ for (const mkt of markets.results ?? []) {
+ periodBySymbol[mkt.symbol] = parseFloat(String(mkt.funding_period_hours ?? "8")) || 8;
+ }
const fundingPerSecPerCoin: Record = {};
- for (const mkt of data.results ?? []) {
+ for (const mkt of summary.results ?? []) {
if (!mkt.symbol.endsWith("-PERP")) continue;
// "BTC-USD-PERP" → "BTC"
const coin = mkt.symbol.replace(/-USD-PERP$/, "").replace(/-PERP$/, "");
- const periodHours = parseFloat(String(mkt.funding_period_hours ?? "8")) || 8;
- const rate = parseFloat(mkt.interest_rate ?? "0") / (periodHours * 3600);
- if (rate > 0) fundingPerSecPerCoin[coin] = rate;
+ const periodHours = periodBySymbol[mkt.symbol] ?? 8;
+ const rate = parseFloat(mkt.funding_rate ?? "") / (periodHours * 3600);
+ if (Number.isFinite(rate) && rate !== 0) fundingPerSecPerCoin[coin] = rate;
}
const result: CarryRates = { fundingPerSecPerCoin, borrowPerSecPerCoin: {}, ts: Date.now() };
carryRateCache["paradex"] = result;
return result;
}
-async function fetchEdgeXRate(): Promise<{ rate: number; note: string }> {
+async function fetchEdgeXRate(): Promise<{ rate: number; makerRate: number; note: string }> {
const cached = rateCache["edgex"];
if (cached && Date.now() - cached.ts < RATE_CACHE_TTL_MS) return cached;
const res = await fetch("https://edgex-prod-v2.edgex.exchange/api/v2/public/meta/getMetaData", {
signal: AbortSignal.timeout(8000),
});
const data = (await res.json()) as {
- data?: { contractList?: Array<{ defaultTakerFeeRate?: string | number }> };
+ data?: { contractList?: Array<{ defaultTakerFeeRate?: string | number; defaultMakerFeeRate?: string | number }> };
};
const contracts = data.data?.contractList ?? [];
const rates = contracts.map((c) => parseFloat(String(c.defaultTakerFeeRate ?? "0"))).filter((r) => r > 0);
+ // Zero is a legitimate maker rate (fee promos); only drop absent/unparsable values.
+ const makerRates = contracts
+ .filter((c) => c.defaultMakerFeeRate !== undefined && c.defaultMakerFeeRate !== null)
+ .map((c) => parseFloat(String(c.defaultMakerFeeRate)))
+ .filter((r) => Number.isFinite(r) && r >= 0);
const rate = rates.length > 0 ? rates.reduce((a, b) => a + b, 0) / rates.length : 0.00038;
- const entry = { rate, note: `${(rate * 10000).toFixed(2)} bps taker (live from EdgeX)`, ts: Date.now() };
+ const makerRate = makerRates.length > 0 ? makerRates.reduce((a, b) => a + b, 0) / makerRates.length : rate;
+ const entry = { rate, makerRate, note: `${(rate * 10000).toFixed(2)} bps taker (live from EdgeX)`, ts: Date.now() };
rateCache["edgex"] = entry;
return entry;
}
@@ -627,10 +657,14 @@ async function fetchGmxCarryRates(): Promise {
if (totalOI === BigInt(0) || fundingFactorRaw === BigInt(0)) return { coin, rate: 0 };
- const imbalance = longsOI > shortsOI ? longsOI - shortsOI : shortsOI - longsOI;
- // rate = fundingFactor × (imbalance / totalOI) / 1e30
- const rateScaled = fundingFactorRaw * imbalance / totalOI;
- const rate = Number(rateScaled) / 1e30;
+ // SIGNED imbalance: longs crowded (>0) → longs pay → positive rate.
+ // Shorts crowded (<0) → shorts pay → negative rate. Keeping the sign lets the
+ // carry projection charge the correct side (a long is only charged when longs pay).
+ const signedImbalance = longsOI - shortsOI;
+ const magnitude = signedImbalance < BigInt(0) ? -signedImbalance : signedImbalance;
+ // rate = fundingFactor × (|imbalance| / totalOI) / 1e30, re-signed afterwards
+ const rateScaled = fundingFactorRaw * magnitude / totalOI;
+ const rate = (Number(rateScaled) / 1e30) * (signedImbalance < BigInt(0) ? -1 : 1);
return { coin, rate };
})
@@ -639,8 +673,8 @@ async function fetchGmxCarryRates(): Promise {
for (const r of fundingReads) {
if (r.status !== "fulfilled" || !r.value) continue;
const { coin, rate } = r.value;
- // Sanity check: GMX funding should be between 1e-12 and 1e-6 /sec
- if (rate > 1e-12 && rate < 1e-6) {
+ // Sanity check: |GMX funding| should be between 1e-12 and 1e-6 /sec
+ if (Math.abs(rate) > 1e-12 && Math.abs(rate) < 1e-6) {
fundingPerSecPerCoin[coin] = rate;
}
}
@@ -655,7 +689,7 @@ async function fetchGmxCarryRates(): Promise {
}
}
-async function fetchGmxLiveRate(): Promise<{ rate: number; note: string }> {
+async function fetchGmxLiveRate(): Promise<{ rate: number; makerRate: number; note: string }> {
const cached = rateCache["gmx-v2"];
if (cached && Date.now() - cached.ts < RATE_CACHE_TTL_MS) return cached;
// Filter to USDC-collateral only: other tokens have different decimals,
@@ -696,34 +730,38 @@ async function fetchGmxLiveRate(): Promise<{ rate: number; note: string }> {
totalNotional += notional;
}
const rate = totalNotional > 0 ? totalFees / totalNotional : 0.0005;
- const entry = { rate, note: `${(rate * 10000).toFixed(2)} bps (live avg from recent GMX v2 trades)`, ts: Date.now() };
+ // GMX v2 is an AMM-style venue: the position fee is charged regardless of
+ // whether the order added or removed liquidity, so maker == taker.
+ const entry = { rate, makerRate: rate, note: `${(rate * 10000).toFixed(2)} bps (live avg from recent GMX v2 trades)`, ts: Date.now() };
rateCache["gmx-v2"] = entry;
return entry;
}
-async function resolveRate(slug: string): Promise<{ rate: number; note: string; rateIsLive: boolean }> {
+async function resolveRate(slug: string): Promise<{ rate: number; makerRate: number; note: string; rateIsLive: boolean }> {
if (slug === "gains") {
+ // AMM-style: same position-size fee whether the order adds or removes liquidity.
const d = await fetchGainsFeeRates();
- return { rate: d.avgPerSide, note: "Live per-coin taker rate (avg across pairs)", rateIsLive: true };
+ return { rate: d.avgPerSide, makerRate: d.avgPerSide, note: "Live per-coin taker rate (avg across pairs)", rateIsLive: true };
}
if (slug === "hyperliquid") {
- const r = await fetchHlRate().catch(() => ({ rate: HL_TAKER_FALLBACK, note: "3.50 bps taker (HL base tier)" }));
+ const r = await fetchHlRate().catch(() => ({ rate: HL_TAKER_FALLBACK, makerRate: 0.0001, note: "3.50 bps taker (HL base tier)" }));
return { ...r, rateIsLive: true };
}
if (slug === "paradex") {
- const r = await fetchParadexRate().catch(() => ({ rate: 0.0002, note: "2.00 bps taker (Paradex api-tier)" }));
+ const r = await fetchParadexRate().catch(() => ({ rate: 0.0002, makerRate: 0.00005, note: "2.00 bps taker (Paradex api-tier)" }));
return { ...r, rateIsLive: true };
}
if (slug === "edgex") {
- const r = await fetchEdgeXRate().catch(() => ({ rate: 0.00038, note: "3.80 bps taker (EdgeX)" }));
+ const r = await fetchEdgeXRate().catch(() => ({ rate: 0.00038, makerRate: 0.0001, note: "3.80 bps taker (EdgeX)" }));
return { ...r, rateIsLive: true };
}
if (slug === "gmx-v2") {
- const r = await fetchGmxLiveRate().catch(() => ({ rate: 0.0005, note: "5.00 bps taker (GMX v2 fallback)" }));
+ const r = await fetchGmxLiveRate().catch(() => ({ rate: 0.0005, makerRate: 0.0005, note: "5.00 bps taker (GMX v2 fallback)" }));
return { ...r, rateIsLive: true };
}
- if (slug === "dydx") return { rate: 0.0005, note: "5.00 bps taker (tier-0, protocol-governed)", rateIsLive: false };
- return { rate: 0.0005, note: "Documented rate", rateIsLive: false };
+ // dYdX v4 tier-0: 5.0 bps taker / 1.0 bps maker (protocol-governed schedule)
+ if (slug === "dydx") return { rate: 0.0005, makerRate: 0.0001, note: "5.00 bps taker (tier-0, protocol-governed)", rateIsLive: false };
+ return { rate: 0.0005, makerRate: 0.0005, note: "Documented rate", rateIsLive: false };
}
type HlOpenPos = {
@@ -802,7 +840,7 @@ async function fetchGmxTrades(wallet: string, cutoffMs: number): Promise();
- for (const t of trades) {
- if (!byId.has(t.id)) byId.set(t.id, {});
+ // Sort oldest-first so increases appear after their open event
+ const sorted = [...trades].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
+
+ type Entry = {
+ open?: GainsApiTrade;
+ close?: GainsApiTrade;
+ increases: GainsApiTrade[];
+ lastIncrease?: GainsApiTrade; // earliest increase, fallback anchor for pre-window positions
+ };
+ const byId = new Map();
+
+ for (const t of sorted) {
+ if (!byId.has(t.id)) byId.set(t.id, { increases: [] });
const e = byId.get(t.id)!;
- if (OPEN_ACTIONS.has(t.action)) e.open = t;
- else if (INCREASE_ACTIONS.has(t.action) && e.open) {
- e.open = { ...e.open, size: t.size, leverage: t.leverage };
+ if (OPEN_ACTIONS.has(t.action)) {
+ e.open = t;
+ } else if (INCREASE_ACTIONS.has(t.action)) {
+ e.increases.push(t);
+ if (!e.open) {
+ // Track earliest increase as anchor for positions opened before the window
+ if (!e.lastIncrease || new Date(t.date).getTime() < new Date(e.lastIncrease.date).getTime()) {
+ e.lastIncrease = t;
+ }
+ }
+ } else if (CLOSE_ACTIONS.has(t.action)) {
+ e.close = t;
}
- else if (CLOSE_ACTIONS.has(t.action)) e.close = t;
}
const now = Date.now();
const slices: PositionSlice[] = [];
- for (const { open, close } of byId.values()) {
- if (!open) continue;
- const openMs = new Date(open.date).getTime();
- if (openMs < cutoffMs) continue;
- // Still-open positions use now as close time (same as reconstructHlPositions)
+
+ for (const { open, close, increases, lastIncrease } of byId.values()) {
+ const anchor = open ?? lastIncrease;
+ if (!anchor) continue;
+
const closeMs = close ? new Date(close.date).getTime() : now;
- slices.push({
- coin: open.pair.split("/")[0],
- notionalUsd: open.size * open.leverage,
- openMs,
- closeMs,
- isLong: open.buy !== false,
- });
+ if (closeMs < cutoffMs) continue;
+
+ const isLong = anchor.buy !== false;
+ const coin = anchor.pair.split("/")[0];
+
+ // Build a size timeline: each entry = { ms, notionalUsd } when size changed.
+ // This lets us create one funding slice per size period instead of one for the whole position.
+ const timeline: Array<{ ms: number; notionalUsd: number }> = [
+ { ms: new Date(anchor.date).getTime(), notionalUsd: anchor.size * anchor.leverage },
+ ];
+ for (const inc of increases) {
+ const incMs = new Date(inc.date).getTime();
+ // Only track increases that happened after the anchor (skip pre-anchor increases already folded in)
+ if (incMs > new Date(anchor.date).getTime()) {
+ timeline.push({ ms: incMs, notionalUsd: inc.size * inc.leverage });
+ }
+ }
+ // Already sorted oldest-first since increases was pushed in order
+
+ // Emit one slice per size period
+ for (let i = 0; i < timeline.length; i++) {
+ const sliceOpen = Math.max(timeline[i].ms, cutoffMs);
+ const sliceClose = i + 1 < timeline.length ? timeline[i + 1].ms : closeMs;
+ if (sliceClose <= cutoffMs) continue; // period entirely before window
+ if (sliceOpen >= sliceClose) continue; // zero-duration
+ slices.push({
+ coin,
+ notionalUsd: timeline[i].notionalUsd,
+ openMs: sliceOpen,
+ closeMs: sliceClose,
+ isLong,
+ });
+ }
}
return slices;
@@ -1162,7 +1242,7 @@ function reconstructGmxPositions(rawTrades: RawGmxTrade[], cutoffMs: number): Po
const slices: PositionSlice[] = [];
for (const t of sorted) {
- const coin = t.indexToken?.symbol ?? "UNKNOWN";
+ const coin = (t.marketAddress && GMX_MARKETS[t.marketAddress]) || "UNKNOWN";
const isLong = t.isLong;
const key = `${coin}:${isLong ? "L" : "S"}`;
const notionalDelta =
@@ -1211,41 +1291,35 @@ function reconstructGmxPositions(rawTrades: RawGmxTrade[], cutoffMs: number): Po
return slices;
}
-// Estimate GMX borrow fees for a set of position slices.
-function estimateGmxBorrowFees(
- positions: PositionSlice[],
- borrowPerSecPerCoin: Record
-): number {
- const fallbackRate = borrowPerSecPerCoin["BTC"] ?? 1.4e-8;
- let total = 0;
- for (const pos of positions) {
- const rate = borrowPerSecPerCoin[pos.coin] ?? fallbackRate;
- const durationSec = Math.max(0, (pos.closeMs - pos.openMs) / 1000);
- total += pos.notionalUsd * rate * durationSec;
- }
- return total;
-}
-
// Fetch HL 8h funding rate history for a set of coins over a period.
-// Returns map of coin → array of { time, rate (as fraction) }.
+// Paginates automatically: the HL API returns at most 500 entries per request.
+// At 3 entries/day, 500 covers ~167 days. Windows >167d need multiple pages.
async function fetchHlFundingHistory(
coins: string[],
startMs: number
): Promise
-
+
{/* Latest deployed benchmarks */}
diff --git a/src/app/partners/opengraph-image.tsx b/src/app/partners/opengraph-image.tsx
new file mode 100644
index 000000000..5566a6188
--- /dev/null
+++ b/src/app/partners/opengraph-image.tsx
@@ -0,0 +1,15 @@
+import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template";
+
+export const runtime = "nodejs";
+export const alt = "OpenChainBench partners and integrations. Projects that embed or reference our open benchmark data.";
+export const size = OG_SIZE;
+export const contentType = "image/png";
+
+export default function OG() {
+ return renderHubOG({
+ kicker: "Partners",
+ headline: "Partners and integrations.",
+ subline:
+ "Projects and documentation sites that embed or reference OpenChainBench benchmark data in their products.",
+ });
+}
diff --git a/src/app/perp/[slug]/opengraph-image.tsx b/src/app/perp/[slug]/opengraph-image.tsx
new file mode 100644
index 000000000..d0f927e0b
--- /dev/null
+++ b/src/app/perp/[slug]/opengraph-image.tsx
@@ -0,0 +1,20 @@
+import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template";
+import { PERP_VENUES } from "@/lib/perp-stats";
+
+export const runtime = "nodejs";
+export const size = OG_SIZE;
+export const contentType = "image/png";
+
+export default async function OG({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+ const cohortSlug = slug === "gmx" ? "gmx-v2" : slug;
+ const venue = PERP_VENUES.find((v) => v.slug === cohortSlug);
+ const name = venue?.name ?? slug;
+ const chain = venue?.chain ?? "";
+
+ return renderHubOG({
+ kicker: "Perp DEX benchmark",
+ headline: `${name} benchmark.`,
+ subline: `Live volume, open interest, all-in fees and funding rate for ${name}${chain ? ` on ${chain}` : ""}. Compared against every major perp exchange.`,
+ });
+}
diff --git a/src/app/perps/opengraph-image.tsx b/src/app/perps/opengraph-image.tsx
new file mode 100644
index 000000000..186d421ee
--- /dev/null
+++ b/src/app/perps/opengraph-image.tsx
@@ -0,0 +1,15 @@
+import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template";
+
+export const runtime = "nodejs";
+export const alt = "Best perp DEX 2026. Live leaderboard of perpetual exchanges by volume, open interest, fees and funding.";
+export const size = OG_SIZE;
+export const contentType = "image/png";
+
+export default function OG() {
+ return renderHubOG({
+ kicker: "Perp DEX leaderboard",
+ headline: "Best perp DEX, by the numbers.",
+ subline:
+ "Volume, open interest, all-in cost and funding rate across every major perpetual exchange. Updated continuously.",
+ });
+}
diff --git a/src/app/prediction-markets/opengraph-image.tsx b/src/app/prediction-markets/opengraph-image.tsx
new file mode 100644
index 000000000..b82b9a441
--- /dev/null
+++ b/src/app/prediction-markets/opengraph-image.tsx
@@ -0,0 +1,15 @@
+import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template";
+
+export const runtime = "nodejs";
+export const alt = "Prediction markets leaderboard 2026. Volume, resolution delay, API latency and data freshness across every major venue.";
+export const size = OG_SIZE;
+export const contentType = "image/png";
+
+export default function OG() {
+ return renderHubOG({
+ kicker: "Prediction markets",
+ headline: "Prediction markets, ranked live.",
+ subline:
+ "Volume, resolution delay, API latency and data freshness across Polymarket, Kalshi and every other major venue.",
+ });
+}
diff --git a/src/app/reports/[category]/[slug]/opengraph-image.tsx b/src/app/reports/[category]/[slug]/opengraph-image.tsx
index 79ab3b056..9374af063 100644
--- a/src/app/reports/[category]/[slug]/opengraph-image.tsx
+++ b/src/app/reports/[category]/[slug]/opengraph-image.tsx
@@ -114,6 +114,6 @@ export default async function OG({
),
- { ...size }
+ { ...size, headers: { "cache-control": "public, s-maxage=3600, stale-while-revalidate=86400" } }
);
}
diff --git a/src/app/reports/[category]/[slug]/page.tsx b/src/app/reports/[category]/[slug]/page.tsx
index 9baf08937..7e2677ed1 100644
--- a/src/app/reports/[category]/[slug]/page.tsx
+++ b/src/app/reports/[category]/[slug]/page.tsx
@@ -31,7 +31,13 @@ export async function generateMetadata({ params }: Props): Promise {
if (!report) return {};
const canonical = `${SITE.url}/reports/${category}/${slug}`;
- const ogImage = report.ogImage ?? `${SITE.url}/opengraph-image`;
+ // Use the per-report OG card (dynamic opengraph-image.tsx in this
+ // segment) as the default, NOT the site-wide /opengraph-image. Setting
+ // openGraph.images explicitly here shadows Next's file convention, so
+ // without this the bespoke titled card was never emitted and every
+ // report shared the generic site thumbnail on X / LinkedIn / SERP.
+ const ogImage =
+ report.ogImage ?? `${SITE.url}/reports/${category}/${slug}/opengraph-image`;
const social = `${report.title} · OpenChainBench`;
return {
@@ -264,7 +270,9 @@ function reportJsonLd(report: ReturnType) {
description: report.summary,
datePublished: new Date(report.publishedAt).toISOString(),
dateModified: new Date(report.publishedAt).toISOString(),
- image: report.ogImage ?? `${SITE.url}/opengraph-image`,
+ image:
+ report.ogImage ??
+ `${SITE.url}/reports/${report.categorySlug}/${report.slug}/opengraph-image`,
author: { "@id": PERSON_ID },
publisher: { "@id": `${SITE.url}/#org` },
inLanguage: "en",
diff --git a/src/app/reports/[category]/opengraph-image.tsx b/src/app/reports/[category]/opengraph-image.tsx
new file mode 100644
index 000000000..ebec3508b
--- /dev/null
+++ b/src/app/reports/[category]/opengraph-image.tsx
@@ -0,0 +1,19 @@
+import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template";
+import { REPORT_CATEGORY_META } from "@/lib/reports/loader";
+
+export const runtime = "nodejs";
+export const size = OG_SIZE;
+export const contentType = "image/png";
+
+export default async function OG({ params }: { params: Promise<{ category: string }> }) {
+ const { category } = await params;
+ const meta = REPORT_CATEGORY_META[category];
+ const label = meta?.label ?? category;
+ const description = meta?.description ?? "In-depth analysis backed by live OpenChainBench benchmark data.";
+
+ return renderHubOG({
+ kicker: `${label} reports`,
+ headline: `${label} reports.`,
+ subline: description,
+ });
+}
diff --git a/src/app/reports/opengraph-image.tsx b/src/app/reports/opengraph-image.tsx
new file mode 100644
index 000000000..c43dbce94
--- /dev/null
+++ b/src/app/reports/opengraph-image.tsx
@@ -0,0 +1,15 @@
+import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template";
+
+export const runtime = "nodejs";
+export const alt = "OpenChainBench reports. In-depth analysis of crypto infrastructure performance backed by live benchmark data.";
+export const size = OG_SIZE;
+export const contentType = "image/png";
+
+export default function OG() {
+ return renderHubOG({
+ kicker: "Reports",
+ headline: "Deeper dives, same data.",
+ subline:
+ "In-depth analysis of crypto infrastructure performance, backed by live benchmark data and open methodology.",
+ });
+}
diff --git a/src/app/rpc-map/page.tsx b/src/app/rpc-map/page.tsx
new file mode 100644
index 000000000..8daa685b2
--- /dev/null
+++ b/src/app/rpc-map/page.tsx
@@ -0,0 +1,98 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { pageMetadata } from "@/lib/page-metadata";
+import { RpcMapClient } from "@/components/speedtest/rpc-map-client";
+import { buildBreadcrumbJsonLd, safeJsonLd } from "@/lib/jsonld";
+import { SITE } from "@/data/site";
+
+export const metadata: Metadata = pageMetadata({
+ path: "/rpc-map",
+ title: "Global RPC Latency Map: real measurements from real connections",
+ description:
+ "World map of RPC provider latency, built from anonymous browser speed tests run by real visitors. See which provider is fastest near you, per chain. Open data, CC-BY-4.0.",
+});
+
+export const revalidate = 3600;
+
+export default function RpcMapPage() {
+ const pageUrl = `${SITE.url}/rpc-map`;
+ const jsonLd = {
+ "@context": "https://schema.org",
+ "@graph": [
+ buildBreadcrumbJsonLd([
+ { name: "Home", item: SITE.url },
+ { name: "RPC Latency Map", item: pageUrl },
+ ]),
+ {
+ "@type": "Dataset",
+ "@id": `${pageUrl}#dataset`,
+ name: "Crowdsourced RPC latency by location",
+ url: pageUrl,
+ description:
+ "Median RPC latency per provider per geographic area, aggregated from anonymous browser speed tests. No IPs stored; coordinates rounded to city-level cells.",
+ license: "https://creativecommons.org/licenses/by/4.0/",
+ creator: { "@id": `${SITE.url}/#org` },
+ distribution: {
+ "@type": "DataDownload",
+ encodingFormat: "application/json",
+ contentUrl: `${SITE.url}/api/speedtest/map`,
+ },
+ },
+ ],
+ };
+
+ return (
+
+
+
+ RPC latency map
+
+
+ Where is each RPC provider actually fast?
+
+
+ Every dot is real measurements from real visitors who ran the{" "}
+
+ browser speed test
+ {" "}
+ at that location. Hover a dot to see which provider wins there and by
+ how many milliseconds. Datacenter benchmarks tell you how fast a
+ provider can be; this map shows what people actually experience.
+
+
+ Fully anonymous: no IPs stored, locations rounded to city-level
+ cells, provider names only (never URLs or keys). Medians and per-area
+ caps keep any single source from tilting the map.
+
+
+
+
+
+
+ How the map is built
+
+
+ When a visitor completes a{" "}
+
+ speed test
+
+ , the browser sends only the provider slug, the chain and the
+ measured median. The server derives a city-level position from the
+ request and immediately discards the address. Results aggregate
+ into ~40 km cells; each cell shows the median of its last
+ contributions over a rolling two-month window, with per-source
+ daily caps so no single machine can flood an area. The dataset is
+ available as{" "}
+
+ open JSON
+ {" "}
+ under CC-BY-4.0.
+
+
+
+ );
+}
diff --git a/src/app/rpc/opengraph-image.tsx b/src/app/rpc/opengraph-image.tsx
new file mode 100644
index 000000000..6cddfe973
--- /dev/null
+++ b/src/app/rpc/opengraph-image.tsx
@@ -0,0 +1,15 @@
+import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template";
+
+export const runtime = "nodejs";
+export const alt = "Fastest RPC providers 2026, by chain and region. Live p50/p90/p99 latency and success rate.";
+export const size = OG_SIZE;
+export const contentType = "image/png";
+
+export default function OG() {
+ return renderHubOG({
+ kicker: "RPC benchmarks",
+ headline: "Fastest RPC, by chain and region.",
+ subline:
+ "p50/p90/p99 latency and success rate for every major RPC provider, probed every 60 seconds from US East, EU West and Singapore.",
+ });
+}
diff --git a/src/app/rpc/page.tsx b/src/app/rpc/page.tsx
index b85eff1e9..afdaad4c5 100644
--- a/src/app/rpc/page.tsx
+++ b/src/app/rpc/page.tsx
@@ -37,6 +37,14 @@ export default async function RpcHubPage() {
const rpcSpecs = specs
.filter((s) => s.slug.endsWith("-rpc") && !NON_CHAIN_RPC_SLUGS.has(s.slug))
.sort((a, b) => a.slug.localeCompare(b.slug));
+ // Keyed / API-key RPC benches (e.g. keyed-rpc-robinhood). These do NOT
+ // belong in the no-key chain matrix above (different tier, and the
+ // slug->chain derivation `replace(/-rpc$/,'')` doesn't apply), so we
+ // surface them in a dedicated aside. Without this they were orphaned
+ // from the RPC cluster with no inbound link from the hub.
+ const keyedRpcSpecs = specs
+ .filter((s) => s.slug.startsWith("keyed-rpc-"))
+ .sort((a, b) => a.slug.localeCompare(b.slug));
const breadcrumbLd = {
"@context": "https://schema.org",
@@ -150,6 +158,44 @@ export default async function RpcHubPage() {
+
+
+
+ Want these numbers from your connection?{" "}
+
+ Run the browser RPC speed test →
+ {" "}
+
+ Paste any endpoints (keyed included), no install, URLs never leave your browser.
+
+
+
+
+ {keyedRpcSpecs.length > 0 && (
+
+
+ Keyed & premium endpoints
+
+
+ The matrix above ranks free, no-key public RPCs. Newer chains
+ served only through API-key endpoints get their own keyed
+ benchmark:
+
+ {/* Dial: svg for the arcs/needle, HTML overlay for the readout
+ (satori does not support svg nodes). */}
+
+
+
+
{READING}
+
ms
+
+
+
+ {/* Copy */}
+
+
+ Free browser tool
+
+
+ RPC Speed Test
+
+
+ Benchmark any RPC endpoints from your own connection. 87 EVM
+ chains, keys never leave your browser.
+
+
+ openchainbench.com/speedtest-rpc
+
+
+
+ ),
+ {
+ ...size,
+ headers: {
+ "cache-control": "public, s-maxage=86400, stale-while-revalidate=604800",
+ },
+ },
+ );
+}
diff --git a/src/app/speedtest-rpc/page.tsx b/src/app/speedtest-rpc/page.tsx
new file mode 100644
index 000000000..8c616b885
--- /dev/null
+++ b/src/app/speedtest-rpc/page.tsx
@@ -0,0 +1,195 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { ArrowUpRight } from "lucide-react";
+import { pageMetadata } from "@/lib/page-metadata";
+import { SpeedtestRpcClient } from "@/components/speedtest/speedtest-rpc-client";
+import { buildBreadcrumbJsonLd, buildFaqPageJsonLd, safeJsonLd } from "@/lib/jsonld";
+import { SITE } from "@/data/site";
+
+// Long-lived branch link: the probe engine ships in the site bundle, so
+// pointing at the source file IS the full disclosure of what runs in the
+// visitor's browser.
+const SOURCE_URL =
+ "https://github.com/ChainBench/OpenChainBench/blob/dev/src/components/speedtest/speedtest-rpc-client.tsx";
+
+const FAQ = [
+ {
+ q: "Is it safe to paste an RPC URL with my API key?",
+ a: "Yes. Every probe fires directly from your browser to the provider. Your URLs and keys are never sent to OpenChainBench servers, never logged, and never leave the browser tab. The probe engine is open source, so you can verify exactly what runs.",
+ },
+ {
+ q: "Why do my numbers differ from the public benchmark pages?",
+ a: "The public benchmarks probe from fixed datacenter regions every 60 seconds around the clock. This tool measures from your device, your network and your location right now. Both use the same anti-cache probe and classification, so the numbers are comparable, but the vantage point is yours. That is the point: it is the latency your application would actually see.",
+ },
+ {
+ q: "Which chains and endpoints can I test?",
+ a: "Any HTTPS JSON-RPC endpoint that speaks the standard eth_ methods, on any EVM chain: public gateways, your own keyed Alchemy, Chainstack or QuickNode URLs, or a self-hosted node with CORS enabled. The chain picker prefills the exact no-key cohort that the public per-chain benchmarks rank, across 87 EVM chains.",
+ },
+ {
+ q: "Why does an endpoint show as browser-blocked?",
+ a: "Some endpoints do not send CORS headers, so browsers refuse to call them. This is a property of the endpoint, not of the test: dapps cannot call those endpoints from a browser either. The tool shows an equivalent curl command so you can measure them from a terminal, and the public benchmarks cover them from datacenter probes.",
+ },
+];
+
+const baseMetadata = pageMetadata({
+ path: "/speedtest-rpc",
+ title: "RPC Speed Test: benchmark your endpoints from your browser",
+ description:
+ "Free online RPC latency test. Paste any RPC URLs and measure their real speed from your own connection, across 87 EVM chains. No install, no signup, keys never leave your browser.",
+});
+
+// pageMetadata pins the generic root card; point socials at the
+// dedicated dial card rendered by ./opengraph-image.tsx instead.
+const OG_IMAGE = {
+ url: `${SITE.url}/speedtest-rpc/opengraph-image`,
+ width: 1200,
+ height: 630,
+};
+export const metadata: Metadata = {
+ ...baseMetadata,
+ openGraph: { ...baseMetadata.openGraph, images: [OG_IMAGE] },
+ twitter: { ...baseMetadata.twitter, images: [OG_IMAGE.url] },
+};
+
+export const revalidate = 3600;
+
+export default function SpeedtestRpcPage() {
+ const pageUrl = `${SITE.url}/speedtest-rpc`;
+ const jsonLd = {
+ "@context": "https://schema.org",
+ "@graph": [
+ buildBreadcrumbJsonLd([
+ { name: "Home", item: SITE.url },
+ { name: "RPC Speed Test", item: pageUrl },
+ ]),
+ {
+ "@type": "WebApplication",
+ "@id": `${pageUrl}#app`,
+ name: "RPC Speed Test",
+ url: pageUrl,
+ applicationCategory: "DeveloperApplication",
+ operatingSystem: "Web",
+ offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
+ description:
+ "Browser-based latency test for JSON-RPC endpoints. Probes run directly from the visitor's connection with the same anti-cache methodology as OpenChainBench's public benchmarks; endpoint URLs never reach OpenChainBench servers.",
+ publisher: { "@id": `${SITE.url}/#org` },
+ // E-E-A-T + GEO: the exact code that runs in the visitor's
+ // browser is public.
+ isBasedOn: SOURCE_URL,
+ license: "https://github.com/ChainBench/OpenChainBench/blob/main/LICENSE",
+ },
+ ],
+ };
+ const faqJsonLd = buildFaqPageJsonLd(
+ FAQ,
+ pageUrl,
+ null,
+ "RPC Speed Test: frequently asked questions",
+ );
+
+ return (
+
+
+ {faqJsonLd && (
+
+ )}
+
+ RPC speed test
+
+
+ Benchmark your RPC endpoints, from your connection
+
+
+ Paste any JSON-RPC URLs, public gateways or your own keyed endpoints,
+ then pick a duration, and watch them race. Every probe fires directly from
+ your browser with the same anti-cache payload as our{" "}
+
+ public RPC benchmarks
+
+ , so the numbers are comparable to the leaderboards.
+
+
+ No install, no signup. Your URLs and API keys never leave this browser
+ tab. Requests go straight from you to the provider.{" "}
+
+ The probe engine is open source
+
+ {" "}
+ so you can verify exactly what runs.
+
+
+
+
+
+
+ Methodology
+
+
+ Each endpoint receives an identical{" "}
+
+ eth_getBlockByNumber("latest", false)
+ {" "}
+ POST with a rotating request id, so no edge cache can answer without
+ touching a real node. One warmup request absorbs the TCP + TLS
+ handshake, then all endpoints are probed once per second in
+ staggered, randomized rounds until the clock runs out, so request
+ bursts never align and network wake-ups hit every endpoint evenly.
+ Responses are classified ok / http_err / jsonrpc_err /
+ timeout, and an endpoint reporting a block more than 20 behind the
+ best tip in the same round is flagged stale. Latency percentiles use
+ ok responses only, exactly like the{" "}
+
+ public benchmark methodology
+
+ . Results reflect your device, network and location. That is the
+ point: it is the latency your application would actually see.
+
@@ -691,11 +700,11 @@ function WalletSide({
{/* Venue-specific extra stats */}
{venue.slug === "hyperliquid" && (() => {
const hlW = w as HlWalletData;
- const hasFunding = Math.abs(hlW.fundingUsd) > 0.5;
+ const hasFunding = Math.abs(hlW.fundingUsd) > 0.01;
return (
-
Volume
+
Total position value
{fmtUsd(volume)}
@@ -766,7 +775,7 @@ function WalletSide({
)}
{hasBorrowing && (
-
Borrowing fees
+
Borrowing fees (vault)
−{fmtUsd(gW.borrowingFeesUsdc)}
@@ -780,6 +789,11 @@ function WalletSide({
{fmtUsd(gW.netCostUsdc)}
+ {(gW.gainsExclusiveFeesUsdc ?? 0) > 0.01 && (
+
+ Incl. {fmtUsd(gW.gainsExclusiveFeesUsdc!)} on pairs not listed on {otherVenue.name} — excluded from comparison.
+
+ )}
);
@@ -899,7 +913,7 @@ function WalletSummaryCard({ result }: { result: FeeCompareResult }) {
Wallet analysis
- Net cost including carry (funding + borrowing) vs taker-rate projection on the other venue
+ Net cost including carry (funding + borrowing) vs a maker/taker-aware projection on the other venue, matched on the coins both venues list
+ {selected.size > 0
+ ? "No samples for this provider selection here yet. Clear the filter or run a test with these providers."
+ : "No community samples for this chain yet. The map fills up as people run the speed test: every completed test adds one anonymous point to its city."}
+
+
+ Run a test for this chain →
+
+
+ )}
+
+
+ {/* Nearest measured areas to the pin */}
+ {pin && (
+
+
+ Closest measured areas to {pin.label}
+
+ {nearest.length === 0 ? (
+
+ Nothing measured near this pin yet for this selection.{" "}
+
+ Run a test from here to add the first point →
+
+
+ median of {p.samples} contribution{p.samples > 1 ? "s" : ""} · last measured {timeAgo(p.lastTs)}
+
+
+ );
+ })}
+
+
+ Bars are the last {Math.min(24, Math.max(...(cellDetail?.providers ?? [{ samples: 0 }]).map((p) => p.samples)))} contributed medians for this area, oldest to newest. Hover a bar for the exact reading and time.
+
+ );
+}
+
+function Gauge({
+ ms,
+ label,
+ sub,
+ live = false,
+ className = "mx-auto w-[280px] sm:w-[340px]",
+}: {
+ ms: number | null;
+ label: string;
+ sub: string;
+ live?: boolean;
+ className?: string;
+}) {
+ const eased = useEased(ms);
+ const angle = eased == null ? GAUGE_START : msToAngle(eased);
+ const color = eased == null ? "var(--color-ink-faint)" : msColor(eased);
+ // Speedtest-style progressive fill: the arc is rendered as many thin
+ // segments; those before the needle carry a smooth green→amber→red
+ // gradient, the rest stay on the grey track. Because `eased` updates
+ // per animation frame, the fill grows/retracts fluidly with the needle.
+ const SEGS = 56;
+ const fillT = (angle - GAUGE_START) / GAUGE_SWEEP; // 0..1
+ return (
+
+
+ {/* Readout sits in the dial's open mouth (the 240° sweep leaves
+ the bottom-center free), Speedtest-style: number, then the unit
+ under it. Sized to never collide with the 1/1000 scale labels
+ at the mouth edges, at any tile width. */}
+
+
+ {eased == null ? "—" : Math.round(eased)}
+
+
+
+ MS
+
+
+ {/* Identity in normal flow below the dial: immune to overlap. */}
+
+
{label}
+
{sub}
+
+
+ );
+}
+
+// ─── Main component ──────────────────────────────────────────────────
+
+const DURATIONS = [
+ { sec: 15, label: "15 s · quick" },
+ { sec: 30, label: "30 s · standard" },
+ { sec: 60, label: "60 s · thorough" },
+];
+
+const EXAMPLE_URLS = [
+ "https://ethereum-rpc.publicnode.com",
+ "https://eth.llamarpc.com",
+ "https://1rpc.io/eth",
+];
+
+function hostOf(url: string): string {
+ try {
+ return new URL(url).host;
+ } catch {
+ return url;
+ }
+}
+
+/** Display name: benchmarked provider name when the URL is from our
+ * directory, bare host otherwise. */
+function epLabel(ep: { url: string; host: string }): string {
+ return providerForUrl(ep.url) ?? ep.host;
+}
+
+// Keyed-provider families recognized by hostname, so a private Alchemy
+// or Chainstack URL contributes to the map as its brand only. The URL
+// and key never leave the browser.
+const KEYED_FAMILY_PATTERNS: [RegExp, string][] = [
+ [/\.alchemy\.com$/i, "alchemy"],
+ [/\.infura\.io$/i, "infura"],
+ [/\.quiknode\.pro$/i, "quicknode"],
+ [/\.quicknode\.com$/i, "quicknode"],
+ [/chainstack\.com$/i, "chainstack"],
+ [/\.ankr\.com$/i, "ankr"],
+ [/\.helius-rpc\.com$/i, "helius"],
+];
+
+function contributionSlug(url: string, host: string): string | null {
+ const dir = directoryEntryForUrl(url);
+ if (dir) return dir.slug;
+ for (const [re, family] of KEYED_FAMILY_PATTERNS) {
+ if (re.test(host)) return family;
+ }
+ return null;
+}
+
+const CONTRIBUTE_PREF_KEY = "ocb-st-contribute";
+
+/** Fire-and-forget anonymous contribution to the global latency map.
+ * Sends provider slugs + medians only; custom hosts are skipped. */
+function contributeToMap(chainSlug: string | null, endpoints: Endpoint[]) {
+ try {
+ if (typeof localStorage !== "undefined" && localStorage.getItem(CONTRIBUTE_PREF_KEY) === "off") return;
+ } catch {
+ /* storage blocked: still fine to contribute */
+ }
+ if (!chainSlug) return;
+ const entries = endpoints
+ .filter((e) => e.samples.length >= 3)
+ .map((e) => {
+ const slug = contributionSlug(e.url, e.host);
+ if (!slug) return null;
+ const s = stats(e);
+ if (Number.isNaN(s.p50)) return null;
+ return { slug, p50: s.p50, n: s.n };
+ })
+ .filter((x): x is { slug: string; p50: number; n: number } => x !== null)
+ .slice(0, 8);
+ if (entries.length === 0) return;
+ void fetch("/api/speedtest/contribute", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ chain: chainSlug, entries }),
+ keepalive: true,
+ }).catch(() => {});
+}
+
+export function SpeedtestRpcClient() {
+ const [stage, setStage] = useState("setup");
+ const [inputs, setInputs] = useState(["", ""]);
+ const [chainQuery, setChainQuery] = useState("");
+ const [pickedChain, setPickedChain] = useState(null);
+ // Ref mirror so the long-running start() closure reads the current
+ // value instead of the one captured at callback creation.
+ const pickedChainRef = useRef(null);
+ const [contribOff, setContribOff] = useState(false);
+ useEffect(() => {
+ try {
+ setContribOff(localStorage.getItem(CONTRIBUTE_PREF_KEY) === "off");
+ } catch {
+ /* storage blocked */
+ }
+ }, []);
+ const [prefillState, setPrefillState] = useState<"idle" | "validating" | "done">("idle");
+ const [skippedProviders, setSkippedProviders] = useState([]);
+ const prefillEpoch = useRef(0);
+ const [durationSec, setDurationSec] = useState(30);
+ const [endpoints, setEndpoints] = useState([]);
+ const [activeId, setActiveId] = useState(null);
+ const [elapsed, setElapsed] = useState(0);
+ const [round, setRound] = useState(0);
+ const abortRef = useRef<{ stop: boolean }>({ stop: false });
+
+ const setInput = (i: number, v: string) =>
+ setInputs((xs) => xs.map((x, j) => (j === i ? v : x)));
+ const addInput = () => setInputs((xs) => [...xs, ""]);
+ const removeInput = (i: number) =>
+ setInputs((xs) => (xs.length <= 1 ? xs : xs.filter((_, j) => j !== i)));
+
+ const chainMatches = useMemo(() => {
+ const q = chainQuery.trim().toLowerCase();
+ if (!q) return [];
+ return RPC_DIRECTORY.filter(
+ (c) => c.name.toLowerCase().includes(q) || c.slug.includes(q),
+ ).slice(0, 8);
+ }, [chainQuery]);
+
+ const pickChain = (c: DirectoryChain) => {
+ // Fill immediately, then validate each endpoint from THIS browser
+ // (eth_chainId, short timeout) and silently drop the ones that
+ // reject browser requests — a prefilled endpoint must never show
+ // up as a dead "no CORS" tile. Manual URLs keep full feedback.
+ const epoch = ++prefillEpoch.current;
+ setInputs(c.endpoints.map((e) => e.url));
+ setPickedChain(c.slug);
+ pickedChainRef.current = c.slug;
+ setChainQuery("");
+ setSkippedProviders([]);
+ setPrefillState("validating");
+ void (async () => {
+ const checks = await Promise.all(
+ c.endpoints.map(async (e) => {
+ try {
+ const r = await rpcCall(e.url, "eth_chainId", []);
+ return { e, ok: r.verdict === "ok" || r.verdict === "http_err" };
+ } catch {
+ return { e, ok: false }; // CORS / network-level rejection
+ }
+ }),
+ );
+ if (prefillEpoch.current !== epoch) return; // user picked again
+ const good = checks.filter((x) => x.ok).map((x) => x.e.url);
+ const skipped = checks.filter((x) => !x.ok).map((x) => x.e.provider);
+ // FAIL-OPEN: when every endpoint flunks the quick check, the
+ // blocker is almost certainly environmental (VPN, shields, a
+ // privacy extension, captive portal), not five independent CORS
+ // failures. Keep the full list and let the real test classify;
+ // only drop endpoints when the signal is differential (some pass,
+ // some fail).
+ if (good.length === 0) {
+ setSkippedProviders([]);
+ setPrefillState("done");
+ console.warn(
+ "[speedtest] reachability precheck failed for ALL endpoints; keeping the list (likely browser/network-level blocking).",
+ );
+ return;
+ }
+ if (skipped.length > 0) setInputs(good);
+ setSkippedProviders(skipped);
+ setPrefillState("done");
+ })();
+ };
+
+ const validCount = inputs.filter((u) => {
+ try {
+ const p = new URL(u.trim());
+ return p.protocol === "https:" && !p.username && !p.password;
+ } catch {
+ return false;
+ }
+ }).length;
+
+ // ── Test orchestration ────────────────────────────────────────────
+ const start = useCallback(async () => {
+ const urls = Array.from(
+ new Set(
+ inputs
+ .map((u) => u.trim())
+ .filter((u) => {
+ try {
+ const p = new URL(u);
+ return p.protocol === "https:" && !p.username && !p.password;
+ } catch {
+ return false;
+ }
+ }),
+ ),
+ );
+ if (urls.length === 0) return;
+ abortRef.current = { stop: false };
+ const eps: Endpoint[] = urls.map((url, i) => ({
+ id: `ep${i}`,
+ url,
+ host: hostOf(url),
+ status: "checking",
+ chainId: null,
+ samples: [],
+ staleRounds: 0,
+ lastMs: null,
+ }));
+ setEndpoints(eps);
+ setStage("testing");
+ setElapsed(0);
+ setRound(0);
+
+ const patch = (id: string, p: Partial) =>
+ setEndpoints((xs) => xs.map((e) => (e.id === id ? { ...e, ...p } : e)));
+
+ // Phase 1: reachability + chain detection (eth_chainId), sequential
+ // so the gauge can narrate each endpoint as it comes online.
+ for (const ep of eps) {
+ if (abortRef.current.stop) return;
+ setActiveId(ep.id);
+ try {
+ const r = await rpcCall(ep.url, "eth_chainId", []);
+ const chainId =
+ r.verdict === "ok" && typeof r.result === "string" ? r.result : null;
+ ep.chainId = chainId;
+ ep.status = "ready";
+ patch(ep.id, { chainId, status: "ready", lastMs: r.ms });
+ } catch {
+ ep.status = "cors_blocked";
+ patch(ep.id, {
+ status: "cors_blocked",
+ error:
+ "This endpoint does not answer browser requests (no CORS headers on preflight). Dapps cannot call it from a browser either. It may still be fast server-side: measure it from a terminal with the curl below.",
+ });
+ }
+ }
+ const live = eps.filter((e) => e.status === "ready");
+ if (live.length === 0) {
+ setStage("results");
+ return;
+ }
+
+ // Phase 2: one warmup each (absorbs TCP+TLS so measured rounds see
+ // steady-state round trips, same reasoning as the harness).
+ for (const ep of live) {
+ if (abortRef.current.stop) return;
+ setActiveId(ep.id);
+ patch(ep.id, { status: "testing" });
+ try {
+ const w = await rpcCall(ep.url, "eth_getBlockByNumber", ["latest", false]);
+ patch(ep.id, { lastMs: w.ms });
+ } catch {
+ /* keep it in the pool; measured rounds will classify */
+ }
+ }
+
+ // Phase 3: measured rounds until the clock runs out. Every endpoint
+ // is probed CONCURRENTLY each round (starts staggered by ~90ms so
+ // request bursts never align) at roughly one probe per second per
+ // endpoint — every needle lives simultaneously, speedtest-style.
+ // RPC payloads are a few KB, so uplink contention is negligible
+ // against the 10-500ms latencies being measured.
+ const tEnd = performance.now() + durationSec * 1000;
+ const t0 = performance.now();
+ let roundNo = 0;
+ const timer = setInterval(
+ () => setElapsed(Math.min(durationSec, (performance.now() - t0) / 1000)),
+ 200,
+ );
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+ try {
+ while (performance.now() < tEnd && !abortRef.current.stop) {
+ roundNo += 1;
+ setRound(roundNo);
+ const roundStart = performance.now();
+ const order = [...live].sort(() => Math.random() - 0.5);
+ const roundBlocks: Record = {};
+ await Promise.all(
+ order.map(async (ep, idx) => {
+ await sleep(idx * 90);
+ if (abortRef.current.stop) return;
+ let sample: Sample;
+ try {
+ const r = await rpcCall(ep.url, "eth_getBlockByNumber", ["latest", false]);
+ sample = { ms: r.ms, verdict: r.verdict, block: r.block, round: roundNo };
+ } catch {
+ sample = { ms: PROBE_TIMEOUT_MS, verdict: "timeout", block: null, round: roundNo };
+ }
+ roundBlocks[ep.id] = sample.block;
+ ep.samples.push(sample);
+ patch(ep.id, { samples: [...ep.samples], lastMs: sample.ms });
+ }),
+ );
+ // Stale detection: an endpoint more than STALE_BLOCKS behind the
+ // best tip seen this round is serving an old head.
+ const tips = Object.values(roundBlocks).filter((b): b is number => b != null);
+ if (tips.length >= 2) {
+ const best = Math.max(...tips);
+ for (const ep of live) {
+ const b = roundBlocks[ep.id];
+ if (b != null && best - b > STALE_BLOCKS) {
+ ep.staleRounds += 1;
+ patch(ep.id, { staleRounds: ep.staleRounds });
+ }
+ }
+ }
+ // Pace: ~1 round/second regardless of how fast the round ran.
+ const roundDur = performance.now() - roundStart;
+ if (performance.now() < tEnd) await sleep(Math.max(0, 1050 - roundDur));
+ }
+ } finally {
+ clearInterval(timer);
+ }
+ for (const ep of live) patch(ep.id, { status: "done" });
+ // Anonymous map contribution (provider slugs + medians only, see
+ // /rpc-map). Fire-and-forget; opt-out persisted in localStorage.
+ contributeToMap(pickedChainRef.current, live);
+ setActiveId(null);
+ // Small beat before the reveal — lets the last needle move land.
+ await new Promise((r) => setTimeout(r, 650));
+ setStage("results");
+ }, [inputs, durationSec]);
+
+ const stop = useCallback(() => {
+ abortRef.current.stop = true;
+ }, []);
+
+ useEffect(() => () => { abortRef.current.stop = true; }, []);
+
+ const ranked = useMemo(() => {
+ const done = endpoints.filter((e) => e.samples.length > 0);
+ return done
+ .map((e) => ({ ep: e, s: stats(e) }))
+ .sort((a, b) => (Number.isNaN(a.s.p50) ? 1 : Number.isNaN(b.s.p50) ? -1 : a.s.p50 - b.s.p50));
+ }, [endpoints]);
+ const maxP50 = Math.max(...ranked.map((r) => (Number.isNaN(r.s.p50) ? 0 : r.s.p50)), 1);
+
+ const reset = () => {
+ abortRef.current.stop = true;
+ setStage("setup");
+ setEndpoints([]);
+ setActiveId(null);
+ setElapsed(0);
+ };
+
+ return (
+
+
+ {/* Prefill from the benchmarked no-key cohort: pick a chain and
+ get exactly the endpoints the public per-chain benches probe
+ continuously (90 EVM chains, 291 endpoints). */}
+
+
+ Prefill with the endpoints we already benchmark. Pick a chain:
+
+
+ Testing from your connection · round {round}
+
+
+
+ {/* One speedometer per endpoint, all live at once. The
+ active one (currently being probed) lifts and gets an
+ ink border; the others keep their last reading. */}
+ {(() => {
+ const visible = endpoints.filter((e) => e.status !== "cors_blocked");
+ const blocked = endpoints.filter((e) => e.status === "cors_blocked");
+ return (
+ <>
+ {/* Flex-wrap + justify-center: tiles keep an ideal width
+ at every count and orphan rows self-center (4 tiles
+ read as a clean 2x2, a lone 7th sits centered). */}
+
+ Not testable from a browser (skipped): {blockedDir.map((e) => epLabel(e)).join(", ")}. The public bench covers {blockedDir.length > 1 ? "them" : "it"} server-side.
+
+ Same probe and classification as the public benchmarks
+ (rotating-id anti-cache, ok / http_err / jsonrpc_err / stale /
+ timeout). Your URLs never left this browser tab.
+
+
+
+
+ {contribOff
+ ? "Anonymous map contribution is off for this browser."
+ : "This result was added anonymously to the global latency map (provider names and medians only, no URLs, no IP stored)."}{" "}
+
+ View the map
+
+ {" · "}
+
+