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>> { + const now = Date.now(); const results = await Promise.allSettled( coins.map(async (coin) => { - const res = await fetch(HL_API, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ type: "fundingHistory", coin, startTime: startMs }), - signal: AbortSignal.timeout(8000), - }); - if (!res.ok) return [coin, []] as [string, Array<{ time: number; rate: number }>]; - const data = (await res.json()) as Array<{ time: number; fundingRate: string }>; - return [coin, data.map((d) => ({ time: d.time, rate: parseFloat(d.fundingRate) }))] as [ - string, - Array<{ time: number; rate: number }> - ]; + const rates: Array<{ time: number; rate: number }> = []; + let cursor = startMs; + for (let page = 0; page < 5; page++) { + const res = await fetch(HL_API, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "fundingHistory", coin, startTime: cursor }), + signal: AbortSignal.timeout(8000), + }); + if (!res.ok) break; + const data = (await res.json()) as Array<{ time: number; fundingRate: string }>; + if (!Array.isArray(data) || data.length === 0) break; + rates.push(...data.map((d) => ({ time: d.time, rate: parseFloat(d.fundingRate) }))); + // If the response is truncated (exactly 500), fetch the next page + if (data.length < 500) break; + cursor = data[data.length - 1].time + 1; + if (cursor >= now) break; + } + return [coin, rates] as [string, Array<{ time: number; rate: number }>]; }) ); @@ -1272,8 +1346,9 @@ function computeHlFunding( ); // Each HL funding entry = one 8h interval. Rate > 0 = longs pay; < 0 = shorts pay. for (const r of rates) { + // Signed: positive = wallet pays, negative = wallet receives funding const cost = pos.isLong ? r.rate : -r.rate; - total += pos.notionalUsd * Math.max(0, cost); + total += pos.notionalUsd * cost; } } return total; @@ -1297,7 +1372,9 @@ function estimateGainsBorrowFees( // Estimate Gains funding fees for a set of position slices. // Uses the current (last known) per-second funding rate as a proxy for the period. -// Rate is absolute (direction already irrelevant for cost estimation). +// SIGNED: a long pays when rate>0 and receives when rate<0 (and vice-versa for shorts). +// Keeping the sign is what makes the projection apple-to-apple with HL's realized +// funding, which also credits the wallet when it was on the paid-to side. function estimateGainsFundingFees( positions: PositionSlice[], fundingPerSecPerCoin: Record @@ -1307,9 +1384,9 @@ function estimateGainsFundingFees( const rate = fundingPerSecPerCoin[pos.coin]; if (!rate) continue; const durationSec = Math.max(0, (pos.closeMs - pos.openMs) / 1000); - // positive rate = longs pay; negative rate = shorts pay - const effectiveRate = pos.isLong ? Math.max(0, rate) : Math.max(0, -rate); - total += pos.notionalUsd * effectiveRate * durationSec; + // positive rate = longs pay shorts; a long's cost is +rate, a short's is -rate + const signedRate = pos.isLong ? rate : -rate; + total += pos.notionalUsd * signedRate * durationSec; } return total; } @@ -1324,9 +1401,10 @@ function estimateCarryFees( const durationSec = Math.max(0, (pos.closeMs - pos.openMs) / 1000); borrowFees += pos.notionalUsd * (rates.borrowPerSecPerCoin[pos.coin] ?? 0) * durationSec; const fundingRate = rates.fundingPerSecPerCoin[pos.coin] ?? 0; - // positive rate = longs pay; negative rate = shorts pay - const fundingCost = pos.isLong ? Math.max(0, fundingRate) : Math.max(0, -fundingRate); - fundingFees += pos.notionalUsd * fundingCost * durationSec; + // SIGNED: positive rate = longs pay shorts. A long's cost is +rate, a short's is -rate. + // Signed carry lets a wallet on the receiving side show a funding credit. + const signedRate = pos.isLong ? fundingRate : -fundingRate; + fundingFees += pos.notionalUsd * signedRate * durationSec; } return { borrowFees, fundingFees }; } @@ -1342,14 +1420,20 @@ function toChecksumAddress(address: string): string { return "0x" + result; } -function walletStats(slug: string, w: AnyWallet): { notional: number; fees: number } | null { +function walletStats(slug: string, w: AnyWallet, otherSlug?: string): { notional: number; fees: number } | null { if (slug === "hyperliquid") { const x = w as HlWalletData; return x.fills > 0 ? { notional: x.notionalUsd, fees: x.netCostUsd } : null; } if (slug === "gains") { const x = w as GainsWalletData; - return x.events > 0 ? { notional: x.positionSizeUsdc, fees: x.netCostUsdc } : null; + // When comparing against HL: exclude exclusive fees AND use comparable-only notional + // so the HL equiv fee isn't inflated by PONS/other non-HL notional + const exclusiveFees = otherSlug === "hyperliquid" ? (x.gainsExclusiveFeesUsdc ?? 0) : 0; + const notional = (otherSlug === "hyperliquid" && x.comparableNotionalUsdc !== undefined) + ? x.comparableNotionalUsdc + : x.positionSizeUsdc; + return x.events > 0 ? { notional, fees: x.netCostUsdc - exclusiveFees } : null; } if (slug === "gmx-v2") { const x = w as GmxWalletData; @@ -1362,6 +1446,91 @@ function walletStats(slug: string, w: AnyWallet): { notional: number; fees: numb return null; } +type GainsRateData = { + perSide: Record; + avgPerSide: number; + borrowPerSecPerCoin: Record; + avgBorrowPerSec: number; + fundingPerSecPerCoin: Record; +}; + +// Maker/taker-aware taker-equivalent for a set of HL fills projected onto an +// order-book venue: a fill that added liquidity on HL (crossed=false) is assumed +// to add liquidity on the target too, so it gets the maker rate. On AMM targets +// pass makerRate === takerRate and every fill is charged the same. +function hlMakerAwareEquiv(fills: HlFill[], takerRate: number, makerRate: number): number { + let sum = 0; + for (const f of fills) { + const notional = parseFloat(f.px) * parseFloat(f.sz); + sum += notional * (f.crossed ? takerRate : makerRate); + } + return sum; +} + +// Shared HL ↔ Gains projection. Restricts to coins Gains actually lists (apple to +// apple), keeps HL funding signed, and projects Gains carry from reconstructed HL +// positions. Returns null when the wallet has no HL fills on Gains-listed coins. +function computeHlGainsSim( + hlFills: HlFill[], + hlFundingEvents: HlFundingEvent[], + hlOpenPositions: HlOpenPos[], + cutoffMs: number, + gainsData: GainsRateData +): { sim: SimResult; hlNetBps: number; gainsEffBps: number } | null { + const inGains = (coin: string) => gainsData.perSide[coin] !== undefined; + const recent = hlFills.filter((f) => f.time >= cutoffMs && inGains(f.coin)); + if (recent.length === 0) return null; + + let takerEquiv = 0; + let notional = 0; + let hlFees = 0; + for (const fill of recent) { + const n = parseFloat(fill.px) * parseFloat(fill.sz); + // Gains is AMM-style: same fee regardless of maker/taker, so per-coin rate applies to all. + // `recent` is pre-filtered to Gains-listed coins, so perSide[coin] always exists here. + takerEquiv += n * gainsData.perSide[fill.coin]; + notional += n; + hlFees += parseFloat(fill.fee); + } + if (notional <= 0) return null; + + // HL realized funding restricted to Gains-comparable coins (delta may omit coin → keep it). + const hlFunding = hlFundingEvents + .filter((f) => f.time >= cutoffMs && (f.delta.coin === undefined || inGains(f.delta.coin))) + .reduce((s, f) => s + parseFloat(f.delta?.usdc ?? "0"), 0); + const hlNet = hlFees - hlFunding; + + // Project Gains carry from HL positions on comparable coins only. + const positions = augmentWithHlOpenPositions( + reconstructHlPositions(hlFills, cutoffMs), + hlOpenPositions, + cutoffMs + ).filter((p) => inGains(p.coin)); + const gainsBorrow = estimateGainsBorrowFees(positions, gainsData.borrowPerSecPerCoin, gainsData.avgBorrowPerSec); + const gainsFunding = estimateGainsFundingFees(positions, gainsData.fundingPerSecPerCoin); + const equiv = takerEquiv + gainsBorrow + gainsFunding; + + return { + sim: { + notionalUsed: notional, + feesActual: hlNet, + equivFees: equiv, + saved: equiv - hlNet, + multiple: hlNet > 0 ? equiv / hlNet : null, + fundingUsd: hlFunding, + projectedCarry: { + takerFees: takerEquiv, + borrowFees: gainsBorrow, + fundingFees: gainsFunding, + borrowProjected: gainsBorrow > 0.01, + fundingProjected: Math.abs(gainsFunding) > 0.01, + }, + }, + hlNetBps: (hlNet / notional) * 10000, + gainsEffBps: (equiv / notional) * 10000, + }; +} + // ────────────────────────────────────────────────────────────────────── // Route // ────────────────────────────────────────────────────────────────────── @@ -1403,8 +1572,8 @@ export async function GET(req: Request) { try { const [ - { rate: rateA, note: noteA, rateIsLive: rateIsLiveA }, - { rate: rateB, note: noteB, rateIsLive: rateIsLiveB }, + { rate: rateA, makerRate: makerRateA, note: noteA, rateIsLive: rateIsLiveA }, + { rate: rateB, makerRate: makerRateB, note: noteB, rateIsLive: rateIsLiveB }, gainsData, dydxCarryData, paradexCarryData, @@ -1429,6 +1598,7 @@ export async function GET(req: Request) { let hlFillsData: HlFill[] = []; let hlFundingData: HlFundingEvent[] = []; let hlOpenPositions: HlOpenPos[] = []; + let hlAvailableCoins = new Set(); let gainsTradesData: GainsApiTrade[] = []; let gmxWalletData: GmxWalletData | null = null; let dydxWalletData: DydxWalletData | null = null; @@ -1456,6 +1626,21 @@ export async function GET(req: Request) { fetches.push( fetchGainsTrades(wallet, cutoffMs).then((d) => { gainsTradesData = d; }).catch(() => {}) ); + if (venueA === "hyperliquid" || venueB === "hyperliquid") { + fetches.push( + fetch(HL_API, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "meta" }), + signal: AbortSignal.timeout(5000), + }) + .then((r) => r.json()) + .then((d: { universe: Array<{ name: string }> }) => { + hlAvailableCoins = new Set(d.universe.map((c) => c.name)); + }) + .catch(() => {}) + ); + } } if (venueA === "gmx-v2" || venueB === "gmx-v2") { fetches.push( @@ -1480,8 +1665,11 @@ export async function GET(req: Request) { await Promise.all(fetches); - // Phase 2: fetch HL funding history for Gains positions (Gains→HL carry projection) + // Phase 2: fetch HL funding history + extended Gains history for position reconstruction let hlFundingHistoryByCoins: Map> = new Map(); + // Extended Gains history (1 year) used only for HL funding projection reconstruction — + // the fee accounting (taker/borrow/funding fees) still uses gainsTradesData (cutoffMs window). + let gainsPositionData: GainsApiTrade[] = gainsTradesData; if ( fetchEvmWallet && (venueA === "gains" || venueB === "gains") && @@ -1491,10 +1679,18 @@ export async function GET(req: Request) { const gainsCoinSet = new Set( gainsTradesData .filter((t) => t.collateralIndex === 3) + // Only fetch funding history for coins that actually exist on HL + .filter((t) => hlAvailableCoins.size === 0 || hlAvailableCoins.has(t.pair.split("/")[0])) .map((t) => t.pair.split("/")[0]) ); - const coinsToFetch = [...gainsCoinSet].slice(0, 6); - hlFundingHistoryByCoins = await fetchHlFundingHistory(coinsToFetch, cutoffMs).catch(() => new Map()); + const coinsToFetch = [...gainsCoinSet].slice(0, 20); + const extendedCutoffMs = cutoffMs - 365 * 24 * 60 * 60 * 1000; + const [fundingHistory, extendedTrades] = await Promise.all([ + fetchHlFundingHistory(coinsToFetch, cutoffMs).catch(() => new Map>()), + fetchGainsTrades(wallet, extendedCutoffMs).catch(() => gainsTradesData), + ]); + hlFundingHistoryByCoins = fundingHistory; + gainsPositionData = extendedTrades; } function buildVenueResult(slug: string, rate: number, note: string, rateIsLive: boolean): VenueResult { @@ -1508,18 +1704,22 @@ export async function GET(req: Request) { 0 ); walletData = buildHlWalletData(recentFills, fundingTotal); - // Annotate each fill with the equivalent fee on the other venue + // Annotate each fill with the equivalent fee on the other venue. const otherSlug = slug === venueA ? venueB : venueA; const otherRate = slug === venueA ? rateB : rateA; + const otherMakerRate = slug === venueA ? makerRateB : makerRateA; const hlW = walletData as HlWalletData; - hlW.recentFills = hlW.recentFills.map((fill) => ({ - ...fill, - equivFee: otherSlug === "gains" - ? fill.notional * (gainsData.perSide[fill.coin] ?? gainsData.avgPerSide) - : fill.notional * otherRate, - })); + hlW.recentFills = hlW.recentFills.map((fill) => { + if (otherSlug === "gains") { + // Only comparable when Gains lists the coin; else leave undefined (n/a). + const coinRate = gainsData.perSide[fill.coin]; + return { ...fill, equivFee: coinRate !== undefined ? fill.notional * coinRate : undefined }; + } + // Order-book / AMM target: preserve execution style (maker fills → maker rate). + const targetRate = fill.isTaker ? otherRate : otherMakerRate; + return { ...fill, equivFee: fill.notional * targetRate }; + }); } else if (fetchEvmWallet && slug === "gains") { - const CLOSE_ACTIONS = new Set(["TradeClosedMarket", "TradeClosedTP", "TradeClosedSL", "TradeClosedLIQ"]); const usdcTrades = gainsTradesData.filter((t) => t.collateralIndex === 3); const otherSlug = slug === venueA ? venueB : venueA; const otherRate = slug === venueA ? rateB : rateA; @@ -1527,6 +1727,9 @@ export async function GET(req: Request) { let fundingFeesUsdc = 0; let borrowingFeesUsdc = 0; let notionalUsd = 0; + let comparableNotionalUsdc = 0; + let gainsExclusiveFeesUsdc = 0; + const checkHlComparable = otherSlug === "hyperliquid" && hlAvailableCoins.size > 0; const recentTrades: GainsWalletData["recentTrades"] = []; for (const t of usdcTrades) { @@ -1542,21 +1745,33 @@ export async function GET(req: Request) { borrowingFeesUsdc += borrowingFee; const tradeNotional = t.size * t.leverage; notionalUsd += tradeNotional; + const coin = t.pair.split("/")[0]; + const hlComparable = checkHlComparable ? hlAvailableCoins.has(coin) : undefined; + if (hlComparable === false) { + gainsExclusiveFeesUsdc += takerFee + fundingFee + borrowingFee; + } else { + comparableNotionalUsdc += tradeNotional; + } if (recentTrades.length < 50) { - const equivFee = otherSlug === "hyperliquid" - ? tradeNotional * (gainsData.perSide[t.pair.split("/")[0]] ?? otherRate) + // Don't show equivFee for Gains-exclusive coins — the coin doesn't exist on HL + // equivFee = what the other venue would charge for this same notional. + // HL has a uniform taker rate (no per-coin lookup); Gains has per-coin rates. + const equivFee = hlComparable === false + ? undefined : tradeNotional * otherRate; - recentTrades.push({ date: t.date, pair: t.pair, action: t.action, notional: tradeNotional, tradingFee: takerFee, fundingFee, borrowingFee, equivFee, pnl_net: t.pnl_net }); + recentTrades.push({ date: t.date, pair: t.pair, action: t.action, notional: tradeNotional, tradingFee: takerFee, fundingFee, borrowingFee, equivFee, hlComparable, pnl_net: t.pnl_net }); } } // When the API doesn't return per-trade funding (meta absent or zero), fall back to // the same per-second rate estimation used in the crossSim projection. + // The estimate is signed — a wallet on the receiving side gets a credit, matching + // how realized funding from the API would report it. let fundingEstimated = false; - if (fundingFeesUsdc >= 0 && fundingFeesUsdc < 0.01 && Object.keys(gainsData.fundingPerSecPerCoin).length > 0) { + if (Math.abs(fundingFeesUsdc) < 0.01 && Object.keys(gainsData.fundingPerSecPerCoin).length > 0) { const gainsPositions = reconstructGainsPositions(usdcTrades, cutoffMs); const est = estimateGainsFundingFees(gainsPositions, gainsData.fundingPerSecPerCoin); - if (est > 0.01) { + if (Math.abs(est) > 0.01) { fundingFeesUsdc = est; fundingEstimated = true; } @@ -1572,6 +1787,8 @@ export async function GET(req: Request) { netCostUsdc, positionSizeUsdc: notionalUsd, avgFeeRateBps: notionalUsd > 0 ? (netCostUsdc / notionalUsd) * 10000 : 0, + gainsExclusiveFeesUsdc: checkHlComparable ? gainsExclusiveFeesUsdc : undefined, + comparableNotionalUsdc: checkHlComparable ? comparableNotionalUsdc : undefined, recentTrades, } satisfies GainsWalletData; } else if (fetchEvmWallet && slug === "gmx-v2" && gmxWalletData) { @@ -1595,64 +1812,29 @@ export async function GET(req: Request) { const venueBResult = buildVenueResult(venueB, rateB, noteB, rateIsLiveB); const comparison: ComparisonResult = { aToBSim: null, bToASim: null }; + // An effective rate derived from the wallet's OWN fills on a venue must not be + // overwritten by a projection computed from the other venue's history. + let aActualRateSet = false; // aToBSim: venueA actual fills vs simulated venueB cost (with carry projection) if (venueAResult.wallet !== null) { if (venueA === "hyperliquid" && venueB === "gains") { - // Per-coin Gains taker rates on HL fills + estimated Gains borrow - const hlW = venueAResult.wallet as HlWalletData; - if (hlW.fills > 0) { - let takerEquiv = 0, aNotional = 0, aFees = 0; - for (const fill of hlFillsData.filter((f) => f.time >= cutoffMs)) { - const notional = parseFloat(fill.px) * parseFloat(fill.sz); - const fee = parseFloat(fill.fee); - const coinRate = gainsData.perSide[fill.coin] ?? gainsData.avgPerSide; - takerEquiv += notional * coinRate; - aNotional += notional; - aFees += fee; - } - const aFunding = hlW.fundingUsd; - const aNetCost = aFees - aFunding; - - // Estimate Gains carry (borrow + funding) by reconstructing HL positions. - // augmentWithHlOpenPositions fills in positions whose open fill is older than the - // 2000-fill API cap — they still generate real HL funding but are invisible to - // fill-only reconstruction. - const hlPositions = augmentWithHlOpenPositions( - reconstructHlPositions(hlFillsData, cutoffMs), - hlOpenPositions, - cutoffMs - ); - const gainsBorrow = estimateGainsBorrowFees(hlPositions, gainsData.borrowPerSecPerCoin, gainsData.avgBorrowPerSec); - const gainsFunding = estimateGainsFundingFees(hlPositions, gainsData.fundingPerSecPerCoin); - const bEquiv = takerEquiv + gainsBorrow + gainsFunding; - - comparison.aToBSim = { - notionalUsed: aNotional, - feesActual: aNetCost, - equivFees: bEquiv, - saved: bEquiv - aNetCost, - multiple: aNetCost > 0 ? bEquiv / aNetCost : null, - fundingUsd: aFunding, - projectedCarry: { - takerFees: takerEquiv, - borrowFees: gainsBorrow, - fundingFees: gainsFunding, - borrowProjected: gainsBorrow > 0.01, - fundingProjected: gainsFunding > 0.01, - }, - }; - if (aNotional > 0) { - venueAResult.effectiveRateBps = (aNetCost / aNotional) * 10000; - venueAResult.effectiveRateNote = `${((aNetCost / aNotional) * 10000).toFixed(2)} bps net (fees + funding)`; - venueBResult.effectiveRateBps = (bEquiv / aNotional) * 10000; - venueBResult.effectiveRateNote = `${((bEquiv / aNotional) * 10000).toFixed(2)} bps effective (your coins)`; - } + const r = computeHlGainsSim(hlFillsData, hlFundingData, hlOpenPositions, cutoffMs, gainsData); + if (r) { + comparison.aToBSim = r.sim; + venueAResult.effectiveRateBps = r.hlNetBps; + venueAResult.effectiveRateNote = `${r.hlNetBps.toFixed(2)} bps net (fees + funding)`; + aActualRateSet = true; + venueBResult.effectiveRateBps = r.gainsEffBps; + venueBResult.effectiveRateNote = `${r.gainsEffBps.toFixed(2)} bps effective (your coins)`; } } else { - const stats = walletStats(venueA, venueAResult.wallet); + const stats = walletStats(venueA, venueAResult.wallet, venueB); if (stats) { - let equivFees = stats.notional * rateB; + // HL source: preserve maker/taker style per fill; other sources are AMM (single fee). + let equivFees = venueA === "hyperliquid" + ? hlMakerAwareEquiv(hlFillsData.filter((f) => f.time >= cutoffMs), rateB, makerRateB) + : stats.notional * rateB; let projectedCarry: SimResult["projectedCarry"]; // Reconstruct positions from venueA for carry projection @@ -1663,9 +1845,12 @@ export async function GET(req: Request) { hlOpenPositions, cutoffMs ); - } else if (venueA === "gains" && gainsTradesData.length > 0) { + } else if (venueA === "gains" && gainsPositionData.length > 0) { positions = reconstructGainsPositions( - gainsTradesData.filter((t) => t.collateralIndex === 3), + gainsPositionData.filter( + (t) => t.collateralIndex === 3 && + (hlAvailableCoins.size === 0 || hlAvailableCoins.has(t.pair.split("/")[0])) + ), cutoffMs ); } else if (venueA === "gmx-v2" && gmxWalletData) { @@ -1683,17 +1868,17 @@ export async function GET(req: Request) { borrowFees: 0, fundingFees: hlFunding, borrowProjected: false, - fundingProjected: hlFunding > 0.01, + fundingProjected: Math.abs(hlFunding) > 0.01, }; } - // HL→GMX: use wallet's own GMX history as carry proxy - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + // HL→GMX: use wallet's own GMX history as carry proxy. + // Funding stays SIGNED: net funding received on GMX projects as a credit. const gmxForA = gmxWalletData as GmxWalletData | null; if (venueA === "hyperliquid" && venueB === "gmx-v2" && gmxForA !== null && gmxForA.notionalUsd > 0) { const takerFees = equivFees; const gmxBorrowRate = gmxForA.borrowingFeesUsdc / gmxForA.notionalUsd; - const gmxFundingRate = Math.max(0, gmxForA.fundingFeesUsdc) / gmxForA.notionalUsd; + const gmxFundingRate = gmxForA.fundingFeesUsdc / gmxForA.notionalUsd; const gmxBorrowProj = stats.notional * gmxBorrowRate; const gmxFundingProj = stats.notional * gmxFundingRate; equivFees += gmxBorrowProj + gmxFundingProj; @@ -1702,15 +1887,17 @@ export async function GET(req: Request) { borrowFees: gmxBorrowProj, fundingFees: gmxFundingProj, borrowProjected: gmxBorrowProj > 0.01, - fundingProjected: gmxFundingProj > 0.01, + fundingProjected: Math.abs(gmxFundingProj) > 0.01, }; } - // Generic carry: dYdX, Paradex, and any future venue with rate data + // Generic carry: dYdX, Paradex, and any future venue with rate data. + // Math.abs on funding: a pure credit (negative fundingFees) must still be + // projected — dropping it would bias the comparison toward the source venue. if (!projectedCarry && positions.length > 0) { const bCarry = getVenueCarryRates(venueB); const { borrowFees, fundingFees } = estimateCarryFees(positions, bCarry); - if (borrowFees > 0.001 || fundingFees > 0.001) { + if (borrowFees > 0.001 || Math.abs(fundingFees) > 0.001) { const takerFees = equivFees; equivFees += borrowFees + fundingFees; projectedCarry = { @@ -1718,7 +1905,7 @@ export async function GET(req: Request) { borrowFees, fundingFees, borrowProjected: borrowFees > 0.01, - fundingProjected: fundingFees > 0.01, + fundingProjected: Math.abs(fundingFees) > 0.01, }; } } @@ -1732,9 +1919,12 @@ export async function GET(req: Request) { projectedCarry, }; if (stats.notional > 0) { + const bBps = (equivFees / stats.notional) * 10000; venueAResult.effectiveRateBps = (stats.fees / stats.notional) * 10000; venueAResult.effectiveRateNote = `${((stats.fees / stats.notional) * 10000).toFixed(2)} bps actual (your fills)`; - venueBResult.effectiveRateBps = (equivFees / stats.notional) * 10000; + aActualRateSet = true; + venueBResult.effectiveRateBps = bBps; + venueBResult.effectiveRateNote = `${bBps.toFixed(2)} bps projected (your ${venueAResult.name} trades)`; } } } @@ -1743,56 +1933,24 @@ export async function GET(req: Request) { // bToASim: venueB actual fills vs simulated venueA cost (with carry projection) if (venueBResult.wallet !== null) { if (venueB === "hyperliquid" && venueA === "gains") { - // Per-coin Gains taker rates on HL fills + estimated Gains borrow - const hlW = venueBResult.wallet as HlWalletData; - if (hlW.fills > 0) { - let takerEquiv = 0, bNotional = 0, bFees = 0; - for (const fill of hlFillsData.filter((f) => f.time >= cutoffMs)) { - const notional = parseFloat(fill.px) * parseFloat(fill.sz); - const fee = parseFloat(fill.fee); - const coinRate = gainsData.perSide[fill.coin] ?? gainsData.avgPerSide; - takerEquiv += notional * coinRate; - bNotional += notional; - bFees += fee; - } - const bFunding = hlW.fundingUsd; - const bNetCost = bFees - bFunding; - - const hlPositions = augmentWithHlOpenPositions( - reconstructHlPositions(hlFillsData, cutoffMs), - hlOpenPositions, - cutoffMs - ); - const gainsBorrow = estimateGainsBorrowFees(hlPositions, gainsData.borrowPerSecPerCoin, gainsData.avgBorrowPerSec); - const gainsFunding = estimateGainsFundingFees(hlPositions, gainsData.fundingPerSecPerCoin); - const aEquiv = takerEquiv + gainsBorrow + gainsFunding; - - comparison.bToASim = { - notionalUsed: bNotional, - feesActual: bNetCost, - equivFees: aEquiv, - saved: aEquiv - bNetCost, - multiple: bNetCost > 0 ? aEquiv / bNetCost : null, - fundingUsd: bFunding, - projectedCarry: { - takerFees: takerEquiv, - borrowFees: gainsBorrow, - fundingFees: gainsFunding, - borrowProjected: gainsBorrow > 0.01, - fundingProjected: gainsFunding > 0.01, - }, - }; - if (bNotional > 0) { - venueBResult.effectiveRateBps = (bNetCost / bNotional) * 10000; - venueBResult.effectiveRateNote = `${((bNetCost / bNotional) * 10000).toFixed(2)} bps net (fees + funding)`; - venueAResult.effectiveRateBps = (aEquiv / bNotional) * 10000; - venueAResult.effectiveRateNote = `${((aEquiv / bNotional) * 10000).toFixed(2)} bps effective (your coins)`; + const r = computeHlGainsSim(hlFillsData, hlFundingData, hlOpenPositions, cutoffMs, gainsData); + if (r) { + comparison.bToASim = r.sim; + venueBResult.effectiveRateBps = r.hlNetBps; + venueBResult.effectiveRateNote = `${r.hlNetBps.toFixed(2)} bps net (fees + funding)`; + // Don't overwrite the Gains wallet's own-fills rate with the HL-derived projection. + if (!aActualRateSet) { + venueAResult.effectiveRateBps = r.gainsEffBps; + venueAResult.effectiveRateNote = `${r.gainsEffBps.toFixed(2)} bps effective (your coins)`; } } } else { - const stats = walletStats(venueB, venueBResult.wallet); + const stats = walletStats(venueB, venueBResult.wallet, venueA); if (stats) { - let equivFees = stats.notional * rateA; + // HL source: preserve maker/taker style per fill; other sources are AMM (single fee). + let equivFees = venueB === "hyperliquid" + ? hlMakerAwareEquiv(hlFillsData.filter((f) => f.time >= cutoffMs), rateA, makerRateA) + : stats.notional * rateA; let projectedCarry: SimResult["projectedCarry"]; // Reconstruct positions from venueB @@ -1803,9 +1961,12 @@ export async function GET(req: Request) { hlOpenPositions, cutoffMs ); - } else if (venueB === "gains" && gainsTradesData.length > 0) { + } else if (venueB === "gains" && gainsPositionData.length > 0) { positions = reconstructGainsPositions( - gainsTradesData.filter((t) => t.collateralIndex === 3), + gainsPositionData.filter( + (t) => t.collateralIndex === 3 && + (hlAvailableCoins.size === 0 || hlAvailableCoins.has(t.pair.split("/")[0])) + ), cutoffMs ); } else if (venueB === "gmx-v2" && gmxWalletData) { @@ -1823,17 +1984,17 @@ export async function GET(req: Request) { borrowFees: 0, fundingFees: hlFunding, borrowProjected: false, - fundingProjected: hlFunding > 0.01, + fundingProjected: Math.abs(hlFunding) > 0.01, }; } // HL→GMX (venueB=HL, venueA=GMX) - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + // Funding stays SIGNED: net funding received on GMX projects as a credit. const gmxForB = gmxWalletData as GmxWalletData | null; if (venueB === "hyperliquid" && venueA === "gmx-v2" && gmxForB !== null && gmxForB.notionalUsd > 0) { const takerFees = equivFees; const gmxBorrowRate = gmxForB.borrowingFeesUsdc / gmxForB.notionalUsd; - const gmxFundingRate = Math.max(0, gmxForB.fundingFeesUsdc) / gmxForB.notionalUsd; + const gmxFundingRate = gmxForB.fundingFeesUsdc / gmxForB.notionalUsd; const gmxBorrowProj = stats.notional * gmxBorrowRate; const gmxFundingProj = stats.notional * gmxFundingRate; equivFees += gmxBorrowProj + gmxFundingProj; @@ -1842,15 +2003,17 @@ export async function GET(req: Request) { borrowFees: gmxBorrowProj, fundingFees: gmxFundingProj, borrowProjected: gmxBorrowProj > 0.01, - fundingProjected: gmxFundingProj > 0.01, + fundingProjected: Math.abs(gmxFundingProj) > 0.01, }; } - // Generic carry projection + // Generic carry projection. + // Math.abs on funding: a pure credit (negative fundingFees) must still be + // projected — dropping it would bias the comparison toward the source venue. if (!projectedCarry && positions.length > 0) { const aCarry = getVenueCarryRates(venueA); const { borrowFees, fundingFees } = estimateCarryFees(positions, aCarry); - if (borrowFees > 0.001 || fundingFees > 0.001) { + if (borrowFees > 0.001 || Math.abs(fundingFees) > 0.001) { const takerFees = equivFees; equivFees += borrowFees + fundingFees; projectedCarry = { @@ -1858,7 +2021,7 @@ export async function GET(req: Request) { borrowFees, fundingFees, borrowProjected: borrowFees > 0.01, - fundingProjected: fundingFees > 0.01, + fundingProjected: Math.abs(fundingFees) > 0.01, }; } } @@ -1874,7 +2037,12 @@ export async function GET(req: Request) { if (stats.notional > 0) { venueBResult.effectiveRateBps = (stats.fees / stats.notional) * 10000; venueBResult.effectiveRateNote = `${((stats.fees / stats.notional) * 10000).toFixed(2)} bps actual (your fills)`; - venueAResult.effectiveRateBps = (equivFees / stats.notional) * 10000; + // Don't overwrite venueA's own-fills rate with a projection from venueB's history. + if (!aActualRateSet) { + const aBps = (equivFees / stats.notional) * 10000; + venueAResult.effectiveRateBps = aBps; + venueAResult.effectiveRateNote = `${aBps.toFixed(2)} bps projected (your ${venueBResult.name} trades)`; + } } } } diff --git a/src/app/api/og/[slug]/route.tsx b/src/app/api/og/[slug]/route.tsx index d24490715..3a8cae4ff 100644 --- a/src/app/api/og/[slug]/route.tsx +++ b/src/app/api/og/[slug]/route.tsx @@ -129,7 +129,18 @@ export async function GET( ), - size, + { + ...size, + // Every social/AI unfurl used to trigger a full satori render + // (max-age=0 → x-vercel-cache MISS on consecutive GETs). The + // underlying data moves every 60s but a 1h-old card is fine for + // an unfurl; s-maxage bounds renders to ~24/day/slug while SWR + // keeps scrapes instant. + headers: { + "cache-control": + "public, s-maxage=3600, stale-while-revalidate=86400", + }, + }, ); } diff --git a/src/app/api/speedtest/cell/route.ts b/src/app/api/speedtest/cell/route.ts new file mode 100644 index 000000000..385316dc5 --- /dev/null +++ b/src/app/api/speedtest/cell/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { unstable_cache } from "next/cache"; +import { redisPipeline, storeConfigured } from "@/lib/materialize/store"; +import { median, monthKeys, parseLatEntry } from "@/lib/speedtest/geo"; +import { RPC_DIRECTORY } from "@/lib/speedtest/rpc-directory"; + +export const runtime = "nodejs"; + +/** + * Detail view for one map cell: the full retained contribution history + * (last 24 readings per provider over the rolling two-month window), + * with timestamps, powering the click-through panel on /rpc-map. + */ + +const KNOWN_CHAINS = new Set(RPC_DIRECTORY.map((c) => c.slug)); +const GH_RE = /^[0-9b-hj-km-np-z]{4}$/; + +async function buildCell(chain: string, gh: string) { + const [cur, prev] = monthKeys(new Date()); + const [curPairs, prevPairs, metaFlat] = (await redisPipeline([ + ["SMEMBERS", `stm:idx:${cur}:${chain}`], + ["SMEMBERS", `stm:idx:${prev}:${chain}`], + ["HGETALL", `stm:meta:${gh}`], + ])) as [string[] | null, string[] | null, string[] | Record | null]; + + const slugs = Array.from( + new Set( + [...(curPairs ?? []), ...(prevPairs ?? [])] + .filter((p) => p.startsWith(`${gh}:`)) + .map((p) => p.slice(5)), + ), + ); + let meta: Record = {}; + if (Array.isArray(metaFlat)) { + for (let j = 0; j < metaFlat.length; j += 2) meta[metaFlat[j]] = metaFlat[j + 1]; + } else if (metaFlat && typeof metaFlat === "object") { + meta = metaFlat as Record; + } + if (slugs.length === 0) return { gh, city: meta.city ?? "Unknown", country: meta.country ?? "??", providers: [] }; + + const readCmds: (string | number)[][] = []; + for (const slug of slugs) { + readCmds.push(["LRANGE", `stm:lat:${cur}:${gh}:${chain}:${slug}`, 0, -1]); + readCmds.push(["LRANGE", `stm:lat:${prev}:${gh}:${chain}:${slug}`, 0, -1]); + } + const results = await redisPipeline(readCmds); + + const providers = slugs + .map((slug, i) => { + const entries = [ + ...((results[i * 2] as string[] | null) ?? []), + ...((results[i * 2 + 1] as string[] | null) ?? []), + ] + .map((raw) => parseLatEntry(String(raw))) + .filter((e): e is { ts: number | null; p50: number } => e !== null) + // LPUSH order is newest first; keep it that way for the panel. + .slice(0, 24); + if (entries.length === 0) return null; + const values = entries.map((e) => e.p50); + const tss = entries.map((e) => e.ts).filter((t): t is number => t !== null); + return { + slug, + p50: Math.round(median(values) * 10) / 10, + samples: entries.length, + lastTs: tss.length > 0 ? Math.max(...tss) : null, + history: entries.map((e) => ({ ts: e.ts, p50: Math.round(e.p50 * 10) / 10 })), + }; + }) + .filter((p): p is NonNullable => p !== null) + .sort((a, b) => a.p50 - b.p50); + + return { gh, city: meta.city ?? "Unknown", country: meta.country ?? "??", providers }; +} + +const cachedCell = unstable_cache( + async (chain: string, gh: string) => buildCell(chain, gh), + ["speedtest-cell-v1"], + { revalidate: 120 }, +); + +export async function GET(req: NextRequest) { + const chain = req.nextUrl.searchParams.get("chain") ?? ""; + const gh = req.nextUrl.searchParams.get("gh") ?? ""; + if (!KNOWN_CHAINS.has(chain) || !GH_RE.test(gh)) { + return NextResponse.json({ error: "bad_params" }, { status: 400 }); + } + if (!storeConfigured()) { + return NextResponse.json({ gh, providers: [] }); + } + try { + const data = await cachedCell(chain, gh); + return NextResponse.json(data, { + headers: { + "cache-control": "public, s-maxage=120, stale-while-revalidate=600", + "access-control-allow-origin": "*", + }, + }); + } catch { + return NextResponse.json({ gh, providers: [] }); + } +} diff --git a/src/app/api/speedtest/contribute/route.ts b/src/app/api/speedtest/contribute/route.ts new file mode 100644 index 000000000..af7a5517f --- /dev/null +++ b/src/app/api/speedtest/contribute/route.ts @@ -0,0 +1,140 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createHash } from "node:crypto"; +import { redisPipeline, storeConfigured } from "@/lib/materialize/store"; +import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; +import { geohashEncode, monthKeys } from "@/lib/speedtest/geo"; +import { RPC_DIRECTORY } from "@/lib/speedtest/rpc-directory"; + +export const runtime = "nodejs"; + +/** + * Anonymous crowdsourced contribution from the browser speed test. + * + * Privacy contract (mirrors the copy on /speedtest-rpc): + * - the client sends provider SLUGS only, never URLs and never keys; + * - geolocation comes from Vercel's IP headers server-side, rounded to + * a ~39 km geohash cell; the IP itself is never stored (a salted + * daily hash is used transiently for per-cell caps and expires in + * 24 h); + * - everything lands in monthly aggregation buckets with a 90-day TTL. + * + * Poisoning posture: per-IP rate limit, per-(cell,provider,source) + * daily cap, bounded reservoirs (last 24 readings per cell), medians at + * read time. Volume cannot buy map weight. + */ + +// Directory slugs + keyed-provider families the client may report. +const KEYED_FAMILIES = ["alchemy", "infura", "quicknode", "chainstack", "ankr", "helius"]; +const KNOWN_SLUGS = new Set(KEYED_FAMILIES); +const KNOWN_CHAINS = new Set(); +for (const c of RPC_DIRECTORY) { + KNOWN_CHAINS.add(c.slug); + for (const e of c.endpoints) KNOWN_SLUGS.add(e.slug); +} + +const MAX_ENTRIES = 8; +const RESERVOIR = 24; +const CELL_DAILY_CAP = 6; +const TTL_SEC = 90 * 24 * 3600; + +type Entry = { slug: string; p50: number; n: number }; + +function parseBody(raw: unknown): { chain: string; entries: Entry[] } | null { + if (typeof raw !== "object" || raw === null) return null; + const b = raw as { chain?: unknown; entries?: unknown }; + if (typeof b.chain !== "string" || !KNOWN_CHAINS.has(b.chain)) return null; + if (!Array.isArray(b.entries) || b.entries.length === 0) return null; + const entries: Entry[] = []; + for (const e of b.entries.slice(0, MAX_ENTRIES)) { + if (typeof e !== "object" || e === null) continue; + const { slug, p50, n } = e as { slug?: unknown; p50?: unknown; n?: unknown }; + if (typeof slug !== "string" || !KNOWN_SLUGS.has(slug)) continue; + if (typeof p50 !== "number" || !Number.isFinite(p50)) continue; + if (typeof n !== "number" || !Number.isFinite(n)) continue; + // Sanity clamps: sub-millisecond readings are below browser fetch + // overhead (fabricated), >10 s is not a usable latency sample. + if (p50 < 1 || p50 > 10_000) continue; + if (n < 3 || n > 500) continue; + entries.push({ slug, p50: Math.round(p50 * 10) / 10, n: Math.round(n) }); + } + return entries.length > 0 ? { chain: b.chain, entries } : null; +} + +export async function POST(req: NextRequest) { + if (!storeConfigured()) { + return NextResponse.json({ ok: false, reason: "store_off" }, { status: 503 }); + } + const rl = rateLimit(clientKey(req, "st-contribute"), 6, 60, req); + if (!rl.ok) return tooManyRequests(rl.retryAfterSec); + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ ok: false, reason: "bad_json" }, { status: 400 }); + } + const parsed = parseBody(body); + if (!parsed) { + return NextResponse.json({ ok: false, reason: "bad_payload" }, { status: 400 }); + } + + // Server-side IP geolocation from Vercel's edge headers. City-level + // accuracy, which matches the precision-4 cell size. No geo, no map + // point (dev fallback keeps local testing possible). + const h = req.headers; + let lat = parseFloat(h.get("x-vercel-ip-latitude") ?? ""); + let lon = parseFloat(h.get("x-vercel-ip-longitude") ?? ""); + let city = decodeURIComponent(h.get("x-vercel-ip-city") ?? ""); + let country = h.get("x-vercel-ip-country") ?? ""; + if ((Number.isNaN(lat) || Number.isNaN(lon)) && process.env.NODE_ENV !== "production") { + lat = 48.86; + lon = 2.35; + city = "Paris"; + country = "FR"; + } + if (Number.isNaN(lat) || Number.isNaN(lon)) { + return NextResponse.json({ ok: false, reason: "no_geo" }, { status: 202 }); + } + const gh = geohashEncode(lat, lon, 4); + const [ym] = monthKeys(new Date()); + const day = new Date().toISOString().slice(0, 10); + + // Transient per-source key: sha256(ip + day), truncated. Rotates + // daily, expires in 24 h, cannot be joined across days. + const ip = clientKey(req, "").split("|")[0] ?? "anon"; + const src = createHash("sha256").update(`${ip}:${day}`).digest("hex").slice(0, 12); + + // Daily cap check per (cell, source) before writing anything. + const capKey = `stm:cap:${day}:${gh}:${src}`; + const [capCount] = (await redisPipeline([ + ["INCR", capKey], + ["EXPIRE", capKey, 86_400], + ])) as [number, unknown]; + if (capCount > CELL_DAILY_CAP) { + return NextResponse.json({ ok: true, capped: true }); + } + + const cmds: (string | number)[][] = []; + // Cell metadata (first writer wins; coordinates rounded to ~1 km). + cmds.push(["HSETNX", `stm:meta:${gh}`, "city", city || "Unknown"]); + cmds.push(["HSETNX", `stm:meta:${gh}`, "country", country || "??"]); + cmds.push(["HSETNX", `stm:meta:${gh}`, "lat", Math.round(lat * 100) / 100]); + cmds.push(["HSETNX", `stm:meta:${gh}`, "lon", Math.round(lon * 100) / 100]); + cmds.push(["EXPIRE", `stm:meta:${gh}`, TTL_SEC]); + for (const e of parsed.entries) { + const pair = `${gh}:${e.slug}`; + const latKey = `stm:lat:${ym}:${gh}:${parsed.chain}:${e.slug}`; + cmds.push(["SADD", `stm:idx:${ym}:${parsed.chain}`, pair]); + // "unixSeconds:p50" so the map can show when each area was last + // measured and render per-provider contribution history. The map + // reader also accepts bare legacy numbers. + cmds.push(["LPUSH", latKey, `${Math.floor(Date.now() / 1000)}:${e.p50}`]); + cmds.push(["LTRIM", latKey, 0, RESERVOIR - 1]); + cmds.push(["EXPIRE", latKey, TTL_SEC]); + } + cmds.push(["EXPIRE", `stm:idx:${ym}:${parsed.chain}`, TTL_SEC]); + cmds.push(["INCR", "stm:total"]); + await redisPipeline(cmds); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/speedtest/map/route.ts b/src/app/api/speedtest/map/route.ts new file mode 100644 index 000000000..3d15edd96 --- /dev/null +++ b/src/app/api/speedtest/map/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from "next/server"; +import { unstable_cache } from "next/cache"; +import { redisPipeline, storeConfigured } from "@/lib/materialize/store"; +import { geohashCenter, median, monthKeys, parseLatEntry } from "@/lib/speedtest/geo"; +import { RPC_DIRECTORY } from "@/lib/speedtest/rpc-directory"; + +export const runtime = "nodejs"; + +/** + * Aggregated read side of the crowdsourced latency map. Returns, per + * geohash-4 cell, the median contributed p50 for every provider seen + * there, plus the winner. Everything is CC-BY-4.0 like the rest of the + * public data. + */ + +const KNOWN_CHAINS = new Set(RPC_DIRECTORY.map((c) => c.slug)); + +type CellOut = { + gh: string; + lat: number; + lon: number; + city: string; + country: string; + providers: { slug: string; p50: number; samples: number; lastTs: number | null }[]; + best: string; +}; + +async function buildMap(chain: string): Promise<{ cells: CellOut[]; total: number }> { + const [cur, prev] = monthKeys(new Date()); + const [curPairs, prevPairs, totalRaw] = (await redisPipeline([ + ["SMEMBERS", `stm:idx:${cur}:${chain}`], + ["SMEMBERS", `stm:idx:${prev}:${chain}`], + ["GET", "stm:total"], + ])) as [string[] | null, string[] | null, string | null]; + const pairs = Array.from(new Set([...(curPairs ?? []), ...(prevPairs ?? [])])); + if (pairs.length === 0) return { cells: [], total: Number(totalRaw ?? 0) }; + + // Cap the read fan-out defensively; ~500 (cell, provider) pairs is far + // beyond current reality and still one pipeline round trip. + const capped = pairs.slice(0, 500); + const readCmds: (string | number)[][] = []; + for (const pair of capped) { + const [gh, slug] = [pair.slice(0, 4), pair.slice(5)]; + readCmds.push(["LRANGE", `stm:lat:${cur}:${gh}:${chain}:${slug}`, 0, -1]); + readCmds.push(["LRANGE", `stm:lat:${prev}:${gh}:${chain}:${slug}`, 0, -1]); + } + const ghs = Array.from(new Set(capped.map((p) => p.slice(0, 4)))); + for (const gh of ghs) readCmds.push(["HGETALL", `stm:meta:${gh}`]); + const results = await redisPipeline(readCmds); + + const metaByGh = new Map>(); + for (let i = 0; i < ghs.length; i++) { + const flat = results[capped.length * 2 + i] as string[] | Record | null; + // REST returns HGETALL as a flat array; TCP client may return a map. + let obj: Record = {}; + if (Array.isArray(flat)) { + for (let j = 0; j < flat.length; j += 2) obj[flat[j]] = flat[j + 1]; + } else if (flat && typeof flat === "object") { + obj = flat as Record; + } + metaByGh.set(ghs[i], obj); + } + + const byCell = new Map(); + for (let i = 0; i < capped.length; i++) { + const pair = capped[i]; + const gh = pair.slice(0, 4); + const slug = pair.slice(5); + const curList = (results[i * 2] as string[] | null) ?? []; + const prevList = (results[i * 2 + 1] as string[] | null) ?? []; + const entries = [...curList, ...prevList] + .map((raw) => parseLatEntry(String(raw))) + .filter((e): e is { ts: number | null; p50: number } => e !== null); + const values = entries.map((e) => e.p50); + if (values.length === 0) continue; + let cell = byCell.get(gh); + if (!cell) { + const meta = metaByGh.get(gh) ?? {}; + const center = geohashCenter(gh); + cell = { + gh, + lat: Number(meta.lat ?? center.lat), + lon: Number(meta.lon ?? center.lon), + city: meta.city ?? "Unknown", + country: meta.country ?? "??", + providers: [], + best: "", + }; + byCell.set(gh, cell); + } + const tss = entries.map((e) => e.ts).filter((t): t is number => t !== null); + cell.providers.push({ + slug, + p50: Math.round(median(values) * 10) / 10, + samples: values.length, + lastTs: tss.length > 0 ? Math.max(...tss) : null, + }); + } + const cells = Array.from(byCell.values()); + for (const c of cells) { + c.providers.sort((a, b) => a.p50 - b.p50); + c.best = c.providers[0]?.slug ?? ""; + } + cells.sort((a, b) => b.providers.reduce((s, p) => s + p.samples, 0) - a.providers.reduce((s, p) => s + p.samples, 0)); + return { cells, total: Number(totalRaw ?? 0) }; +} + +const cachedMap = unstable_cache( + async (chain: string) => buildMap(chain), + ["speedtest-map-v1"], + { revalidate: 300 }, +); + +export async function GET(req: NextRequest) { + const chain = req.nextUrl.searchParams.get("chain") ?? "ethereum"; + if (!KNOWN_CHAINS.has(chain)) { + return NextResponse.json({ error: "unknown_chain" }, { status: 400 }); + } + if (!storeConfigured()) { + return NextResponse.json({ cells: [], total: 0, license: "CC-BY-4.0" }); + } + try { + const data = await cachedMap(chain); + return NextResponse.json( + { chain, ...data, license: "CC-BY-4.0" }, + { + headers: { + "cache-control": "public, s-maxage=300, stale-while-revalidate=3600", + "access-control-allow-origin": "*", + }, + }, + ); + } catch { + return NextResponse.json({ cells: [], total: 0, license: "CC-BY-4.0" }); + } +} diff --git a/src/app/api/speedtest/whereami/route.ts b/src/app/api/speedtest/whereami/route.ts new file mode 100644 index 000000000..cc062d7ee --- /dev/null +++ b/src/app/api/speedtest/whereami/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +/** + * IP-level position of the requester, from Vercel's edge geo headers. + * Powers the "Near me" button on /rpc-map without the browser + * geolocation permission prompt. Nothing is stored; the response is + * private and uncacheable by design. + */ +export async function GET(req: NextRequest) { + const h = req.headers; + let lat = parseFloat(h.get("x-vercel-ip-latitude") ?? ""); + let lon = parseFloat(h.get("x-vercel-ip-longitude") ?? ""); + let city = decodeURIComponent(h.get("x-vercel-ip-city") ?? ""); + let country = h.get("x-vercel-ip-country") ?? ""; + if ((Number.isNaN(lat) || Number.isNaN(lon)) && process.env.NODE_ENV !== "production") { + lat = 48.86; + lon = 2.35; + city = "Paris"; + country = "FR"; + } + if (Number.isNaN(lat) || Number.isNaN(lon)) { + return NextResponse.json({ ok: false }, { status: 202 }); + } + return NextResponse.json( + { ok: true, lat, lon, city, country }, + { headers: { "cache-control": "private, no-store" } }, + ); +} diff --git a/src/app/badges/opengraph-image.tsx b/src/app/badges/opengraph-image.tsx new file mode 100644 index 000000000..eaa9f78c5 --- /dev/null +++ b/src/app/badges/opengraph-image.tsx @@ -0,0 +1,15 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +export const alt = "OpenChainBench live ranking badges. Embed a live benchmark rank badge in your docs or README."; +export const size = OG_SIZE; +export const contentType = "image/png"; + +export default function OG() { + return renderHubOG({ + kicker: "Badges", + headline: "Show your live rank.", + subline: + "Embed a live OpenChainBench ranking badge in your docs, README or website. Updates automatically as benchmark data changes.", + }); +} diff --git a/src/app/benchmarks/[slug]/[chain]/opengraph-image.tsx b/src/app/benchmarks/[slug]/[chain]/opengraph-image.tsx new file mode 100644 index 000000000..f28275fdb --- /dev/null +++ b/src/app/benchmarks/[slug]/[chain]/opengraph-image.tsx @@ -0,0 +1,127 @@ +import { ImageResponse } from "next/og"; +import { getBenchmark } from "@/data/benchmarks"; +import { headlineSentence, leader } from "@/lib/citation"; +import { fmtUnit } from "@/lib/format"; +import { CATEGORY_COLOR } from "@/lib/category-colors"; +import { loadBenchmark } from "@/lib/spec"; +import { matchesChainSlug } from "@/lib/chain-aliases"; + +export const runtime = "nodejs"; +export const alt = "OpenChainBench. Open benchmarks for crypto infrastructure"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +export default async function OG({ + params, +}: { + params: Promise<{ slug: string; chain: string }>; +}) { + const { slug, chain } = await params; + const b = + (await loadBenchmark(slug, { chain })) ?? (await getBenchmark(slug)); + if (!b) return new ImageResponse(
, { ...size }); + + const chainEntry = b.dimensions?.chain?.find((c) => + matchesChainSlug(c.value, chain), + ); + const chainLabel = chainEntry?.label ?? chain; + const top = leader(b); + const headline = top + ? `${top.name} leads at ${fmtUnit(top.value, b.unit)}` + : "Awaiting first run"; + const sentence = headlineSentence(b); + const catColor = CATEGORY_COLOR[b.category] ?? "#7a2e1f"; + const titleText = `${b.title} on ${chainLabel}`; + + return new ImageResponse( + ( +
+
+ OpenChainBench · Bench {b.number} + {b.category} +
+ +
+
+ {b.metric} · {chainLabel} +
+
38 ? 72 : 88, + fontWeight: 700, + lineHeight: 0.98, + letterSpacing: -2, + maxWidth: 1080, + }} + > + {titleText} +
+
+ {top ? sentence : b.subtitle} +
+
+ +
+ openchainbench.com/benchmarks/{b.slug}/{chain} + {headline} +
+
+ ), + { ...size }, + ); +} diff --git a/src/app/benchmarks/[slug]/opengraph-image.tsx b/src/app/benchmarks/[slug]/opengraph-image.tsx index 4b50596e4..c46b72f75 100644 --- a/src/app/benchmarks/[slug]/opengraph-image.tsx +++ b/src/app/benchmarks/[slug]/opengraph-image.tsx @@ -153,6 +153,6 @@ export default async function OG({
), - { ...size } + { ...size, headers: { "cache-control": "public, s-maxage=3600, stale-while-revalidate=86400" } } ); } diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index dd95803d8..1c6037470 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -586,7 +586,8 @@ export default async function BenchmarkPage({ )} {(benchmark.slug.endsWith("-rpc") || - benchmark.slug === "rpc-capabilities") && ( + benchmark.slug === "rpc-capabilities" || + benchmark.slug.startsWith("keyed-rpc-")) && (
), - { ...size } + { ...size, headers: { "cache-control": "public, s-maxage=3600, stale-while-revalidate=86400" } } ); } diff --git a/src/app/benchmarks/category/[cat]/opengraph-image.tsx b/src/app/benchmarks/category/[cat]/opengraph-image.tsx new file mode 100644 index 000000000..6376e2cfa --- /dev/null +++ b/src/app/benchmarks/category/[cat]/opengraph-image.tsx @@ -0,0 +1,19 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; +import { CATEGORY_BY_SLUG } from "@/lib/categories"; + +export const runtime = "nodejs"; +export const size = OG_SIZE; +export const contentType = "image/png"; + +export default async function OG({ params }: { params: Promise<{ cat: string }> }) { + const { cat } = await params; + const entry = CATEGORY_BY_SLUG.get(cat); + const label = entry?.heading ?? cat; + const description = entry?.description ?? "Live benchmarks across crypto infrastructure providers."; + + return renderHubOG({ + kicker: `${label} benchmarks`, + headline: `All ${label} benchmarks.`, + subline: description, + }); +} diff --git a/src/app/bridge/opengraph-image.tsx b/src/app/bridge/opengraph-image.tsx new file mode 100644 index 000000000..48d466bef --- /dev/null +++ b/src/app/bridge/opengraph-image.tsx @@ -0,0 +1,15 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +export const alt = "Cheapest cross-chain bridge 2026. Live fee and slippage ranking across Across, deBridge, LI.FI, Relay and more."; +export const size = OG_SIZE; +export const contentType = "image/png"; + +export default function OG() { + return renderHubOG({ + kicker: "Bridge benchmarks", + headline: "Cheapest cross-chain bridge, live.", + subline: + "All-in fee (fees + slippage + destination gas) for $300 USDC across Solana, Base and Arbitrum corridors. Refreshed every 5 minutes.", + }); +} diff --git a/src/app/chains/opengraph-image.tsx b/src/app/chains/opengraph-image.tsx new file mode 100644 index 000000000..7a4251e0e --- /dev/null +++ b/src/app/chains/opengraph-image.tsx @@ -0,0 +1,15 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +export const alt = "Chains tracked by OpenChainBench. Browse all live benchmarks grouped by blockchain."; +export const size = OG_SIZE; +export const contentType = "image/png"; + +export default function OG() { + return renderHubOG({ + kicker: "Chains", + headline: "Every chain we measure.", + subline: + "All blockchains tracked by OpenChainBench. Pick a chain for the full set of live RPC, finality and data measurements.", + }); +} diff --git a/src/app/compare/opengraph-image.tsx b/src/app/compare/opengraph-image.tsx new file mode 100644 index 000000000..bc6a36158 --- /dev/null +++ b/src/app/compare/opengraph-image.tsx @@ -0,0 +1,15 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +export const alt = "Compare crypto infrastructure providers head to head. Live benchmark data, no vendor claims."; +export const size = OG_SIZE; +export const contentType = "image/png"; + +export default function OG() { + return renderHubOG({ + kicker: "Compare", + headline: "Providers, head to head.", + subline: + "Pick any two infrastructure providers and compare them on latency, reliability and cost. Live data, no vendor claims.", + }); +} diff --git a/src/app/data-api/opengraph-image.tsx b/src/app/data-api/opengraph-image.tsx new file mode 100644 index 000000000..502014c0c --- /dev/null +++ b/src/app/data-api/opengraph-image.tsx @@ -0,0 +1,15 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +export const alt = "Best crypto data API 2026. Live benchmark ranking of price, NFT and DeFi data providers by latency and accuracy."; +export const size = OG_SIZE; +export const contentType = "image/png"; + +export default function OG() { + return renderHubOG({ + kicker: "Data API benchmarks", + headline: "Best crypto data API, ranked.", + subline: + "Latency, accuracy and reliability for every major crypto data API provider. Measured continuously from three regions.", + }); +} diff --git a/src/app/fee-compare/[venueA]/[venueB]/[wallet]/opengraph-image.tsx b/src/app/fee-compare/[venueA]/[venueB]/[wallet]/opengraph-image.tsx new file mode 100644 index 000000000..342de00bc --- /dev/null +++ b/src/app/fee-compare/[venueA]/[venueB]/[wallet]/opengraph-image.tsx @@ -0,0 +1,27 @@ +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"; + +function venueName(slug: string): string { + const cohortSlug = slug === "gmx" ? "gmx-v2" : slug; + return PERP_VENUES.find((v) => v.slug === cohortSlug)?.name ?? slug; +} + +export default async function OG({ + params, +}: { + params: Promise<{ venueA: string; venueB: string; wallet: string }>; +}) { + const { venueA, venueB } = await params; + const nameA = venueName(venueA); + const nameB = venueName(venueB); + + return renderHubOG({ + kicker: "Fee compare", + headline: `${nameA} vs ${nameB}.`, + subline: `Real taker fees paid on ${nameA} vs what they would have cost on ${nameB}. Live on-chain wallet data, no API key.`, + }); +} diff --git a/src/app/fee-compare/opengraph-image.tsx b/src/app/fee-compare/opengraph-image.tsx new file mode 100644 index 000000000..f5e261b88 --- /dev/null +++ b/src/app/fee-compare/opengraph-image.tsx @@ -0,0 +1,15 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +export const alt = "Perp DEX fee comparison. Compare taker fees between any two venues using real on-chain wallet data."; +export const size = OG_SIZE; +export const contentType = "image/png"; + +export default function OG() { + return renderHubOG({ + kicker: "Fee compare", + headline: "Perp DEX fees, head to head.", + subline: + "Paste a wallet and compare what you paid on Hyperliquid or Gains against any other venue. Live on-chain data, no API key.", + }); +} diff --git a/src/app/fee-compare/page.tsx b/src/app/fee-compare/page.tsx index 3cc0f946f..7baf32889 100644 --- a/src/app/fee-compare/page.tsx +++ b/src/app/fee-compare/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { pageMetadata } from "@/lib/page-metadata"; import { FeeCompareClient } from "@/components/fee-compare-client"; +import { buildBreadcrumbJsonLd, safeJsonLd } from "@/lib/jsonld"; +import { SITE } from "@/data/site"; export const metadata: Metadata = pageMetadata({ path: "/fee-compare", @@ -26,8 +28,38 @@ export default async function FeeComparePage({ const rawDays = parseInt(params.days ?? "90", 10); const initialDays = isFinite(rawDays) ? Math.min(180, Math.max(7, rawDays)) : 90; + // Only page on the site without JSON-LD until 2026-09: emit the same + // BreadcrumbList shape every hub page ships, plus a WebApplication + // node describing the comparison tool itself. + const jsonLd = { + "@context": "https://schema.org", + "@graph": [ + buildBreadcrumbJsonLd([ + { name: "Home", item: SITE.url }, + { name: "Perp DEX fee comparison", item: `${SITE.url}/fee-compare` }, + ]), + { + "@type": "WebApplication", + "@id": `${SITE.url}/fee-compare#app`, + name: "Perp DEX fee comparison", + url: `${SITE.url}/fee-compare`, + applicationCategory: "FinanceApplication", + operatingSystem: "Web", + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + description: + "Compare taker fees between any two perp DEXs. Paste a wallet to see what was actually paid on Hyperliquid or Gains and what the same trades would have cost elsewhere.", + publisher: { "@id": `${SITE.url}/#org` }, + }, + ], + }; + return (
+