diff --git a/benchmarks/ethereum-classic-rpc.yml b/benchmarks/ethereum-classic-rpc.yml index 6add2964..c7122e78 100644 --- a/benchmarks/ethereum-classic-rpc.yml +++ b/benchmarks/ethereum-classic-rpc.yml @@ -4,7 +4,7 @@ slug: ethereum-classic-rpc number: "194" title: Fastest free Ethereum Classic RPC, live no-key endpoint latency seo_title: "Fastest free Ethereum Classic RPC 2026" -seo_description: "{{best_name}} leads free Ethereum Classic RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 3 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free Ethereum Classic RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 1 no-key provider measured every 60s from 3 regions." subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Ethereum Classic EVM endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,13 +14,13 @@ unit: ms higher_is_better: false seo_intro: | - Ethereum Classic (chain 61) is the original Ethereum chain post-2016 DAO fork, continuing as a Proof-of-Work EVM network with ETC as the gas token. The chain upholds code-is-law immutability and has a dedicated long-term holder community. Three keyless endpoints are probed continuously: dRPC (etc.drpc.org), ETCDesktop (etc.etcdesktop.com), and ETCMC (rpc.etcmc.net). + Ethereum Classic (chain 61) is the original Ethereum chain post-2016 DAO fork, continuing as a Proof-of-Work EVM network with ETC as the gas token. The chain upholds code-is-law immutability and has a dedicated long-term holder community. One keyless endpoint is probed continuously: dRPC (etc.drpc.org). ETCDesktop and ETCMC are no longer responding to probes and have been removed. abstract: | Per-chain member of the RPC latency cluster, extended to Ethereum Classic. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Ethereum Classic endpoint - that sustains continuous probing, 3 providers at launch, + that sustains continuous probing, 1 provider at launch, every 60 seconds, from us-east, eu-west and Singapore. The cross-chain view lives on the parent `rpc-capabilities` benchmark; this page is the Ethereum Classic-scoped answer with per-region breakdowns as a first-class dimension. @@ -31,16 +31,16 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err`, `stale` (more than 20 blocks behind the cross-provider tip), `timeout`." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"ethereum-classic\"`. Provider coverage at launch: 3 no-key endpoints." + - "Chain scope: every query on this page is pinned to `chain=\"ethereum-classic\"`. Provider coverage: 1 no-key endpoint (dRPC). ETCDesktop and ETCMC stopped responding to probes and have been removed." findings: - - "{{best_name}} currently leads free Ethereum Classic RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 3 measured providers." + - "{{best_name}} currently leads free Ethereum Classic RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h), 1 measured provider." faq: - q: "What is the fastest free Ethereum Classic RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 3 no-key providers probed every 60 seconds from us-east, eu-west and Singapore." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 1 no-key provider probed every 60 seconds from us-east, eu-west and Singapore." - q: "Which Ethereum Classic RPCs work without an API key?" - a: "3 endpoints sustain continuous keyless probing: dRPC, ETCDesktop, and ETCMC. Every endpoint was live-verified before inclusion." + a: "1 endpoint sustains continuous keyless probing: dRPC (etc.drpc.org). ETCDesktop and ETCMC stopped responding to probes and have been removed from the bench." - q: "Does the fastest Ethereum Classic RPC change by region?" a: "Often. The region tabs at the top of the page re-scope every number to a single origin." - q: "How is Ethereum Classic RPC latency measured here?" @@ -84,49 +84,3 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="ethereum-classic", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="ethereum-classic", region="sgp"}[1h]) - - - slug: etcdesktop - name: ETCDesktop - tag: ETCDesktop community public RPC for Ethereum Classic - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to ETCDesktop's no-key Ethereum Classic endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcdesktop", chain="ethereum-classic"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="etcdesktop", chain="ethereum-classic"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="etcdesktop", chain="ethereum-classic"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="etcdesktop", chain="ethereum-classic"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="etcdesktop", chain="ethereum-classic"}) / sum(ocb:rpc_call:rate_24h{provider="etcdesktop", chain="ethereum-classic"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="etcdesktop", chain="ethereum-classic"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="etcdesktop", chain="ethereum-classic"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcdesktop", chain="ethereum-classic", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="etcdesktop", chain="ethereum-classic", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcdesktop", chain="ethereum-classic", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="etcdesktop", chain="ethereum-classic", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcdesktop", chain="ethereum-classic", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="etcdesktop", chain="ethereum-classic", region="sgp"}[1h]) - - - slug: etcmc - name: ETCMC - tag: ETCMC community public RPC for Ethereum Classic - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to ETCMC's no-key Ethereum Classic endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcmc", chain="ethereum-classic"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="etcmc", chain="ethereum-classic"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="etcmc", chain="ethereum-classic"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="etcmc", chain="ethereum-classic"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="etcmc", chain="ethereum-classic"}) / sum(ocb:rpc_call:rate_24h{provider="etcmc", chain="ethereum-classic"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="etcmc", chain="ethereum-classic"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="etcmc", chain="ethereum-classic"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcmc", chain="ethereum-classic", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="etcmc", chain="ethereum-classic", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcmc", chain="ethereum-classic", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="etcmc", chain="ethereum-classic", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcmc", chain="ethereum-classic", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="etcmc", chain="ethereum-classic", region="sgp"}[1h]) diff --git a/benchmarks/indexing-freshness.yml b/benchmarks/indexing-freshness.yml deleted file mode 100644 index 67cc1f92..00000000 --- a/benchmarks/indexing-freshness.yml +++ /dev/null @@ -1,127 +0,0 @@ -# OpenChainBench. Bench № 070 - -slug: indexing-freshness -number: "070" -title: Freshest wallet data API. Zerion, Allium, Mobula -seo_title: "Freshest wallet data API 2026" -seo_description: "How fast do wallet APIs index a new transaction? Zerion, Allium and Mobula measured live on organic Base transfers, second by second." -subtitle: Seconds between an organic transfer confirming on Base and the moment each wallet data API first returns it, measured continuously on real user transactions. -category: Aggregators -status: live -metric: Indexing freshness -unit: sec -higher_is_better: false - -seo_intro: | - Every wallet app, portfolio tracker and exchange faces the same - question. when a user receives funds, how long before the API my - app is built on actually shows it. Providers advertise "real-time" - and "sub-second indexing"; none publish comparable numbers. This - benchmark measures it the only way that cannot be gamed. we pick a - random, organic native transfer from the newest Base block, real - user, different wallet every time, and poll each provider's wallet - transactions API until the tx hash appears. Because the ground - truth is a random real transaction there is no benchmark wallet a - provider could special-case, and every measurement is publicly - re-verifiable from the tx hash. Cohort. Zerion, Allium, - GoldRush (Covalent) and Mobula, all probed with the identical - schedule from the same host. The timing context matters in 2026. - Dune Sim is sunsetting and SimpleHash is gone, so teams are - choosing a replacement wallet data API right now, and freshness is - the spec sheet line that separates them. - -abstract: | - We measure the visibility lag of wallet data APIs. the time between - an organic native transfer confirming on Base (T0 = the instant our - own RPC observes the containing block) and the first poll at which - each provider's wallet transactions endpoint returns that tx hash. - One probe event per 10 minutes; each event uses a fresh random - transaction from a fresh block, so wallets are cold and results - include any lazy, on-demand indexing path a provider runs for - never-before-queried addresses. Detection is parser-free (tx hash - substring in the raw response), the poll schedule is front-loaded - (1s to 120s, identical for every provider), and per-provider - monthly quota guards keep the probe inside every free tier. An - event a provider has not indexed within 120 seconds counts as - missed, which feeds the reliability column, because an API that - never shows the deposit is worse than a slow one. - -methodology: - - "Ground truth: one probe event per 10 minutes. The harness watches new Base blocks through its own RPC and picks one random plain native transfer (value > 0, empty calldata, OP-stack system deposit excluded). T0 is the instant the harness observes the containing block; the same T0 is used for every provider." - - "Anti-gaming by construction: the measured wallet is a random real user's address, different on every event, so no provider can whitelist or pre-warm a known benchmark wallet. Every measurement is re-verifiable by anyone from the public tx hash." - - "Poll schedule: each provider's wallet transactions endpoint is polled at 1, 2, 3, 4, 6, 8, 11, 15, 20, 26, 34, 45, 60, 80, 100 and 120 seconds after T0. Reported lag is the first poll at which the response contains the tx hash, an upper bound with resolution equal to the gap between consecutive polls." - - "Detection is parser-free: the raw response body is scanned for the tx hash substring. Every cohort API returns the hash verbatim, so no provider gains or loses from response schema differences." - - "Cold wallets by design: because each event uses a never-before-queried address, results include any on-demand indexing path a provider runs for new wallets. This mirrors the experience of a user opening an app on a fresh address, and it is disclosed here because warm, continuously-queried wallets may see lower lags." - - "Classification: found (lag recorded), missed (not indexed within 120s), api_error (provider errored on every poll of the event). Percentiles are computed from the histogram of found lags over 24h; the miss rate is published alongside because latency without reliability is a misleading ranking signal." - - "Quota fairness: per-provider monthly call budgets sized to each free tier with headroom, enforced by a guard that pauses probing at 90%. Allium participates in every third event (20k calls/month free tier); all other providers join every event. Sample sizes per provider are published." - - "Scope: Base mainnet, single probe region. Freshness lags are measured in seconds while cross-region network deltas are milliseconds, so multi-region probing would add cost without changing the ranking; this is revisited if two providers converge within one poll-step of each other." - -findings: - - "{{best_name}} currently leads at {{best_p50}} (p50 of found lags, 24h) across organic Base transfers." - - "{{name:zerion}} sits at {{p50:zerion}}. Early runs showed a bimodal pattern, some events indexed in about a second and others surfacing several seconds later, consistent with a caching layer in front of the wallet endpoint." - - "{{name:goldrush}} trails at {{p50:goldrush}} on this probe. Its unified multi-chain schema trades freshness for breadth, a real trade-off teams should weigh explicitly." - - "Miss rates matter more than medians: an API that fails to show a deposit within two minutes breaks the user flow entirely, so read the reliability column before the latency one." - -faq: - - q: "Which wallet data API shows new transactions fastest?" - a: "Per the live leaderboard above: {{best_name}} at {{best_p50}} (p50 over 24h of organic Base transfers). The ranking re-sorts continuously as probe events land every 10 minutes. Check the miss-rate column too, a provider that occasionally never indexes a transfer is worse for a wallet app than one that is a second slower on median." - - q: "How is indexing freshness measured here?" - a: "The harness picks a random organic native transfer from the newest Base block, records T0 when its own RPC observes the block, then polls every provider's wallet transactions endpoint on an identical front-loaded schedule (1s to 120s) until the tx hash appears in the raw response. The lag is the first successful poll. Events a provider has not indexed within 120 seconds count as missed. The full harness is open source and each measurement can be re-verified from the public tx hash." - - q: "Why use random real transactions instead of a controlled test wallet?" - a: "Two reasons. First, integrity: a fixed benchmark wallet could be whitelisted or pre-warmed by a provider; a random real user's transfer, different every event, cannot. Second, realism: cold, never-before-queried addresses exercise any lazy indexing path a provider runs for new wallets, which is exactly what a new user experiences. The trade-off, disclosed in the methodology, is that continuously-queried warm wallets may see lower lags than reported here." - - q: "Does this benchmark cover more chains than Base?" - a: "Not yet. Base was chosen first for its high volume of plain native transfers (dense organic ground truth) and 2-second blocks. The harness is chain-agnostic and additional EVM chains join by adding an RPC endpoint, subject to each provider's free-tier quota budget. Chain coverage itself differs per provider and is part of what teams should evaluate." - - q: "Why does freshness matter when choosing a wallet API in 2026?" - a: "Because the market is consolidating. Dune Sim is sunsetting in August 2026 and SimpleHash shut down in 2025, so many teams are migrating to a new wallet data API right now. Providers advertise real-time indexing but publish no comparable numbers; deposit visibility lag is the difference between a user seeing their funds arrive and a support ticket. This page is the only continuously measured, provider-neutral comparison of that number." - -source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/indexing-freshness - -prometheus: - window: 24h - freshness_metric: indexing_freshness_seconds - -providers: - - slug: mobula - name: Mobula - tag: Wallet + market data API, 50+ chains - formula: "Median (p50) over 24h of visibility lag in seconds between an organic Base transfer confirming and Mobula's wallet transactions endpoint first returning it, from the shared histogram estimator." - queries: - p50: histogram_quantile(0.5, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="mobula"}[24h]))) - p90: histogram_quantile(0.9, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="mobula"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="mobula"}[24h]))) - success: sum(increase(indexing_probe_total{provider="mobula", result="found"}[24h])) / sum(increase(indexing_probe_total{provider="mobula", result=~"found|missed"}[24h])) - sample_size: sum(increase(indexing_probe_total{provider="mobula", result=~"found|missed"}[24h])) - series: avg_over_time(indexing_freshness_seconds{provider="mobula"}[1h]) - - slug: zerion - name: Zerion - tag: Wallet API behind the Zerion app, 25+ chains - formula: "Median (p50) over 24h of visibility lag in seconds between an organic Base transfer confirming and Zerion's wallet transactions endpoint first returning it, from the shared histogram estimator." - queries: - p50: histogram_quantile(0.5, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="zerion"}[24h]))) - p90: histogram_quantile(0.9, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="zerion"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="zerion"}[24h]))) - success: sum(increase(indexing_probe_total{provider="zerion", result="found"}[24h])) / sum(increase(indexing_probe_total{provider="zerion", result=~"found|missed"}[24h])) - sample_size: sum(increase(indexing_probe_total{provider="zerion", result=~"found|missed"}[24h])) - series: avg_over_time(indexing_freshness_seconds{provider="zerion"}[1h]) - - slug: goldrush - name: GoldRush - tag: Covalent's multi-chain wallet API, unified schema - formula: "Median (p50) over 24h of visibility lag in seconds between an organic Base transfer confirming and GoldRush's transactions endpoint first returning it, from the shared histogram estimator." - queries: - p50: histogram_quantile(0.5, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="goldrush"}[24h]))) - p90: histogram_quantile(0.9, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="goldrush"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="goldrush"}[24h]))) - success: sum(increase(indexing_probe_total{provider="goldrush", result="found"}[24h])) / sum(increase(indexing_probe_total{provider="goldrush", result=~"found|missed"}[24h])) - sample_size: sum(increase(indexing_probe_total{provider="goldrush", result=~"found|missed"}[24h])) - series: avg_over_time(indexing_freshness_seconds{provider="goldrush"}[1h]) - - slug: allium - name: Allium - tag: Enterprise realtime wallet APIs, 100+ chains - formula: "Median (p50) over 24h of visibility lag in seconds between an organic Base transfer confirming and Allium's wallet transactions endpoint first returning it. Allium joins every third event to respect its free-tier quota." - queries: - p50: histogram_quantile(0.5, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="allium"}[24h]))) - p90: histogram_quantile(0.9, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="allium"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="allium"}[24h]))) - success: sum(increase(indexing_probe_total{provider="allium", result="found"}[24h])) / sum(increase(indexing_probe_total{provider="allium", result=~"found|missed"}[24h])) - sample_size: sum(increase(indexing_probe_total{provider="allium", result=~"found|missed"}[24h])) - series: avg_over_time(indexing_freshness_seconds{provider="allium"}[1h]) diff --git a/benchmarks/xdc-rpc.yml b/benchmarks/xdc-rpc.yml index e4c64a92..493e78f5 100644 --- a/benchmarks/xdc-rpc.yml +++ b/benchmarks/xdc-rpc.yml @@ -4,7 +4,7 @@ slug: xdc-rpc number: "153" title: Fastest free XDC Network RPC, live no-key endpoint latency seo_title: "Fastest free XDC Network RPC 2026" -seo_description: "{{best_name}} leads free XDC Network RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 5 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free XDC Network RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 2 no-key providers measured every 60s from 3 regions." subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public XDC Network RPC endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,13 +14,13 @@ unit: ms higher_is_better: false seo_intro: | - XDC Network (chain 50) is an EVM-compatible hybrid blockchain targeting enterprise and trade-finance use cases, XDC gas token, delegated proof of stake consensus, designed for ISO 20022-compliant financial applications. The XDC Foundation's rpc.xinfin.network and additional endpoints from Ankr and the community provide keyless access probed every 60 seconds from three regions. + XDC Network (chain 50) is an EVM-compatible hybrid blockchain targeting enterprise and trade-finance use cases, XDC gas token, delegated proof of stake consensus, designed for ISO 20022-compliant financial applications. Two keyless endpoints are probed continuously: Ankr and XDC eRPC (erpc.xinfin.network). XDC Foundation official, XDCrpc, and XDC.org stopped responding to probes and have been removed. abstract: | Per-chain member of the RPC latency cluster, extended to XDC Network. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public XDC Network endpoint - that sustains continuous probing, 5 providers at launch, + that sustains continuous probing, 2 providers, every 60 seconds, from us-east, eu-west and Singapore. The harness classifies every response (ok / http_err / jsonrpc_err / stale / timeout) so the leaderboard rewards sustained availability rather than @@ -34,16 +34,16 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"xdc\"`. Provider coverage at launch: 5 no-key endpoints (XDC Foundation, Ankr, XDCrpc, XDC eRPC, XDC.org)." + - "Chain scope: every query on this page is pinned to `chain=\"xdc\"`. Provider coverage: 2 no-key endpoints (Ankr, XDC eRPC). XDC Foundation official, XDCrpc and XDC.org stopped responding to probes and have been removed." findings: - - "{{best_name}} currently leads free XDC Network RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 5 measured providers." + - "{{best_name}} currently leads free XDC Network RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 2 measured providers." faq: - q: "What is the fastest free XDC Network RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 5 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 2 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples." - q: "Which XDC Network RPCs work without an API key?" - a: "5 endpoints sustain continuous keyless probing at launch: XDC Foundation, Ankr, XDCrpc, XDC eRPC, XDC.org. Every listed endpoint was live-verified before inclusion." + a: "2 endpoints sustain continuous keyless probing: Ankr and XDC eRPC (erpc.xinfin.network). XDC Foundation official, XDCrpc and XDC.org stopped responding to probes and have been removed." - q: "Does the fastest XDC Network RPC change by region?" a: "Often. The region tabs at the top of the page re-scope every number to a single origin; pick the one closest to where your requests originate." - q: "How is XDC Network RPC latency measured here?" @@ -65,29 +65,6 @@ dimensions: - { value: sgp, label: Singapore } providers: - - slug: xdc-official - name: XDC Foundation - tag: XDC Foundation's primary public RPC - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to XDC Foundation's no-key XDC Network endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-official", chain="xdc"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="xdc-official", chain="xdc"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="xdc-official", chain="xdc"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="xdc-official", chain="xdc"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="xdc-official", chain="xdc"}) / sum(ocb:rpc_call:rate_24h{provider="xdc-official", chain="xdc"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="xdc-official", chain="xdc"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="xdc-official", chain="xdc"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-official", chain="xdc", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdc-official", chain="xdc", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-official", chain="xdc", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdc-official", chain="xdc", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-official", chain="xdc", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdc-official", chain="xdc", region="sgp"}[1h]) - - slug: ankr name: Ankr tag: Multi-cloud RPC network @@ -111,29 +88,6 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="xdc", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="xdc", region="sgp"}[1h]) - - slug: xdcrpc - name: XDCrpc - tag: Community-operated XDC public RPC - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to XDCrpc's no-key XDC Network endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdcrpc", chain="xdc"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="xdcrpc", chain="xdc"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="xdcrpc", chain="xdc"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="xdcrpc", chain="xdc"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="xdcrpc", chain="xdc"}) / sum(ocb:rpc_call:rate_24h{provider="xdcrpc", chain="xdc"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="xdcrpc", chain="xdc"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="xdcrpc", chain="xdc"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdcrpc", chain="xdc", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdcrpc", chain="xdc", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdcrpc", chain="xdc", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdcrpc", chain="xdc", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdcrpc", chain="xdc", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdcrpc", chain="xdc", region="sgp"}[1h]) - - slug: xdc-erpc name: XDC eRPC tag: XDC Foundation alternate public RPC gateway @@ -156,26 +110,3 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-erpc", chain="xdc", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="xdc-erpc", chain="xdc", region="sgp"}[1h]) - - - slug: xdc-org - name: XDC.org - tag: XDC Foundation third public RPC mirror - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to XDC Foundation's rpc.xdc.org endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-org", chain="xdc"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="xdc-org", chain="xdc"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="xdc-org", chain="xdc"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="xdc-org", chain="xdc"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="xdc-org", chain="xdc"}) / sum(ocb:rpc_call:rate_24h{provider="xdc-org", chain="xdc"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="xdc-org", chain="xdc"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="xdc-org", chain="xdc"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-org", chain="xdc", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdc-org", chain="xdc", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-org", chain="xdc", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdc-org", chain="xdc", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-org", chain="xdc", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdc-org", chain="xdc", region="sgp"}[1h]) diff --git a/harnesses/aggregator-head-lag/Dockerfile b/harnesses/aggregator-head-lag/Dockerfile index 74ca8f0f..777b00d3 100644 --- a/harnesses/aggregator-head-lag/Dockerfile +++ b/harnesses/aggregator-head-lag/Dockerfile @@ -21,9 +21,12 @@ FROM debian:bookworm-slim WORKDIR /app -# Install runtime dependencies +# Install runtime dependencies + Chromium for in-process JWE scraping RUN apt-get update && apt-get install -y \ ca-certificates \ + chromium \ + chromium-sandbox \ + fonts-liberation \ && rm -rf /var/lib/apt/lists/* # Copy binary from builder diff --git a/harnesses/aggregator-head-lag/cmd/scrape-cookie/main.go b/harnesses/aggregator-head-lag/cmd/scrape-cookie/main.go index 686fa5d6..f64c3df5 100644 --- a/harnesses/aggregator-head-lag/cmd/scrape-cookie/main.go +++ b/harnesses/aggregator-head-lag/cmd/scrape-cookie/main.go @@ -1,12 +1,9 @@ package main import ( - "bytes" "context" "encoding/json" "fmt" - "io" - "net/http" "net/url" "os" "time" @@ -28,16 +25,66 @@ func main() { defer cancel() ctx, cancel := chromedp.NewContext(allocCtx) defer cancel() - ctx, cancel = context.WithTimeout(ctx, 45*time.Second) + ctx, cancel = context.WithTimeout(ctx, 60*time.Second) defer cancel() cookies := map[string]string{} + // Monkey-patch WebSocket to capture connection_init sent to graph.codex.io + // and the server's first response (ack or error/close). + captureScript := ` +window.__wsCaptures = []; +window.__wsResponses = []; +const _WS = window.WebSocket; +window.WebSocket = function(url, protocols) { + const ws = new _WS(url, protocols); + if (url && url.includes('graph.codex.io')) { + const _send = ws.send.bind(ws); + ws.send = function(data) { + try { + const parsed = JSON.parse(data); + if (parsed.type === 'connection_init') { + window.__wsCaptures.push({url: url, payload: parsed.payload, raw: data}); + console.log('WS_CAPTURE:' + JSON.stringify({url, payload: parsed.payload})); + } + } catch(e) {} + return _send(data); + }; + ws.addEventListener('message', function(evt) { + try { + const parsed = JSON.parse(evt.data); + window.__wsResponses.push({url: url, type: parsed.type, raw: evt.data.slice(0, 200)}); + console.log('WS_RESPONSE:' + JSON.stringify({url, type: parsed.type})); + } catch(e) {} + }); + ws.addEventListener('close', function(evt) { + window.__wsResponses.push({url: url, closeCode: evt.code, reason: evt.reason}); + console.log('WS_CLOSE:' + JSON.stringify({url, code: evt.code, reason: evt.reason})); + }); + } + return ws; +}; +window.WebSocket.prototype = _WS.prototype; +window.WebSocket.CONNECTING = _WS.CONNECTING; +window.WebSocket.OPEN = _WS.OPEN; +window.WebSocket.CLOSING = _WS.CLOSING; +window.WebSocket.CLOSED = _WS.CLOSED; +` + err := chromedp.Run(ctx, + // Inject monkey-patch before page loads + chromedp.ActionFunc(func(ctx context.Context) error { + return chromedp.Evaluate(`void 0`, nil).Do(ctx) // warm up + }), + chromedp.Navigate("about:blank"), + chromedp.Evaluate(captureScript, nil), chromedp.Navigate("https://www.defined.fi/"), chromedp.WaitVisible(`body`, chromedp.ByQuery), - chromedp.Sleep(8*time.Second), + // Re-inject on the loaded page (navigate clears scripts) + chromedp.Evaluate(captureScript, nil), + chromedp.Sleep(20*time.Second), chromedp.ActionFunc(func(ctx context.Context) error { + // Get cookies cookieParams, err := network.GetCookies().Do(ctx) if err != nil { return fmt.Errorf("failed to get cookies: %w", err) @@ -48,41 +95,49 @@ func main() { } return nil }), + // Capture what the browser sent in connection_init + chromedp.ActionFunc(func(ctx context.Context) error { + var captures []map[string]interface{} + err := chromedp.Evaluate(`window.__wsCaptures || []`, &captures).Do(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "capture eval error: %v\n", err) + return nil + } + if len(captures) == 0 { + fmt.Fprintf(os.Stderr, "\n[WS CAPTURE] No connection_init captured for graph.codex.io\n") + // Try to get captures via console approach + var raw string + chromedp.Evaluate(`JSON.stringify(window.__wsCaptures || [])`, &raw).Do(ctx) + fmt.Fprintf(os.Stderr, "[WS CAPTURE] raw: %s\n", raw) + } + for i, c := range captures { + fmt.Fprintf(os.Stderr, "\n[WS CAPTURE #%d] url=%v\n", i, c["url"]) + payloadBytes, _ := json.MarshalIndent(c["payload"], "", " ") + fmt.Fprintf(os.Stderr, "[WS CAPTURE #%d] connection_init payload:\n%s\n", i, string(payloadBytes)) + fmt.Printf("WS_INIT_PAYLOAD=%s\n", string(payloadBytes)) + } + // Capture server responses (ack / close) + var responses []map[string]interface{} + chromedp.Evaluate(`window.__wsResponses || []`, &responses).Do(ctx) + if len(responses) == 0 { + fmt.Fprintf(os.Stderr, "\n[WS RESPONSE] No server responses captured yet\n") + } + for i, r := range responses { + respBytes, _ := json.MarshalIndent(r, "", " ") + fmt.Fprintf(os.Stderr, "\n[WS RESPONSE #%d]:\n%s\n", i, string(respBytes)) + } + return nil + }), ) if err != nil { fmt.Fprintf(os.Stderr, "chrome error: %v\n", err) os.Exit(1) } - // Try codex_token first (it's a pre-minted token, might be usable directly) - // It's stored as URL-encoded JSON: {"token":""} - if raw, ok := cookies["codex_token"]; ok { - decoded, err := url.QueryUnescape(raw) - if err == nil { - var obj struct { - Token string `json:"token"` - } - if json.Unmarshal([]byte(decoded), &obj) == nil && obj.Token != "" { - fmt.Fprintf(os.Stderr, "\ncodex_token JWT (len=%d): %s...\n", len(obj.Token), obj.Token[:min(60, len(obj.Token))]) - // Test if we can use this token directly for a Codex query - testCodexToken(obj.Token) - } - } - } - - // Also test defined-attestation-token for JWT minting - if attToken, ok := cookies["defined-attestation-token"]; ok { - fmt.Fprintf(os.Stderr, "\nTesting defined-attestation-token for JWT mint...\n") - testJWTMint(attToken, "defined-attestation-token") - } - - // Output fresh defined-attestation-token + // Output cookies if attToken, ok := cookies["defined-attestation-token"]; ok { fmt.Printf("DEFINED_SESSION_COOKIE=%s\n", attToken) - fmt.Printf("COOKIE_NAME=defined-attestation-token\n") } - - // Also output full codex_token JSON decoded token if raw, ok := cookies["codex_token"]; ok { decoded, _ := url.QueryUnescape(raw) var obj struct { @@ -93,57 +148,3 @@ func main() { } } } - -func testJWTMint(cookieValue, cookieName string) { - reqBody := map[string]interface{}{ - "operationName": "CreateApiToken", - "query": "mutation CreateApiToken { createApiTokens(input: { count: 1 }) { token } }", - "variables": map[string]interface{}{}, - } - bodyBytes, _ := json.Marshal(reqBody) - req, _ := http.NewRequest("POST", "https://www.defined.fi/api", bytes.NewBuffer(bodyBytes)) - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "https://www.defined.fi") - req.Header.Set("Referer", "https://www.defined.fi/") - req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") - req.AddCookie(&http.Cookie{Name: cookieName, Value: cookieValue}) - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - fmt.Fprintf(os.Stderr, "mint request failed: %v\n", err) - return - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - fmt.Fprintf(os.Stderr, "mint status: %d, body: %s\n", resp.StatusCode, string(body[:min(200, len(body))])) -} - -func testCodexToken(token string) { - // Try using the token directly as a Bearer for Codex GraphQL - reqBody := map[string]interface{}{ - "query": "{ getNetworkStats(networkId: 1) { addressCount } }", - } - bodyBytes, _ := json.Marshal(reqBody) - req, _ := http.NewRequest("POST", "https://graph.codex.io/graphql", bytes.NewBuffer(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+token) - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - fmt.Fprintf(os.Stderr, "codex token test failed: %v\n", err) - return - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - fmt.Fprintf(os.Stderr, "codex test status: %d, body: %s\n", resp.StatusCode, string(body[:min(200, len(body))])) -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/harnesses/aggregator-head-lag/cmd/script/codex_scraper.go b/harnesses/aggregator-head-lag/cmd/script/codex_scraper.go new file mode 100644 index 00000000..6ec7cac7 --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/script/codex_scraper.go @@ -0,0 +1,141 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" +) + +// in-process JWE refresher: scrapes defined.fi every 5 min via headless Chrome. +// Result is stored here so GetDefinedJWTToken can use it without hitting the Paris box. +var inProcessJWE struct { + sync.RWMutex + token string + mintedAt time.Time +} + +func getInProcessJWE() (string, time.Time) { + inProcessJWE.RLock() + defer inProcessJWE.RUnlock() + return inProcessJWE.token, inProcessJWE.mintedAt +} + +func setInProcessJWE(token string) { + inProcessJWE.Lock() + defer inProcessJWE.Unlock() + inProcessJWE.token = token + inProcessJWE.mintedAt = time.Now() +} + +// scrapeCodexToken runs headless Chrome, navigates to defined.fi, and extracts the codex_token JWE. +func scrapeCodexToken() (string, error) { + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", true), + chromedp.Flag("disable-gpu", true), + chromedp.Flag("no-sandbox", true), + chromedp.Flag("disable-dev-shm-usage", true), + chromedp.UserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"), + ) + + // Allow overriding Chrome binary path via env (useful for Alpine/Debian containers). + if chromePath := os.Getenv("CHROME_PATH"); chromePath == "" { + // Try common locations + for _, p := range []string{"/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome"} { + if _, err := os.Stat(p); err == nil { + opts = append(opts, chromedp.ExecPath(p)) + break + } + } + } else { + opts = append(opts, chromedp.ExecPath(chromePath)) + } + + allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) + defer allocCancel() + + ctx, cancel := chromedp.NewContext(allocCtx) + defer cancel() + + ctx, cancel = context.WithTimeout(ctx, 90*time.Second) + defer cancel() + + var cookies []*network.Cookie + err := chromedp.Run(ctx, + chromedp.Navigate("https://www.defined.fi/"), + chromedp.WaitVisible(`body`, chromedp.ByQuery), + chromedp.Sleep(20*time.Second), + chromedp.ActionFunc(func(ctx context.Context) error { + var err error + cookies, err = network.GetCookies().Do(ctx) + return err + }), + ) + if err != nil { + return "", fmt.Errorf("chromedp run failed: %w", err) + } + + for _, c := range cookies { + if c.Name == "codex_token" { + decoded, err := url.QueryUnescape(c.Value) + if err != nil { + return "", fmt.Errorf("url unescape failed: %w", err) + } + var obj struct { + Token string `json:"token"` + } + if err := json.Unmarshal([]byte(decoded), &obj); err == nil && obj.Token != "" { + return obj.Token, nil + } + // Fallback: if the cookie value is the JWE directly + if strings.HasPrefix(decoded, "eyJ") { + return decoded, nil + } + } + } + return "", fmt.Errorf("codex_token cookie not found after page load") +} + +// startInProcessScraper launches a background goroutine that refreshes the JWE every 5 min. +// Call once from main. Safe to call even if Chrome is not installed (logs error, no crash). +func startInProcessScraper(stopChan <-chan struct{}) { + go func() { + // Initial delay: let the container fully start before launching Chrome. + select { + case <-stopChan: + return + case <-time.After(10 * time.Second): + } + + refreshInterval := 5 * time.Minute + + for { + fmt.Println("[CODEX-SCRAPER] Scraping defined.fi for fresh JWE...") + tok, err := scrapeCodexToken() + if err != nil { + fmt.Printf("[CODEX-SCRAPER] Scrape failed: %v — retrying in 60s\n", err) + select { + case <-stopChan: + return + case <-time.After(60 * time.Second): + continue + } + } + setInProcessJWE(tok) + fmt.Printf("[CODEX-SCRAPER] Fresh JWE stored (len=%d), next refresh in %v\n", len(tok), refreshInterval) + + select { + case <-stopChan: + return + case <-time.After(refreshInterval): + } + } + }() +} diff --git a/harnesses/aggregator-head-lag/cmd/script/defined_auth.go b/harnesses/aggregator-head-lag/cmd/script/defined_auth.go index 62728c56..987a1f13 100644 --- a/harnesses/aggregator-head-lag/cmd/script/defined_auth.go +++ b/harnesses/aggregator-head-lag/cmd/script/defined_auth.go @@ -130,17 +130,23 @@ func tryTokenService(baseURL string) (string, error) { } // GetDefinedJWTToken returns a cached JWT token or generates a new one if expired. -// Priority: CODEX_JWT env var > direct /api/codex/token (same IP as WS) > sidecar > inline mint. +// Priority: CODEX_JWT env var > in-process scraper (fresh, <5min) > direct /api/codex/token > sidecar > inline mint. func GetDefinedJWTToken(sessionCookie string) (string, error) { if jwt := os.Getenv("CODEX_JWT"); jwt != "" { return jwt, nil } + // In-process scraper: headless Chrome running inside this container, freshest possible. + // JWE expires in ~10 min; scraper refreshes every 5 min so token is always <5 min old. + if tok, mintedAt := getInProcessJWE(); tok != "" && time.Since(mintedAt) < 8*time.Minute { + fmt.Printf("[DEFINED-AUTH] Got token from in-process scraper (age=%v, len=%d)\n", time.Since(mintedAt).Round(time.Second), len(tok)) + return tok, nil + } // Direct mint: JWE minted from this container's IP = same IP used for WS = no 4403. if tok, err := tryDirectCodexToken(sessionCookie); err == nil && tok != "" { fmt.Printf("[DEFINED-AUTH] Got token via direct /api/codex/token (len=%d)\n", len(tok)) return tok, nil } - // Sidecar fallback (Paris box chromedp, auto-refreshes every 25 min) + // Sidecar fallback (Paris box chromedp, auto-refreshes every 25 min — may be stale) if svcURL := os.Getenv("DEFINED_TOKEN_SERVICE_URL"); svcURL != "" { if tok, err := tryTokenService(svcURL); err == nil && tok != "" { fmt.Printf("[DEFINED-AUTH] Got token from sidecar (len=%d)\n", len(tok)) diff --git a/harnesses/aggregator-head-lag/cmd/script/main.go b/harnesses/aggregator-head-lag/cmd/script/main.go index 84444379..a545d832 100644 --- a/harnesses/aggregator-head-lag/cmd/script/main.go +++ b/harnesses/aggregator-head-lag/cmd/script/main.go @@ -38,6 +38,11 @@ func main() { var wg sync.WaitGroup stopChan := make(chan struct{}) + // In-process JWE scraper: headless Chrome inside this container scrapes defined.fi + // every 5 min to keep the Codex token fresh. JWE expires in ~10 min, so 5 min gives + // a comfortable margin. Runs only if Chrome is installed (no-op otherwise). + startInProcessScraper(stopChan) + wg.Add(1) go func() { defer wg.Done() diff --git a/harnesses/solana-exec/cmd/collector/main.go b/harnesses/solana-exec/cmd/collector/main.go index 1f4b2338..909c7774 100644 --- a/harnesses/solana-exec/cmd/collector/main.go +++ b/harnesses/solana-exec/cmd/collector/main.go @@ -45,20 +45,28 @@ func collect(ctx context.Context, db *store.DB, h *helius.Client, plt, feeAccoun } const sigLimit = 100 - // Fetch signatures newer than last seen. Results are newest-first. - sigs, err := h.GetSignaturesForAddress(ctx, feeAccount, sigLimit, cursor.LastSig) - if err != nil { - return fmt.Errorf("get sigs: %w", err) + // Paginate through ALL signatures newer than cursor (newest-first per page). + // Each page uses `before=oldestSigInPreviousPage` to walk backwards until + // we exhaust the window. This guarantees complete coverage regardless of volume. + var sigs []helius.SigEntry + before := "" + for { + batch, err := h.GetSignaturesForAddress(ctx, feeAccount, sigLimit, cursor.LastSig, before) + if err != nil { + return fmt.Errorf("get sigs (before=%s): %w", before, err) + } + sigs = append(sigs, batch...) + if len(batch) < sigLimit { + break // last page + } + before = batch[len(batch)-1].Signature + time.Sleep(300 * time.Millisecond) // respect Helius free-tier rate limit between pages } if len(sigs) == 0 { return nil } - // On incremental polls (cursor set), hitting the cap means we're dropping older - // txs from this window — tx counts will be understated, though fee stats remain - // a representative sample of the most-recent transactions. - // The initial bootstrap (no cursor) always hits the cap; that's expected. - if len(sigs) == sigLimit && cursor.LastSig != "" { - log.Printf("collector: %s: WARNING hit sig limit (%d) — older txs in this poll window dropped; reduce POLL_INTERVAL or increase limit", plt, sigLimit) + if len(sigs) > sigLimit { + log.Printf("collector: %s: paginated %d raw sigs (%d pages)", plt, len(sigs), (len(sigs)+sigLimit-1)/sigLimit) } // Reverse to process oldest-first so cursor is always the true watermark. @@ -77,12 +85,31 @@ func collect(ctx context.Context, db *store.DB, h *helius.Client, plt, feeAccoun } } + // Count successful sigs per hour bucket from raw pagination (full volume, no enhanced API cost). + hourBuckets := make(map[time.Time]int64) + for _, s := range reversed { + if s.Err != nil || s.BlockTime == 0 { + continue + } + bucket := time.Unix(s.BlockTime, 0).UTC().Truncate(time.Hour) + hourBuckets[bucket]++ + } + if err := db.UpsertRawCounts(ctx, plt, hourBuckets); err != nil { + return fmt.Errorf("upsert raw counts: %w", err) + } + if len(sigStrs) == 0 { // All sigs in this batch were failed txs; advance cursor and skip. newest := sigs[0] return db.SaveCursor(ctx, plt, newest.Signature, newest.Slot) } + // Cap enhanced API at 100 sigs per poll (Helius free-tier budget: ~432K CUs/month). + // Fee quality metrics are sampled; tx_count comes from raw counts above. + if len(sigStrs) > 100 { + sigStrs = sigStrs[:100] + } + txs, err := h.GetEnhancedTransactions(ctx, sigStrs) if err != nil { return fmt.Errorf("get enhanced txs: %w", err) diff --git a/harnesses/solana-exec/internal/helius/client.go b/harnesses/solana-exec/internal/helius/client.go index b35fd148..45bc6c17 100644 --- a/harnesses/solana-exec/internal/helius/client.go +++ b/harnesses/solana-exec/internal/helius/client.go @@ -34,11 +34,11 @@ type SigEntry struct { Err any `json:"err"` // nil = success } -// GetSignaturesForAddress fetches up to `limit` finalized signatures -// for `address`. Pass `until=""` to get the most recent; pass a sig -// to get only transactions newer than that sig (exclusive). -// Results are returned newest-first. -func (c *Client) GetSignaturesForAddress(ctx context.Context, address string, limit int, until string) ([]SigEntry, error) { +// GetSignaturesForAddress fetches up to `limit` finalized signatures for +// `address`, newest-first. `until` is the exclusive upper bound (cursor); +// `before` is the exclusive lower bound used for pagination (pass "" for +// the first page). +func (c *Client) GetSignaturesForAddress(ctx context.Context, address string, limit int, until, before string) ([]SigEntry, error) { params := map[string]any{ "limit": limit, "commitment": "finalized", @@ -46,6 +46,9 @@ func (c *Client) GetSignaturesForAddress(ctx context.Context, address string, li if until != "" { params["until"] = until } + if before != "" { + params["before"] = before + } body, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", diff --git a/harnesses/solana-exec/internal/store/store.go b/harnesses/solana-exec/internal/store/store.go index 1ed3f826..1564a23d 100644 --- a/harnesses/solana-exec/internal/store/store.go +++ b/harnesses/solana-exec/internal/store/store.go @@ -97,7 +97,36 @@ func (db *DB) SaveCursor(ctx context.Context, platform, lastSig string, slot uin return err } -// Materialize recomputes hourly facts for the given platform and time range. +// UpsertRawCounts stores hourly successful-tx counts from raw sig pagination. +// counts maps bucket_start (hour-truncated UTC) → total successful sigs that hour. +func (db *DB) UpsertRawCounts(ctx context.Context, platform string, counts map[time.Time]int64) error { + if len(counts) == 0 { + return nil + } + tx, err := db.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("store: begin: %w", err) + } + defer tx.Rollback(ctx) + for bucket, count := range counts { + _, err := tx.Exec(ctx, ` + INSERT INTO solana_exec_raw_counts (platform, bucket_start, success_count, updated_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (platform, bucket_start) DO UPDATE SET + success_count = solana_exec_raw_counts.success_count + EXCLUDED.success_count, + updated_at = now()`, + platform, bucket, count, + ) + if err != nil { + return fmt.Errorf("store: upsert raw count %v: %w", bucket, err) + } + } + return tx.Commit(ctx) +} + +// Materialize recomputes hourly facts for the given platform. +// tx_count comes from solana_exec_raw_counts (full volume); fee metrics come +// from the sampled solana_exec_events (representative quality metrics). func (db *DB) Materialize(ctx context.Context, platform string) error { _, err := db.pool.Exec(ctx, ` INSERT INTO solana_exec_facts @@ -105,19 +134,22 @@ func (db *DB) Materialize(ctx context.Context, platform string) error { avg_priority_fee_lamports, p50_cu_price_micro, p95_cu_price_micro, avg_platform_fee_lamports, jito_rate, avg_cu_consumed, computed_at) SELECT - platform, - date_trunc('hour', block_time) AS bucket_start, - COUNT(*) AS tx_count, - AVG(priority_fee_lamports) AS avg_priority_fee_lamports, - COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY cu_price_micro) FILTER (WHERE cu_price_micro > 0), 0) AS p50_cu_price_micro, - COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY cu_price_micro) FILTER (WHERE cu_price_micro > 0), 0) AS p95_cu_price_micro, - AVG(platform_fee_lamports) AS avg_platform_fee_lamports, - AVG(CASE WHEN is_jito_bundle THEN 1.0 ELSE 0.0 END) AS jito_rate, - AVG(cu_consumed) AS avg_cu_consumed, + e.platform, + date_trunc('hour', e.block_time) AS bucket_start, + COALESCE(r.success_count, COUNT(*)) AS tx_count, + AVG(e.priority_fee_lamports) AS avg_priority_fee_lamports, + COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY e.cu_price_micro) FILTER (WHERE e.cu_price_micro > 0), 0) AS p50_cu_price_micro, + COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY e.cu_price_micro) FILTER (WHERE e.cu_price_micro > 0), 0) AS p95_cu_price_micro, + AVG(e.platform_fee_lamports) AS avg_platform_fee_lamports, + AVG(CASE WHEN e.is_jito_bundle THEN 1.0 ELSE 0.0 END) AS jito_rate, + AVG(e.cu_consumed) AS avg_cu_consumed, now() - FROM solana_exec_events - WHERE platform = $1 - GROUP BY platform, date_trunc('hour', block_time) + FROM solana_exec_events e + LEFT JOIN solana_exec_raw_counts r + ON r.platform = e.platform + AND r.bucket_start = date_trunc('hour', e.block_time) + WHERE e.platform = $1 + GROUP BY e.platform, date_trunc('hour', e.block_time), r.success_count ON CONFLICT (platform, bucket_start) DO UPDATE SET tx_count = EXCLUDED.tx_count, avg_priority_fee_lamports = EXCLUDED.avg_priority_fee_lamports, diff --git a/harnesses/solana-exec/migrations/002_raw_counts.sql b/harnesses/solana-exec/migrations/002_raw_counts.sql new file mode 100644 index 00000000..bf8f199a --- /dev/null +++ b/harnesses/solana-exec/migrations/002_raw_counts.sql @@ -0,0 +1,18 @@ +BEGIN; + +-- Hourly raw successful-tx counts derived from getSignaturesForAddress pagination. +-- Counted at zero extra Helius credit cost (no enhanced API needed). +-- Separate from solana_exec_events which only holds fee-quality sampled rows. +CREATE TABLE IF NOT EXISTS solana_exec_raw_counts ( + platform TEXT NOT NULL, + bucket_start TIMESTAMPTZ NOT NULL, + success_count BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (platform, bucket_start) +); + +-- Replace tx_count (INT) in facts with BIGINT to hold real volumes. +ALTER TABLE solana_exec_facts + ALTER COLUMN tx_count TYPE BIGINT; + +COMMIT; diff --git a/src/lib/materialize/load.ts b/src/lib/materialize/load.ts index f6271f05..420739a5 100644 --- a/src/lib/materialize/load.ts +++ b/src/lib/materialize/load.ts @@ -807,6 +807,11 @@ async function tryLoadLive( // common AWAITING trigger. Logged at warn level only on the // unfiltered "All" view to avoid spamming logs on filtered views // where a missing provider is expected behavior. + // For gauge-only benches (e.g. validator yield) p90/p99 are not + // defined in the spec. Fall back to p50 so the provider isn't skipped. + if (p90 == null && !q.p90) p90 = p50; + if (p99 == null && !q.p99) p99 = p50; + if (p50 == null || p90 == null || p99 == null) { // Unresponsive cohort member: the latency series is gone from // the window (failed probes record no latency, so a fully dead