diff --git a/.env.example b/.env.example index f27cce1f5..804459876 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,9 @@ # Optional. Live-page WebSocket relay. Public (NEXT_PUBLIC_) so it's baked # into the browser bundle. Defaults to the production relay if unset. # NEXT_PUBLIC_RELAY_WS_URL=wss://ocb-stream-relay-production.up.railway.app/ws + +# Optional. Minimum seconds between two full ISR tag purges triggered by +# the materialize worker's /api/internal/revalidate-aggregate hook. The +# worker calls the hook after every 60 s publish; without this throttle +# every ISR page was invalidated each minute. Default 600. "0" disables. +# REVALIDATE_MIN_INTERVAL_SEC=600 diff --git a/benchmarks/aggregator-head-lag.yml b/benchmarks/aggregator-head-lag.yml index 1d53d822b..74d7f95fc 100644 --- a/benchmarks/aggregator-head-lag.yml +++ b/benchmarks/aggregator-head-lag.yml @@ -2,16 +2,16 @@ slug: aggregator-head-lag number: "001" -title: Fastest crypto price API, live head lag across Mobula, Codex, GeckoTerminal +title: Fastest crypto price API, live head lag across Mobula, Codex, GeckoTerminal, Serialized seo_title: "Fastest crypto price API 2026" -seo_description: "{{best_name}} leads fastest crypto price API at {{best_p50}} (cross-chain avg p50, 24h). Mobula WebSocket, Codex GraphQL, GeckoTerminal REST live across Base, BNB, Solana, Robinhood." -subtitle: Wall-clock head lag in seconds from on-chain swap event to API emission, measured live for Mobula, Codex and GeckoTerminal on Base, BNB Chain, Solana and Robinhood Chain. +seo_description: "{{best_name}} leads fastest crypto price API at {{best_p50}} (cross-chain avg p50, 24h). Mobula WebSocket, Codex GraphQL, GeckoTerminal REST, Serialized WebSocket live across Base, BNB, Solana, Robinhood." +subtitle: Wall-clock head lag in seconds from on-chain swap event to API emission, measured live for Mobula, Codex, GeckoTerminal and Serialized on Base, BNB Chain, Solana and Robinhood Chain. per_chain_explainer: - slug: base h2: "Lowest Base price feed head lag" body: | - {{best_name:chain:base}} currently leads Base head lag at {{best_p50:chain:base}} (p50, 24h) across the 3 measured providers. Base targets a 2 s sequencer cadence on the OP Stack, so the chain itself sets a roughly 2 s floor below which no provider can publish: any latency you see beyond that floor is pipeline work (indexer, fan-out, edge). Measured by holding archive-node `eth_subscribe("newHeads")` and timestamping each provider's emission of the same swap event from us-east, eu-west and Singapore. + {{best_name:chain:base}} currently leads Base head lag at {{best_p50:chain:base}} (p50, 24h) across the 4 measured providers. Base targets a 2 s sequencer cadence on the OP Stack, so the chain itself sets a roughly 2 s floor below which no provider can publish: any latency you see beyond that floor is pipeline work (indexer, fan-out, edge). Measured by holding the sequencer's flashblock preconfirmation stream and timestamping each provider's emission of the same swap from us-east, eu-west and Singapore, so the zero point is the moment the trade becomes public rather than the hour its block will carry. - slug: bnb h2: "Lowest BNB Chain price feed head lag" body: | @@ -36,10 +36,12 @@ seo_intro: | data provider. how many seconds elapse between a swap landing on chain and the same event appearing on the provider's feed. Marketing pages quote "real-time" without a number; this page quotes the - number. Mobula's WebSocket, Codex's GraphQL feed and GeckoTerminal's - REST endpoint are watched from three regions (us-east, eu-west, sgp) - against canonical-tip archive - nodes on Base, BNB Chain, Solana and Robinhood Chain. Because each chain has its own + number. Mobula's WebSocket, Codex's GraphQL feed, GeckoTerminal's + REST endpoint and Serialized's WebSocket trades stream are watched + from three regions (us-east, eu-west, sgp). On Base the zero point is + the sequencer's flashblock preconfirmation stream, which we hold + ourselves; on BNB Chain, Solana and Robinhood Chain it is the + on-chain timestamp against canonical-tip archive nodes. Because each chain has its own base block cadence (Solana's ~400 ms slots vs Base/BNB's 2-3 s blocks), the headline winner shifts by chain. On Solana the leader is {{best_name:chain:solana}} at {{best_p50:chain:solana}}; on Base @@ -61,26 +63,50 @@ abstract: | same event. methodology: - - "Aggregators measured: Mobula, Codex, GeckoTerminal." + - "Aggregators measured: Mobula, Codex, GeckoTerminal, Serialized." - "Chains: Base, BNB Chain, Solana, Robinhood Chain." + - "Reference on BNB Chain and Robinhood Chain: archive nodes per chain, validated against block hashes. These carry no preconfirmation layer a provider consumes, and their chain-supplied timestamps sit within roughly 60 ms of the moment a trade is observable, so they keep the on-chain timestamp as the zero point." + - "Solana (since 2026-09-16): the headline is the lag behind the first feed to report the trade, our own node subscription included in the race. Solana has no on-chain timestamp with sub-second precision, so the timestamps providers send compare conventions: Mobula's `date` (its ingestion time) read as a constant 0.10 s, Serialized's `at` (blockTime, whole seconds) as 0.75 s, while against a common clock the two feeds are 10 to 30 ms apart." + - "Solana zero point: no RPC WebSocket we hold, public or keyed (mainnet-beta, Helius, Alchemy), precedes the providers' geyser feeds, so an absolute zero point is not available and the first observation is the ruler. Our node stays in the race: it validates the hash on chain and bounds the field whenever a feed is slower than a plain RPC subscription." + - "Solana race: every feed's arrival for a transaction is compared to the earliest one (ties within 5 ms count as first for each feed involved). Published as `head_lag_first_share_pct` (share of the last 24 h of trades a feed reported first, the First to report view) and as `head_lag_seconds` (lag behind the first, 0 for the leader). GeckoTerminal polls and lands 15 to 100 s later; it is measured against the same first arrival." - "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." + - "Reference on Base: the sequencer's flashblock preconfirmation stream, held by us in every region. A Base block becomes queryable about 0.36 s after the timestamp it carries, while flashblocks publish every 200 ms inside the 2 s interval, so that timestamp is not the moment a trade becomes knowable." + - "Base emissions we cannot match to a flashblock are dropped rather than measured against a different ruler. Measuring against the block timestamp charged a provider reading preconfirmations with a negative lag, and described the chain's stamping convention rather than the provider's pipeline." + - "Metric: gauge `head_lag_seconds`, scraped every 30 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 (2,880 expected per provider per day at the 30 s scrape). 100% means the aggregator's feed was reachable for the full window." + - "Cardinality: 4 aggregators × 4 chains × 3 regions = 48 active series." + - "Probe placement: the three probes run in their named regions on Railway (europe-west4, us-east4, asia-southeast1). Until 2026-09-15 all three ran in asia-southeast1 under regional labels; region columns before that date are three Singapore vantage points." 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." - - "On Solana the leader is {{best_name:chain:solana}} at {{best_p50:chain:solana}} (p50, 24 h); the trailer is {{worst_name:chain:solana}} at {{worst_p50:chain:solana}}. Slot cadence is sub-second, so any second-scale aggregate latency is on the provider's pipeline, not the chain." - - "On Base the leader is {{best_name:chain:base}} at {{best_p50:chain:base}} (p50, 24 h); the trailer is {{worst_name:chain:base}} at {{worst_p50:chain:base}}. Base targets a 2 s block cadence, so head lag here measures pipeline latency on top of the chain's natural block interval." + - "On Solana the leader is {{best_name:chain:solana}} at {{best_p50:chain:solana}} (p50, 24 h) behind the first feed to report each trade; the trailer is {{worst_name:chain:solana}} at {{worst_p50:chain:solana}}. The First to report view gives the share of trades each feed delivered first; the leader's lag reads near zero by construction and the spread between push feeds is tens of milliseconds." + - "On Base the leader is {{best_name:chain:base}} at {{best_p50:chain:base}} (p50, 24 h); the trailer is {{worst_name:chain:base}} at {{worst_p50:chain:base}}. Measured against the flashblock preconfirmation stream, so the number is how long after a trade became public the provider delivered it, whichever layer it reads." - "On BNB Chain the leader is {{best_name:chain:bnb}} at {{best_p50:chain:bnb}} (p50, 24 h); the trailer is {{worst_name:chain:bnb}} at {{worst_p50:chain:bnb}}. BNB's ~3 s blocks set the floor; the spread between leader and trailer is the provider-side delta." - - "{{name:mobula}} leads on three of four chains. WebSocket feeds compress latency by pushing events the moment the harness emits them rather than waiting for the next poll cycle, which is the architectural reason real-time integrations prefer them over REST." - - "{{name:codex}} leads on Base at {{best_p50:chain:base}} (p50, 24 h). GraphQL subscriptions are a server-push pattern in practice and cluster near WebSocket performance on liquid pools, but tail latency tends to widen on long-tail tokens with sparse events." + - "Push feeds (WebSocket, GraphQL subscription) compress latency by emitting the moment the provider's indexer has the event rather than waiting for the next poll cycle, which is the architectural reason real-time integrations prefer them over REST. Which push feed leads shifts by chain, see the per-chain lines above." + - "On Base, what a provider reads matters more than how fast its pipeline is. A provider emitting from flashblock preconfirmations delivers a trade about 1.6 s (median over 21,538 transactions) before the sealed block carrying it becomes queryable, so a sealed-block reader carries that floor before any pipeline work. A preconfirmation is not final and can be reordered." + - "The Base spread is therefore mostly that floor: the providers' own pipelines sit within a few hundred milliseconds of each other. Read the Base column as a latency versus finality choice, not as a pure speed ranking." - "{{name:geckoterminal}} trails on every chain. REST polling adds the poll interval to every read, so head lag tracks the publisher's chosen cadence rather than raw infrastructure speed." - - "p99 is the integration-grade number. The gap between p50 and {{p99:mobula}} / {{p99:codex}} / {{p99:geckoterminal}} is what a live UI feels when a chain spikes or a region's path degrades." + - "p99 is the integration-grade number. The gap between p50 and {{p99:mobula}} / {{p99:codex}} / {{p99:serialized}} / {{p99:geckoterminal}} is what a live UI feels when a chain spikes or a region's path degrades." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/aggregator-head-lag +# Per-transaction race (harness race.go): share of trades each feed +# reported first over the last 24 h, our node reference included as a +# competitor. The avg by (aggregator) collapses chain x region series the +# way the headline queries do; the chain filter narrows it per chain. +chart: + default_panel_by_chain: + solana: first_share + +metric_panels: + - id: first_share + label: First to report + label_key: aggregator + metric: avg by (aggregator) (head_lag_first_share_pct{}) + unit: pct + higher_is_better: true + description: "Share of the last 24 hours of trades this feed delivered before every other feed (ties within 5 ms count for both). Our own node subscription runs in the same race; on Solana it wins a few percent, which is the gap between a plain RPC subscription and a geyser feed." + prometheus: window: 24h # Live-feed sanity gate. Sums event-arrival changes across every @@ -101,8 +127,10 @@ faq: a: "Yes, structurally. A WebSocket connection pushes events the moment the provider's indexer has them, so latency tracks the publisher's indexer speed and the network path. REST polling adds the poll interval to every read: a one-second poll cadence puts a floor of one second on top of whatever the indexer adds. The leaderboard shows the difference live, with Mobula on a WebSocket feed, Codex on GraphQL subscriptions and GeckoTerminal on REST." - q: "How fast does GeckoTerminal update prices?" a: "{{name:geckoterminal}} currently sits at {{p50:geckoterminal}} (p50, 24 h) head lag on this benchmark. REST endpoints update at the publisher's chosen cadence rather than the moment a swap lands, which is the architectural reason REST polling reads slower than WebSocket feeds for the same underlying chain data." + - q: "Why does the Solana leader show a lag near zero?" + a: "Because on Solana the ruler is the first feed to report the trade, not the chain. Solana's blockTime has one-second resolution and nothing on chain marks the millisecond a trade became observable, so measuring each provider against the timestamp it sends compared clock conventions: one provider's field was its own ingestion time (a constant 0.10 s), another's the blockTime (0.75 s on average). Against a common clock the two are 10 to 30 ms apart. The First to report view answers the question directly: the share of trades each feed delivered first. Our own node runs in the race and only wins on a small share of trades, which tells you these push feeds beat a plain RPC subscription; an absolute number needs a geyser-grade reference, which no RPC WebSocket we tested provides." - q: "How is head lag measured on OpenChainBench?" - a: "The harness holds a live WebSocket subscription to canonical-tip archive nodes on each chain (Base, BNB Chain, Solana, Robinhood Chain). Every new on-chain swap is timestamped on receipt with millisecond precision. The harness then watches each provider's feed for the same event and computes the delta. p50, p90 and p99 are aggregated over 24 hours via Prometheus `quantile_over_time`. Errors and dropouts are tracked separately as a presence ratio so a fast feed that disappears does not silently rank above a slower feed that stays up." + a: "On Base the harness holds the sequencer's flashblock preconfirmation stream in every region and timestamps each transaction the first time it appears there; on BNB Chain, Solana and Robinhood Chain it holds canonical-tip archive nodes and uses the on-chain timestamp. Every swap is timestamped with millisecond precision. The harness then watches each provider's feed for the same event and computes the delta. p50, p90 and p99 are aggregated over 24 hours via Prometheus `quantile_over_time`. Errors and dropouts are tracked separately as a presence ratio so a fast feed that disappears does not silently rank above a slower feed that stays up." - q: "Which data provider should I pick for a real-time trading interface?" a: "Read both the p50 and the p99 columns. p50 tells the typical experience, p99 tells the worst minute a user will see when a chain spikes or a region's path degrades. A 500 ms p50 with a 30 s p99 means most of the time the UI feels snappy and once per minute it stutters. The leaderboard splits both, plus the success rate column flags providers whose feed dropped during the measurement window." @@ -164,7 +192,7 @@ providers: p90: avg by (aggregator) (quantile_over_time(0.90, head_lag_seconds{aggregator="mobula"}[24h])) * 1000 p99: avg by (aggregator) (quantile_over_time(0.99, head_lag_seconds{aggregator="mobula"}[24h])) * 1000 mean: avg by (aggregator) (avg_over_time(head_lag_seconds{aggregator="mobula"}[24h])) * 1000 - success: clamp_max(avg by (aggregator) (count_over_time(head_lag_seconds{aggregator="mobula"}[24h]) / 5760), 1) + success: clamp_max(avg by (aggregator) (count_over_time(head_lag_seconds{aggregator="mobula"}[24h]) / 2880), 1) sample_size: sum(count_over_time(head_lag_seconds{aggregator="mobula"}[24h])) # Hybrid `unless` guard: drop the sample when the gauge saw no # fresh event in the last 15 min AND we have enough scrapes in @@ -202,7 +230,7 @@ providers: p90: avg by (aggregator) (quantile_over_time(0.90, head_lag_seconds{aggregator="codex"}[24h])) * 1000 p99: avg by (aggregator) (quantile_over_time(0.99, head_lag_seconds{aggregator="codex"}[24h])) * 1000 mean: avg by (aggregator) (avg_over_time(head_lag_seconds{aggregator="codex"}[24h])) * 1000 - success: clamp_max(avg by (aggregator) (count_over_time(head_lag_seconds{aggregator="codex"}[24h]) / 5760), 1) + success: clamp_max(avg by (aggregator) (count_over_time(head_lag_seconds{aggregator="codex"}[24h]) / 2880), 1) sample_size: sum(count_over_time(head_lag_seconds{aggregator="codex"}[24h])) series: avg_over_time(head_lag_seconds{aggregator="codex"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="codex"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="codex"}[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="codex", chain=""}[15m]) > 0.8) live_activity: sum(changes(head_lag_seconds{aggregator="codex"}[15m])) @@ -217,6 +245,32 @@ 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]) / 2880), 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 @@ -226,7 +280,7 @@ providers: p90: avg by (aggregator) (quantile_over_time(0.90, head_lag_seconds{aggregator="geckoterminal"}[24h])) * 1000 p99: avg by (aggregator) (quantile_over_time(0.99, head_lag_seconds{aggregator="geckoterminal"}[24h])) * 1000 mean: avg by (aggregator) (avg_over_time(head_lag_seconds{aggregator="geckoterminal"}[24h])) * 1000 - success: clamp_max(avg by (aggregator) (count_over_time(head_lag_seconds{aggregator="geckoterminal"}[24h]) / 5760), 1) + success: clamp_max(avg by (aggregator) (count_over_time(head_lag_seconds{aggregator="geckoterminal"}[24h]) / 2880), 1) sample_size: sum(count_over_time(head_lag_seconds{aggregator="geckoterminal"}[24h])) series: avg_over_time(head_lag_seconds{aggregator="geckoterminal"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="geckoterminal"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="geckoterminal"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{}[15m])) > 5) live_activity: sum(changes(head_lag_seconds{aggregator="geckoterminal"}[15m])) 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/bitcoin-rpc.yml b/benchmarks/bitcoin-rpc.yml new file mode 100644 index 000000000..5fc30be7d --- /dev/null +++ b/benchmarks/bitcoin-rpc.yml @@ -0,0 +1,127 @@ +# OpenChainBench. Bench No 259 + +slug: bitcoin-rpc +number: "259" +title: Fastest free Bitcoin RPC, live no-key endpoint latency +seo_title: "Fastest free Bitcoin RPC 2026" +seo_description: "{{best_name}} leads free Bitcoin RPC at {{best_p50}} (getblockcount p50, 24h). 3 no-key providers measured every 60s from 3 regions." +subtitle: HTTP round-trip latency for block-height queries against every available public Bitcoin endpoint, audited every 60 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Bitcoin is the original proof-of-work UTXO blockchain, targeting one block every ten minutes under SHA-256. It exposes the canonical Bitcoin Core JSON-RPC interface; the lightest height method is getblockcount, which returns the current chain tip as a plain integer. A handful of providers serve this endpoint with no API key: PublicNode, dRPC and NOWNodes (public no-key endpoint, rate-limited to 5 requests per second). Every provider was live-verified with consecutive getblockcount probes at launch, all returning the same tip. + +abstract: | + Per-chain member of the RPC latency cluster, extended to Bitcoin. We probe every + public no-key endpoint with a single getblockcount call every 60 seconds from three + regions (us-east, eu-west, Singapore) and record wall-clock round-trip latency. Only ok + responses (HTTP 200 with a usable block height, not stale) contribute to the distribution; + p50/p90/p99 are computed via Prometheus quantile_over_time over a rolling 24-hour window. + +methodology: + - "Probe: a single getblockcount JSON-RPC POST every 60 seconds per provider from us-east, eu-west and Singapore. Latency is the client-side wall-clock round trip." + - "Classification: ok (HTTP 200 with a usable height), http_err, jsonrpc_err (HTTP 200 with an error body), stale (tip more than 20 blocks behind the cross-provider max), timeout. Only ok responses contribute latency." + - "Providers: PublicNode (bitcoin-rpc.publicnode.com), dRPC (bitcoin.drpc.org), NOWNodes (public-btc.nownodes.io, no key, 5 RPS)." + - "Aggregation: p50/p90/p99 via Prometheus quantile_over_time over 24h; the headline cites the cross-region figure and the region tabs re-scope it." + +findings: + - "{{best_name}} leads at {{best_p50}} (getblockcount p50, 24h) across {{provider_count}} no-key Bitcoin endpoints." + - "Bitcoin's ten-minute block time means read latency reflects the provider's edge, not chain speed: a stale tip is rare, so this bench is a near-pure latency race." + - "{{name:nownodes}} serves Bitcoin from its public no-key endpoint at {{p50:nownodes}} (p50, 24h), rate-limited to 5 requests per second." + +faq: + - q: "Which free Bitcoin RPC is fastest?" + a: "Per the live data above: {{best_name}} at {{best_p50}} (getblockcount p50, 24h) across the no-key providers measured here." + - q: "How is Bitcoin RPC latency measured?" + a: "A single getblockcount call every 60 seconds from three regions (us-east, eu-west, Singapore), recording the wall-clock round trip. Only successful, non-stale responses count." + - q: "Do these endpoints need an API key?" + a: "No. All three (PublicNode, dRPC, NOWNodes) serve Bitcoin Core JSON-RPC with no key. NOWNodes' public endpoint is rate-limited to 5 requests per second." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + expected_freshness_seconds: 5400 + +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="bitcoin"}) + +dimensions: + region: + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, no-key Bitcoin Core JSON-RPC + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `getblockcount` sent every 60s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Bitcoin endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="bitcoin"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="bitcoin"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="bitcoin"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="bitcoin"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="bitcoin"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="bitcoin"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="bitcoin"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="bitcoin"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="bitcoin", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="bitcoin", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="bitcoin", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="bitcoin", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="bitcoin", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="bitcoin", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Decentralised RPC mesh, no-key Bitcoin endpoint + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `getblockcount` sent every 60s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Bitcoin endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="bitcoin"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="bitcoin"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="bitcoin"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="bitcoin"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="bitcoin"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="bitcoin"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="bitcoin"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="bitcoin"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="bitcoin", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="bitcoin", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="bitcoin", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="bitcoin", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="bitcoin", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="bitcoin", region="sgp"}[1h]) + + - slug: nownodes + name: NOWNodes + tag: Public node, 110+ chains, 5 RPS free tier + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `getblockcount` sent every 60s from 3 regions (us-east + eu-west + sgp) to NOWNodes's no-key Bitcoin endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="bitcoin"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nownodes", chain="bitcoin"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="nownodes", chain="bitcoin"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="nownodes", chain="bitcoin"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="nownodes", chain="bitcoin"}) / sum(ocb:rpc_call:rate_24h{provider="nownodes", chain="bitcoin"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="nownodes", chain="bitcoin"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="bitcoin"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="bitcoin", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="bitcoin", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="bitcoin", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="bitcoin", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="bitcoin", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="bitcoin", region="sgp"}[1h]) diff --git a/benchmarks/bnb-rpc.yml b/benchmarks/bnb-rpc.yml index 95ca204e9..94bf6875c 100644 --- a/benchmarks/bnb-rpc.yml +++ b/benchmarks/bnb-rpc.yml @@ -86,6 +86,29 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="bnb", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="bnb", region="sgp"}[1h]) + - slug: nownodes + name: NOWNodes + tag: Public node, 110+ chains, 5 RPS free tier + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to NOWNodes public no-key BNB Chain endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="bnb"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nownodes", chain="bnb"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="nownodes", chain="bnb"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="nownodes", chain="bnb"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="nownodes", chain="bnb"}) / sum(ocb:rpc_call:rate_24h{provider="nownodes", chain="bnb"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="nownodes", chain="bnb"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="bnb"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="bnb", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="bnb", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="bnb", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="bnb", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="bnb", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="bnb", region="sgp"}[1h]) + - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked diff --git a/benchmarks/bridge-execution-latency.yml b/benchmarks/bridge-execution-latency.yml new file mode 100644 index 000000000..c51e77e9b --- /dev/null +++ b/benchmarks/bridge-execution-latency.yml @@ -0,0 +1,159 @@ +# OpenChainBench. Bench № 261 + +slug: bridge-execution-latency +number: "261" +title: "Fastest on-chain bridge execution: real settlement latency, Mobula vs Relay vs LI.FI" +seo_title: "Fastest bridge execution 2026: real on-chain settlement latency and success rate" +seo_description: "Live on-chain bridge execution, not quotes: real broadcast-to-settlement latency, success rate and realized fees for Mobula, Relay and LI.FI on a self-conserving USDC triangle across Solana, Base and Arbitrum." +subtitle: Real broadcast-to-funds-received latency, success rate and realized fees for executed bridge transactions on a self-conserving USDC triangle (Solana, Base, Arbitrum), at $3 and $30, measured from EU-West. +category: Bridges +status: live +metric: Execution latency +unit: ms +higher_is_better: false + +# Per-corridor breakdown. The chain label carries route.ToChain, so each tab +# isolates one leg of the triangle: Sol to Base, Base to Arb, Arb to Sol. The +# amount tab isolates the $3 and $30 ticket sizes. Every figure is a REAL +# executed transaction (broadcast to on-chain settlement), never a quote. +dimensions: + chain: + - { value: Base, label: Sol to Base } + - { value: Arbitrum, label: Base to Arb } + - { value: Solana, label: Arb to Sol } + amount_usd: + - { value: "3", label: "$3" } + - { value: "30", label: "$30" } + +seo_intro: | + Every other bridge comparison on the internet measures quotes. This one + measures the transaction. We run a self-conserving USDC triangle (Solana + to Base to Arbitrum and back) with real capital, broadcasting real + transfers through Mobula, Relay and LI.FI, and we time each one from + broadcast to the moment the funds actually land on the destination chain. + Quotes are a promise; settlement is the truth, and the gap between them is + where slippage, failed fills and refunds live. This benchmark reports the + real broadcast-to-settlement latency, the success rate (fills versus + reverts and refunds) and the realized fee (amount sent minus amount + received) for two ticket sizes, $3 and $30, per corridor, measured from + EU-West. Inventory is conserved across the cycle, so only fees burn. + +abstract: | + We execute real cross-chain transfers on a conserving USDC triangle + (Solana to Base, Base to Arbitrum, Arbitrum to Solana) through Mobula, + Relay and LI.FI, at $3 and $30. For each executed transaction we record + the on-chain execution latency (broadcast to funds received), the + end-to-end latency (quote to funds received), the realized fee, and + whether it settled, reverted or was refunded. p50, p90 and p99 latency are + derived over a rolling 24-hour window; success rate is fills over fills + plus reverts and refunds. Reverted and refunded transactions are excluded + from the latency distribution and counted against the success rate. + +methodology: + - "Route: a self-conserving all-USDC triangle. Solana USDC to Base USDC, Base USDC to Arbitrum USDC, Arbitrum USDC to Solana USDC. Each executing bridge completes the full triangle before the next starts, so inventory returns to its origin and only fees burn." + - "Ticket sizes: $3 daily and $30 twice weekly, on fixed UTC schedules. Amounts are deliberately small: this measures settlement reliability, not large-ticket cost." + - "Latency: execution latency is wall-clock from broadcast to the destination credit; end-to-end latency adds the quote round-trip. p50, p90 and p99 are quantile_over_time over the exact observed latencies of the last 24h (a per-execution gauge), not a histogram-bucket interpolation, so the figures are the real values rather than a bucket midpoint." + - "Outcome classification: settled (funds received), reverted or refunded (capital returned to source), or errored (never broadcast). Only settled transactions contribute to the latency distribution; reverts and refunds count against the success rate." + - "Region: EU-West only at present. The execution node runs a single wallet, so a second origin would race the same inventory; additional origins are not planned for the execution loop." + - "Cohort: Mobula, Relay, LI.FI and Near Intents (Near Intents runs its own conserving USDC triangle via the 1Click flow). deBridge is quote-only in this loop. Providers without a symmetric route on all three legs are excluded so the triangle conserves." + - "Cost metrics captured per real execution (beyond latency and success): realized output on-chain, execution slippage vs the quote (realized fee minus quote-projected fee), our own on-chain gas paid (approve + deposit, measured as source-chain native balance delta), and the refund rate split from hard-fail rate. These surface the true all-in cost and the gap between a quote and what actually settles." + - "Reliability tail: cross-chain settlement is asynchronous, so a broadcast can end three ways beyond a clean fill. We split them: refunded (capital returned, with a refund-latency histogram for how fast it came back), reverted (on-chain failure), and stuck (a deposit whose status never resolved to a fill or refund in the poll window). The stuck rate is the share of swaps a user would have to chase manually: the honest measure of what is not automatically handled." + +findings: + - "{{name:mobula}} settles at {{p50:mobula}} (p50, broadcast to funds received) with a {{success:mobula}} success rate over the last 24 hours." + - "{{name:relay}} settles at {{p50:relay}} with a {{success:relay}} success rate." + - "{{name:lifi}} settles at {{p50:lifi}} with a {{success:lifi}} success rate." + - "{{name:near-intents}} settles at {{p50:near-intents}} with a {{success:near-intents}} success rate; intent settlement runs longer than lock-mint fills, so its latency sits above the others while staying reliable." + +faq: + - q: "How is this different from the bridge quote and bridge fee benchmarks?" + a: "Those measure the API response: how fast a quote comes back and what it claims the cost will be. This benchmark broadcasts and settles a real transaction, then measures how long the funds actually took to arrive and what the realized cost was. A provider can quote fast and cheap yet settle slowly or fail, and only an execution benchmark surfaces that." + - q: "What does the triangle conserve, and why does it matter?" + a: "The route returns every unit of capital to its origin over a full cycle (Solana to Base to Arbitrum to Solana), so the loop can run indefinitely on a fixed float with only fees burning. That is what makes continuous real execution affordable to benchmark." + - q: "Why only $3 and $30?" + a: "The goal is settlement reliability and latency, which do not require large notionals. Small tickets keep the standing capital and the burned fees low while still exercising the full broadcast, settle and accounting path on every provider." + - q: "How is the success rate computed?" + a: "Settled transactions divided by settled plus reverted and refunded. A transaction that never broadcast (a pre-flight or quote failure) is not counted against a provider; a broadcast that reverts or is refunded is." + - q: "Where does this run from?" + a: "EU-West. The execution node holds a single wallet and signs every broadcast, so running a second origin would race the same inventory. The latency therefore reflects an EU-West vantage; the quote-latency benchmark on this site covers multi-region read latency separately." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/bridge-monitor + +# Companion panels beside the latency headline. Both are single-selector +# gauges so the per-provider label injection stays exact. Slippage-vs-quote +# and our all-in gas are the two cost signals a quote can never show; the +# refund rate, stuck rate and refund latency live as raw metrics +# (bridge_refunds_total, bridge_stuck_total, bridge_refund_latency_ms) and +# are documented in the methodology until they accrue enough samples to panel. +metric_panels: + - id: slippage + label: Slippage vs quote + metric: avg by (bridge) (ocb:bridge_quote_slippage_usd:mean_24h{region="eu-west"}) + label_key: bridge + unit: usd + higher_is_better: false + description: "Realized fee minus quote-projected fee, in USD. The gap between what the quote promised and what actually settled." + - id: gas + label: All-in gas we paid + metric: avg by (bridge) (ocb:bridge_exec_gas_usd:avg_24h{region="eu-west"}) + label_key: bridge + unit: usd + higher_is_better: false + description: "Our own on-chain gas (approve plus deposit), measured as the source-chain native balance delta. The cost the user bears on top of the bridge fee." + +prometheus: + window: 24h + freshness_metric: bridge_execution_latency_ms + +providers: + - slug: mobula + name: Mobula + tag: Aggregator + intent layer + formula: "p50 over 24h of on-chain execution latency (broadcast to funds received, ms) for real USDC-triangle transfers executed through Mobula from EU-West, at the selected ticket size and corridor." + queries: + p50: avg(ocb:bridge_exec_latency_ms:p50_24h{bridge="mobula", region="eu-west"}) + p90: avg(ocb:bridge_exec_latency_ms:p90_24h{bridge="mobula", region="eu-west"}) + p99: avg(ocb:bridge_exec_latency_ms:p99_24h{bridge="mobula", region="eu-west"}) + mean: avg(ocb:bridge_exec_latency_ms:mean_24h{bridge="mobula", region="eu-west"}) + success: sum(increase(bridge_success_total{bridge="mobula", region="eu-west"}[24h])) / clamp_min(sum(increase(bridge_success_total{bridge="mobula", region="eu-west"}[24h])) + sum(increase(bridge_reverts_total{bridge="mobula", region="eu-west"}[24h])), 1) + sample_size: sum(increase(bridge_success_total{bridge="mobula", region="eu-west"}[24h])) + sum(increase(bridge_reverts_total{bridge="mobula", region="eu-west"}[24h])) + series: ocb:bridge_exec_latency_ms:p50_24h{bridge="mobula", region="eu-west"} + + - slug: relay + name: Relay + tag: Cross-chain relay + formula: "p50 over 24h of on-chain execution latency (broadcast to funds received, ms) for real USDC-triangle transfers executed through Relay from EU-West, at the selected ticket size and corridor." + queries: + p50: avg(ocb:bridge_exec_latency_ms:p50_24h{bridge="relay", region="eu-west"}) + p90: avg(ocb:bridge_exec_latency_ms:p90_24h{bridge="relay", region="eu-west"}) + p99: avg(ocb:bridge_exec_latency_ms:p99_24h{bridge="relay", region="eu-west"}) + mean: avg(ocb:bridge_exec_latency_ms:mean_24h{bridge="relay", region="eu-west"}) + success: sum(increase(bridge_success_total{bridge="relay", region="eu-west"}[24h])) / clamp_min(sum(increase(bridge_success_total{bridge="relay", region="eu-west"}[24h])) + sum(increase(bridge_reverts_total{bridge="relay", region="eu-west"}[24h])), 1) + sample_size: sum(increase(bridge_success_total{bridge="relay", region="eu-west"}[24h])) + sum(increase(bridge_reverts_total{bridge="relay", region="eu-west"}[24h])) + series: ocb:bridge_exec_latency_ms:p50_24h{bridge="relay", region="eu-west"} + + - slug: lifi + name: LI.FI + tag: Aggregator + formula: "p50 over 24h of on-chain execution latency (broadcast to funds received, ms) for real USDC-triangle transfers executed through LI.FI from EU-West, at the selected ticket size and corridor." + queries: + p50: avg(ocb:bridge_exec_latency_ms:p50_24h{bridge="lifi", region="eu-west"}) + p90: avg(ocb:bridge_exec_latency_ms:p90_24h{bridge="lifi", region="eu-west"}) + p99: avg(ocb:bridge_exec_latency_ms:p99_24h{bridge="lifi", region="eu-west"}) + mean: avg(ocb:bridge_exec_latency_ms:mean_24h{bridge="lifi", region="eu-west"}) + success: sum(increase(bridge_success_total{bridge="lifi", region="eu-west"}[24h])) / clamp_min(sum(increase(bridge_success_total{bridge="lifi", region="eu-west"}[24h])) + sum(increase(bridge_reverts_total{bridge="lifi", region="eu-west"}[24h])), 1) + sample_size: sum(increase(bridge_success_total{bridge="lifi", region="eu-west"}[24h])) + sum(increase(bridge_reverts_total{bridge="lifi", region="eu-west"}[24h])) + series: ocb:bridge_exec_latency_ms:p50_24h{bridge="lifi", region="eu-west"} + + - slug: near-intents + name: Near Intents + tag: Intent layer (NEAR) + formula: "p50 over 24h of exact on-chain execution latency (broadcast to funds received, ms) for real USDC-triangle transfers executed through the Near Intents 1Click flow from EU-West, at the selected ticket size and corridor." + queries: + p50: avg(ocb:bridge_exec_latency_ms:p50_24h{bridge="near-intents", region="eu-west"}) + p90: avg(ocb:bridge_exec_latency_ms:p90_24h{bridge="near-intents", region="eu-west"}) + p99: avg(ocb:bridge_exec_latency_ms:p99_24h{bridge="near-intents", region="eu-west"}) + mean: avg(ocb:bridge_exec_latency_ms:mean_24h{bridge="near-intents", region="eu-west"}) + success: sum(increase(bridge_success_total{bridge="near-intents", region="eu-west"}[24h])) / clamp_min(sum(increase(bridge_success_total{bridge="near-intents", region="eu-west"}[24h])) + sum(increase(bridge_reverts_total{bridge="near-intents", region="eu-west"}[24h])), 1) + sample_size: sum(increase(bridge_success_total{bridge="near-intents", region="eu-west"}[24h])) + sum(increase(bridge_reverts_total{bridge="near-intents", region="eu-west"}[24h])) + series: ocb:bridge_exec_latency_ms:p50_24h{bridge="near-intents", region="eu-west"} diff --git a/benchmarks/bridge-fee.yml b/benchmarks/bridge-fee.yml index db25d8a07..7c8708225 100644 --- a/benchmarks/bridge-fee.yml +++ b/benchmarks/bridge-fee.yml @@ -22,11 +22,11 @@ dimensions: - { value: Arbitrum, label: Base to Arb } - { value: Solana, label: Arb to Sol } - { value: HyperCore, label: Arb to HyperCore } + # us-east and sgp probes are temporarily paused (2026-09-10): only the + # EU-West (Paris) origin is live, so the bench is scoped to a single region. + # Restore the all/us-east/sgp values when the Railway us/sgp probes resume. region: - - { value: all, label: All regions } - { value: eu-west, label: EU-West } - - { value: us-east, label: US-East } - - { value: sgp, label: Singapore } amount_usd: - { value: "300", label: "$300" } - { value: "50", label: "$50" } @@ -63,9 +63,9 @@ methodology: - "Reference notional: $300 USDC. Smaller sizes ($5, $50) are captured by the harness but excluded from this report." - "Cost: `bridge_cost_percent{amount_usd=\"300\"}`, in percent of notional. Includes fees + slippage + destination gas." - "Cadence: full sweep every 5 minutes for 24 hours." - - "Aggregation: the headline number is the average of per-corridor 24h medians over the corridors a provider actually quotes. The TRUMP to BRETT calibration route is excluded. Near Intents has no USDT legs, so its aggregate covers the Solana to Base and Arbitrum to HyperCore corridors only. deBridge does not quote the Arbitrum to HyperCore corridor, so its aggregate covers the remaining three. Across also skips Arbitrum to HyperCore, so its aggregate covers the same three corridors as deBridge." + - "Aggregation: the headline number is the average of per-corridor 24h medians over the corridors a provider actually quotes. The TRUMP to BRETT calibration route is excluded. deBridge does not quote the Arbitrum to HyperCore corridor, so its aggregate covers the remaining three. Across also skips Arbitrum to HyperCore, so its aggregate covers the same three corridors as deBridge." - "Normalization: cost is quoted USD in minus quoted USD delivered, the same all-in definition for every provider. Near Intents bundles the destination chain withdrawal fee into its quoted amountOut (the 1Click API documents withdrawFee as already deducted), so no extra gas term is added on top." - - "Regions: eu-west, us-east, sgp. Three independent instances probe simultaneously; the All-regions aggregate uses the full union; per-region tabs isolate the view to one origin." + - "Regions: eu-west only at present. The us-east and sgp probes are temporarily paused, so the live figures reflect the EU-West (Paris) origin; the other two tabs return when those probes resume." - "Failures (quote_failed, unsupported route) excluded from cost aggregates and counted toward success rate." - "Provider types are surfaced as badges: direct protocols (deBridge) charge their own bridging fees; aggregators (LI.FI) route through whichever underlying bridge is cheapest, so their effective fee includes that bridge's cost plus any aggregator markup; intent / relay layers (Mobula, Relay, Across, Near Intents) bake cost into the spread of a settlement intent or optimistic fill rather than charging an explicit fee." - "Excluded by design: Stargate, because its keyless quote endpoint was deprecated in favor of a key-gated LayerZero API, and its on-chain quoteOFT path is same-asset EVM-only, which matches none of this bench's corridors. CCTP, because Circle's burn/mint has no quote that returns a delivered amount; standard transfers mint 1:1 and the whole cost is user-paid gas we would have to estimate ourselves, which breaks the quoted USD in minus quoted USD delivered definition used for every other row." @@ -85,7 +85,7 @@ findings: - "{{name:mobula}} returns {{p50:mobula}} (p50, 24 h). Intent layers compress fees, slippage and destination gas into a single spread, so the effective number reads lower than aggregators that route through a paid underlying bridge." - "{{name:lifi}} sits at {{p50:lifi}} (p50, 24 h). Aggregators pay the underlying bridge plus a thin markup, so their effective cost trails intent layers on liquid corridors where multiple solvers compete on the same route." - "{{name:debridge}} clocks {{p50:debridge}} (p50, 24 h). Direct protocols front-load cost into a native-token fee, which dominates on $300 trades because the fixed component does not scale with notional." - - "{{name:across}} posts {{p50:across}} (p50, 24 h). Optimistic relayer fills price mostly relayer capital plus destination gas, so the number stays tight on same-asset USDC corridors and widens on the cross-asset USDT legs, where the quote wraps a DEX swap around the bridge and swap impact enters the all-in cost." + - "{{name:across}} posts {{p50:across}} (p50, 24 h). Optimistic relayer fills price mostly relayer capital plus destination gas, so the number stays tight across the same-asset USDC corridors of the triangle." - "{{worst_name}} trails the cross-corridor aggregate at {{worst_p50}} (p50, 24 h). On a $300 trade the gap between leader and laggard is mostly the fixed-fee floor of the slower path, not relative spread efficiency, and the laggard on one corridor can be the leader on another once corridor dimensions are surfaced." faq: diff --git a/benchmarks/bridge-quote-latency-solana.yml b/benchmarks/bridge-quote-latency-solana.yml new file mode 100644 index 000000000..7284a2b97 --- /dev/null +++ b/benchmarks/bridge-quote-latency-solana.yml @@ -0,0 +1,167 @@ +# OpenChainBench. Bench № 264 + +slug: bridge-quote-latency-solana +number: "264" +title: "Fastest Solana cross-chain bridge quote API, live ms ranking" +seo_title: "Fastest Solana bridge quote API 2026: SOL to Base, Arbitrum, BNB, Ethereum" +seo_description: "Live p50 quote latency for cross-chain bridges leaving Solana. Mobula, Relay, LI.FI, deBridge, Across and Near Intents ranked on SOL to Base, Arbitrum, BNB and Ethereum, measured every five minutes." +subtitle: Time to receive a usable cross-chain quote when the source is Solana, in milliseconds. Identical notional, measured every five minutes on SOL to Base, Arbitrum, BNB and Ethereum across Mobula, Relay, LI.FI, deBridge, Across and Near Intents. +category: Bridges +status: live +metric: Quote latency +unit: ms + +# Solana-source only. The general bridge-quote-latency bench aggregates every +# corridor; this one isolates Solana as the origin, which is the leg with the +# most demanding quote path (Solana address + SPL token resolution on top of +# the destination route search). Each tab is a destination chain. +dimensions: + chain: + - { value: Base, label: SOL to Base } + - { value: Arbitrum, label: SOL to Arbitrum } + - { value: BNB, label: SOL to BNB } + - { value: Ethereum, label: SOL to Ethereum } + - { value: Robinhood, label: SOL to Robinhood } + # EU-West only at present (us-east / sgp probes paused, single origin). + region: + - { value: eu-west, label: EU-West } + amount_usd: + - { value: "300", label: "$300" } + - { value: "50", label: "$50" } + - { value: "5", label: "$5" } + +seo_intro: | + This benchmark measures how fast each cross-chain bridge returns a usable + quote when the transfer starts on Solana. Solana-origin quotes are the + hardest case: the API has to resolve an SPL token account and a Solana + address alongside the destination route search, so it is the corridor that + most separates a thin price-discovery call from a heavy route-search graph. + We ask every bridge for the same SOL to Base, Arbitrum, BNB and Ethereum + quotes at $5, $50 and $300 every five minutes from EU-West, and rank them by + p50 latency over the last 24 hours. The general cross-chain quote benchmark + aggregates every origin; this one isolates Solana so the SOL to X story is + legible on its own. + +abstract: | + Per-provider p50, p90 and p99 quote latency for Solana-origin corridors + (SOL to Base, Arbitrum, BNB, Ethereum) at $5, $50 and $300, sampled every + five minutes from EU-West and aggregated over a rolling 24-hour window from + a recording rule scoped to from_chain=Solana. Success rate is the share of + probes that returned a usable quote. Providers without a Solana-source route + (EVM-only aggregators) are excluded. + +methodology: + - "Origin: Solana only. Every corridor in this bench starts from Solana USDC; the destination is the per-tab chain." + - "Destinations: Base, Arbitrum, BNB, Ethereum and Robinhood Chain. A provider that does not quote a given destination simply does not appear on that tab." + - "Robinhood Chain (Arbitrum Orbit, chainId 4663) has no native USDC, so it is a cross-asset USDC to USDG (Global Dollar) corridor. On the live probe only Mobula returns a usable SOL to Robinhood quote; Relay, LI.FI and deBridge reject the USDC-to-USDG pair on this chain (invalid input/output currency), so they do not appear on the Robinhood tab." + - "Latency: wall-clock from request to a usable quote, in milliseconds. p50, p90 and p99 over a rolling 24h window via histogram_quantile on a from_chain=Solana-scoped recording rule." + - "Cadence: every five minutes, at $5, $50 and $300 notional." + - "Region: EU-West only at present. The us-east and sgp probes are paused, so the figures reflect the EU-West (Paris) origin." + - "Cohort: Mobula, Relay, LI.FI, deBridge, Across and Near Intents. Squid and Socket are EVM-only aggregators with no Solana-source route and are excluded here (they remain in the general bridge-quote-latency bench)." + +findings: + - "{{best_name}} returns the fastest Solana-origin quote at {{best_p50}} (p50, 24 h) across {{count}} providers, on the destinations it covers." + - "{{name:mobula}} returns {{p50:mobula}} (p50, 24 h). A thin price-discovery call against pre-quoted solvers keeps Solana-origin latency low even though the SPL resolution adds work the EVM-only corridors avoid." + - "{{name:relay}} sits at {{p50:relay}} (p50, 24 h)." + - "{{name:near-intents}} clocks {{p50:near-intents}} (p50, 24 h); its 1Click flow is bimodal, sub-100ms on solver-cached corridors and up to the full auction window otherwise." + - "{{worst_name}} trails at {{worst_p50}} (p50, 24 h), the cost of a heavier route search on the Solana-origin path." + +faq: + - q: "What is the fastest bridge to leave Solana?" + a: "{{best_name}} currently returns the fastest Solana-origin quote at {{best_p50}} (p50, 24 h), across the destinations it covers. The leaderboard re-sorts every five minutes against fresh samples, so it reflects the last 24 hours of live data rather than a static claim." + - q: "How is this different from the general bridge quote latency benchmark?" + a: "The general bench aggregates every origin chain. This one isolates Solana as the source, because Solana-origin quotes carry extra work (SPL token account and Solana address resolution) and because SOL to X is a corridor worth ranking on its own." + - q: "Why are some bridges missing from a destination tab?" + a: "Because they do not quote that Solana-origin corridor. Squid and Socket are EVM-only aggregators with no Solana-source route, so they are excluded entirely; other providers appear only on the destinations they actually return a quote for." + - q: "Does a fast quote mean a fast transfer?" + a: "No. This measures the quote API response only. The end-to-end execution benchmark measures the real broadcast-to-settlement time on executed transfers separately." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/bridge-monitor + +prometheus: + window: 24h + expected_freshness_seconds: 1800 + +# rank_matrix_query required when dimensions.region is declared. label_replace +# maps bridge= -> provider= so badge scoping works per region, over the +# Solana-scoped recording rule. +rank_matrix_query: histogram_quantile(0.50, label_replace(ocb:bridge_quote_latency_ms_bucket:sol_rate24h, "provider", "$1", "bridge", "(.+)")) + +providers: + - slug: mobula + name: Mobula + tag: Aggregator + intent layer + formula: "p50 over 24h of Solana-origin quote latency (ms) from Mobula, at the selected destination and notional, sampled every 5 minutes from EU-West." + queries: + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="mobula"}) + p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="mobula"}) + p99: histogram_quantile(0.99, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="mobula"}) + mean: ocb:bridge_quote_latency_ms_sum:sol_rate24h{bridge="mobula"} / clamp_min(ocb:bridge_quote_latency_ms_count:sol_rate24h{bridge="mobula"}, 1) + success: ocb:bridge_quote_success:sol_avg24h{bridge="mobula"} + sample_size: ocb:bridge_quote_latency_ms_count:sol_increase24h{bridge="mobula"} + series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mobula", from_chain="Solana"}[1h]))) + + - slug: relay + name: Relay + tag: Cross-chain relay + formula: "p50 over 24h of Solana-origin quote latency (ms) from Relay, at the selected destination and notional, sampled every 5 minutes from EU-West." + queries: + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="relay"}) + p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="relay"}) + p99: histogram_quantile(0.99, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="relay"}) + mean: ocb:bridge_quote_latency_ms_sum:sol_rate24h{bridge="relay"} / clamp_min(ocb:bridge_quote_latency_ms_count:sol_rate24h{bridge="relay"}, 1) + success: ocb:bridge_quote_success:sol_avg24h{bridge="relay"} + sample_size: ocb:bridge_quote_latency_ms_count:sol_increase24h{bridge="relay"} + series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="relay", from_chain="Solana"}[1h]))) + + - slug: lifi + name: LI.FI + tag: Aggregator + formula: "p50 over 24h of Solana-origin quote latency (ms) from LI.FI, at the selected destination and notional, sampled every 5 minutes from EU-West." + queries: + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="lifi"}) + p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="lifi"}) + p99: histogram_quantile(0.99, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="lifi"}) + mean: ocb:bridge_quote_latency_ms_sum:sol_rate24h{bridge="lifi"} / clamp_min(ocb:bridge_quote_latency_ms_count:sol_rate24h{bridge="lifi"}, 1) + success: ocb:bridge_quote_success:sol_avg24h{bridge="lifi"} + sample_size: ocb:bridge_quote_latency_ms_count:sol_increase24h{bridge="lifi"} + series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="lifi", from_chain="Solana"}[1h]))) + + - slug: debridge + name: deBridge + tag: Native-token fees + formula: "p50 over 24h of Solana-origin quote latency (ms) from deBridge, at the selected destination and notional, sampled every 5 minutes from EU-West." + queries: + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="debridge"}) + p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="debridge"}) + p99: histogram_quantile(0.99, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="debridge"}) + mean: ocb:bridge_quote_latency_ms_sum:sol_rate24h{bridge="debridge"} / clamp_min(ocb:bridge_quote_latency_ms_count:sol_rate24h{bridge="debridge"}, 1) + success: ocb:bridge_quote_success:sol_avg24h{bridge="debridge"} + sample_size: ocb:bridge_quote_latency_ms_count:sol_increase24h{bridge="debridge"} + series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="debridge", from_chain="Solana"}[1h]))) + + - slug: across + name: Across + tag: Optimistic relay + formula: "p50 over 24h of Solana-origin quote latency (ms) from Across, at the selected destination and notional, sampled every 5 minutes from EU-West." + queries: + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="across"}) + p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="across"}) + p99: histogram_quantile(0.99, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="across"}) + mean: ocb:bridge_quote_latency_ms_sum:sol_rate24h{bridge="across"} / clamp_min(ocb:bridge_quote_latency_ms_count:sol_rate24h{bridge="across"}, 1) + success: ocb:bridge_quote_success:sol_avg24h{bridge="across"} + sample_size: ocb:bridge_quote_latency_ms_count:sol_increase24h{bridge="across"} + series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="across", from_chain="Solana"}[1h]))) + + - slug: near-intents + name: Near Intents + tag: Intent layer (NEAR) + formula: "p50 over 24h of Solana-origin quote latency (ms) from the Near Intents 1Click API, at the selected destination and notional, sampled every 5 minutes from EU-West." + queries: + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="near-intents"}) + p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="near-intents"}) + p99: histogram_quantile(0.99, ocb:bridge_quote_latency_ms_bucket:sol_rate24h{bridge="near-intents"}) + mean: ocb:bridge_quote_latency_ms_sum:sol_rate24h{bridge="near-intents"} / clamp_min(ocb:bridge_quote_latency_ms_count:sol_rate24h{bridge="near-intents"}, 1) + success: ocb:bridge_quote_success:sol_avg24h{bridge="near-intents"} + sample_size: ocb:bridge_quote_latency_ms_count:sol_increase24h{bridge="near-intents"} + series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents", from_chain="Solana"}[1h]))) diff --git a/benchmarks/bridge-quote-latency.yml b/benchmarks/bridge-quote-latency.yml index 7362a22ed..9de6245f3 100644 --- a/benchmarks/bridge-quote-latency.yml +++ b/benchmarks/bridge-quote-latency.yml @@ -24,11 +24,12 @@ dimensions: - { value: Arbitrum, label: Base to Arb } - { value: Solana, label: Arb to Sol } - { value: HyperCore, label: Arb to HyperCore } + # us-east and sgp probes are temporarily paused (2026-09-10): only the + # EU-West (Paris) origin is live, so the bench is scoped to a single region. + # Restore the all/us-east/sgp values to bring the multi-region tabs back once + # the Railway us/sgp probes resume. region: - - { value: all, label: All regions } - { value: eu-west, label: EU-West } - - { value: us-east, label: US-East } - - { value: sgp, label: Singapore } amount_usd: - { value: "300", label: "$300" } - { value: "50", label: "$50" } @@ -41,9 +42,10 @@ seo_intro: | flow into a wallet, an aggregator or a chat-app care about the same number every public roundup ignores. how many milliseconds the API burns before the user sees a price. We hammer the same USDC routes - (Solana, Base, Arbitrum) against every bridge from three geographic - origins (EU-West, US-East, Singapore), every five minutes, on three - notional sizes. p50, p90 and p99 are computed via Prometheus + (Solana, Base, Arbitrum) against every bridge from the EU-West (Paris) + origin, every five minutes, on three notional sizes. The US-East and + Singapore origins are temporarily paused, so the live numbers reflect + EU-West only. p50, p90 and p99 are computed via Prometheus `histogram_quantile` over the 24-hour window, so the leaderboard reflects sustained API performance, not a lucky cold-start. The same harness records failure mode for every request (quote_failed, @@ -63,7 +65,7 @@ methodology: - "Routes: USDC pairs spanning Solana, Base and Arbitrum." - "Notional sizes: $5, $50, $300 per quote." - "Cadence: full sweep (4 routes × 3 amounts × N bridges) every 5 minutes for 24 hours." - - "Regions: eu-west, us-east, sgp. Three independent instances probe simultaneously; the All-regions aggregate uses the full union; per-region tabs isolate the view to one origin." + - "Regions: eu-west only at present. The us-east and sgp probes are temporarily paused, so the live figures reflect the EU-West (Paris) origin; the harness is built to probe three origins simultaneously and the other two tabs return when those probes resume." - "Histogram buckets: 10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000 ms. Sub-50ms buckets added 2026-07-13 so solver-cached fast paths (Near Intents on HyperCore in particular) surface their true p50 instead of being floored at the interpolation of the (0, 50] bucket." - "Failures (quote_failed, execution_failed, unsupported route) excluded from latency aggregates and counted toward success rate." @@ -91,7 +93,7 @@ prometheus: faq: - q: "Which cross-chain bridge has the lowest API latency?" - a: "{{best_name}} currently leads the cross-corridor aggregate at {{best_p50}} (p50, 24 h) across {{count}} measured bridges, averaged over the 4 USDC routes the harness sweeps. The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the ranking reflects the last 24 hours of actual API performance from 3 regions (eu-west, us-east, sgp) under the same routes. Per-corridor latency leaders can differ from this aggregate where one provider has a solver pool closer to a specific destination chain, that breakdown is on the roadmap once corridor dimensions are surfaced in the page UI." + a: "{{best_name}} currently leads the cross-corridor aggregate at {{best_p50}} (p50, 24 h) across {{count}} measured bridges, averaged over the 4 USDC routes the harness sweeps. The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the ranking reflects the last 24 hours of actual API performance from the EU-West (Paris) origin under the same routes. Per-corridor latency leaders can differ from this aggregate where one provider has a solver pool closer to a specific destination chain, that breakdown is on the roadmap once corridor dimensions are surfaced in the page UI." - q: "What is the difference between quote latency and bridge fill time?" a: "Quote latency is the wall-clock time the bridge API takes to return a price. Fill time is the wall-clock time the funds take to land on the destination chain after the user signs. They are decoupled: a bridge can quote in 100 ms and take 30 seconds to settle, or quote in 2 seconds and settle in 5 seconds. This benchmark measures the quote half because that is the part a wallet, an aggregator or an embedded swap UI feels first and tunes its UX around." - q: "Why does bridge quote latency matter for builders?" @@ -99,7 +101,7 @@ faq: - q: "Are intent and relay bridges always faster than aggregators?" a: "On a single-route query, usually. Intent layers like Relay quote against a pool of pre-positioned solvers, so the API is a thin price-discovery call. Aggregators like LI.FI run a route-search graph over N underlying bridges per request, which adds 100 to 400 ms of irreducible work. On multi-hop or rare-corridor queries the aggregator advantage in coverage matters more than the latency gap, and the comparison flips." - q: "How does OpenChainBench measure bridge quote latency?" - a: "The harness issues identical quote requests against every bridge for the same route and notional, every five minutes, from three origins (eu-west, us-east, sgp) simultaneously. Each request times wall-clock duration from send to last byte received, then publishes a Prometheus histogram (10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000 ms buckets). p50, p90 and p99 are derived via `histogram_quantile` over a rolling 24-hour window. Errored quotes (quote_failed, unsupported route, timeout) are excluded from the latency aggregate and counted toward success rate so a fast-but-broken bridge cannot game the headline." + a: "The harness issues identical quote requests against every bridge for the same route and notional, every five minutes, from the EU-West (Paris) origin. Each request times wall-clock duration from send to last byte received, then publishes a Prometheus histogram (10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000 ms buckets). p50, p90 and p99 are derived via `histogram_quantile` over a rolling 24-hour window. Errored quotes (quote_failed, unsupported route, timeout) are excluded from the latency aggregate and counted toward success rate so a fast-but-broken bridge cannot game the headline." - q: "Does quote latency affect the price the user gets?" a: "Indirectly, yes. A slower quote API leaves a larger window between the moment the price is quoted and the moment the user signs. On volatile corridors the underlying spot can drift in that window, which surfaces as a quoted-vs-realized gap. Intent layers compensate by re-quoting at signing time; aggregators usually pass through a slippage parameter. The fee benchmark on this site (`/benchmarks/bridge-fee`) measures the realized cost; this page measures the latency half of the same flow." @@ -118,7 +120,7 @@ providers: - slug: mobula name: Mobula tag: Aggregator + intent layer - formula: "Median wall-clock ms over 24h for Mobula's quote API to return a usable USDC route, sampled every 5 minutes across 4 routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." + formula: "Median wall-clock ms over 24h for Mobula's quote API to return a usable USDC route, sampled every 5 minutes across 4 routes and 3 notionals from the EU-West (Paris) origin." queries: p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="mobula"}) p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="mobula"}) @@ -141,7 +143,7 @@ providers: - slug: relay name: Relay tag: Cross-chain relay - formula: "Median wall-clock ms over 24h for Relay's solver-quote API to return a usable USDC route, sampled every 5 minutes across 4 routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." + formula: "Median wall-clock ms over 24h for Relay's solver-quote API to return a usable USDC route, sampled every 5 minutes across 4 routes and 3 notionals from the EU-West (Paris) origin." queries: p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="relay"}) p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="relay"}) @@ -164,7 +166,7 @@ providers: - slug: lifi name: LI.FI tag: Aggregator - formula: "Median wall-clock ms over 24h for LI.FI's route-search API to return a usable USDC route across underlying bridges, sampled every 5 minutes across 4 routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." + formula: "Median wall-clock ms over 24h for LI.FI's route-search API to return a usable USDC route across underlying bridges, sampled every 5 minutes across 4 routes and 3 notionals from the EU-West (Paris) origin." queries: p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="lifi"}) p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="lifi"}) @@ -187,7 +189,7 @@ providers: - slug: debridge name: deBridge tag: Native-token fees - formula: "Median wall-clock ms over 24h for deBridge's direct-protocol quote API to return a single-route USDC quote, sampled every 5 minutes across 4 routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." + formula: "Median wall-clock ms over 24h for deBridge's direct-protocol quote API to return a single-route USDC quote, sampled every 5 minutes across 4 routes and 3 notionals from the EU-West (Paris) origin." queries: p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="debridge"}) p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="debridge"}) @@ -210,7 +212,7 @@ providers: - slug: near-intents name: Near Intents tag: Intent layer (NEAR) - formula: "Median wall-clock ms over 24h for Near Intents 1Click API to return a usable USDC quote via the solver auction bus, sampled every 5 minutes across the supported routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." + formula: "Median wall-clock ms over 24h for Near Intents 1Click API to return a usable USDC quote via the solver auction bus, sampled every 5 minutes across the supported routes and 3 notionals from the EU-West (Paris) origin." queries: p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="near-intents"}) p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="near-intents"}) @@ -233,7 +235,7 @@ providers: - slug: across name: Across tag: Intent layer - formula: "Median wall-clock ms over 24h for Across's Swap API to return a usable USDC quote across supported corridors (Sol to Base, Base to Arb) from 3 regions. Arb to Sol excluded: no destination swaps to Solana." + formula: "Median wall-clock ms over 24h for Across's Swap API to return a usable USDC quote across supported corridors (Sol to Base, Base to Arb) from the EU-West origin. Arb to Sol excluded: no destination swaps to Solana." queries: p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="across"}) p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="across"}) diff --git a/benchmarks/bridge-realized-cost.yml b/benchmarks/bridge-realized-cost.yml new file mode 100644 index 000000000..98baa909a --- /dev/null +++ b/benchmarks/bridge-realized-cost.yml @@ -0,0 +1,146 @@ +# OpenChainBench. Bench № 263 + +slug: bridge-realized-cost +number: "263" +title: "Bridge slippage vs quote: what actually settles versus what was quoted" +seo_title: "Bridge execution slippage 2026: realized cost vs the quote" +seo_description: "Live gap between a bridge quote and the real on-chain settlement for Mobula, Relay and LI.FI, plus the gas the user actually pays, measured on real executed USDC transfers across Solana, Base and Arbitrum from EU-West." +subtitle: The gap between what a bridge quotes and what actually settles on-chain, in USD, on real executed USDC transfers, plus our own gas, at $3 and $30, measured from EU-West. +category: Bridges +status: live +metric: Slippage vs quote +unit: usd +higher_is_better: false + +# Per-corridor breakdown. The chain label carries route.ToChain, so each tab +# isolates one leg of the executed triangle. amount_usd isolates ticket size. +# Every figure is a REAL executed transaction, never a quote. +dimensions: + chain: + - { value: Base, label: Sol to Base } + - { value: Arbitrum, label: Base to Arb } + - { value: Solana, label: Arb to Sol } + amount_usd: + - { value: "3", label: "$3" } + - { value: "30", label: "$30" } + +seo_intro: | + Every bridge quotes a cost. This benchmark measures whether that quote + survives contact with the chain. We broadcast and settle real USDC + transfers through Mobula, Relay and LI.FI on a self-conserving triangle, + then record the realized fee against the fee the quote promised. The + headline is that gap, in dollars: a bridge whose settlement matches its + quote sits near zero, a bridge that fills below quote reads positive, and + a bridge that beats its quote reads negative. Alongside it we surface the + gas the user actually pays to execute (approve plus deposit) and the value + that actually landed on the destination. Quotes are a promise; this is the + settlement. + +abstract: | + For every executed transfer we record the realized fee (amount sent minus + amount received on-chain) and subtract the quote-projected fee, giving the + execution slippage versus the quote in USD. The headline is the 24h median + of that gap. Companion panels report our own on-chain gas (approve plus + deposit, measured as the source-chain native balance delta) and the + realized output that landed on the destination. Sampled at $3 and $30 from + EU-West on the same conserving USDC triangle as the execution-latency + benchmark, so cost and latency are measured on the identical transactions. + +methodology: + - "Route: a self-conserving all-USDC triangle (Solana to Base, Base to Arbitrum, Arbitrum to Solana). Each bridge completes the full triangle before the next starts, so inventory returns to origin and only fees burn." + - "Slippage vs quote: realized fee minus quote-projected fee, in USD (bridge_quote_slippage_usd). Realized fee is amount sent minus amount actually received on-chain. Near zero means the settlement matched the quote; positive means it settled worse; negative means it beat the quote." + - "All-in gas: our own on-chain gas to execute (approve plus deposit), measured as the source-chain native balance delta before and after (bridge_exec_gas_usd). This is the cost the user bears on top of the bridge fee." + - "Realized output: the USD value that actually landed on the destination chain (bridge_realized_output_usd), waited for on-chain rather than taken from the quote." + - "Ticket sizes: $3 and $30. Small on purpose, since this measures the quote-to-settlement gap, not large-ticket depth." + - "Region: EU-West only. The execution node runs a single wallet, so a second origin would race the same inventory." + - "Cohort: Mobula, Relay and LI.FI, the bridges executed on all three legs. Quote-only providers are covered by the bridge-fee and bridge-quote-latency benchmarks." + +findings: + - "{{name:mobula}} settles within {{p50:mobula}} of its quote (median, 24 h) on executed USDC transfers." + - "{{name:relay}} settles within {{p50:relay}} of its quote." + - "{{name:lifi}} settles within {{p50:lifi}} of its quote." + +faq: + - q: "How is this different from the bridge fee benchmark?" + a: "The fee benchmark measures the cost a quote advertises. This one measures the cost that actually settled on-chain and subtracts what the quote promised, so it captures the gap a user only discovers after they commit. A bridge can quote cheap and settle worse, and only an execution benchmark surfaces that." + - q: "Why can the number be negative?" + a: "Because a bridge can settle better than it quoted. Slippage vs quote is realized fee minus quoted fee, so a solver that fills above the quoted output makes the figure negative. Near zero means the quote was honest; positive means the execution cost more than promised." + - q: "Does this include the gas I pay?" + a: "The headline is the bridge's own quote-to-settlement gap. Our own execution gas (approve plus deposit) is reported separately in the all-in gas panel, measured as the real native-token balance delta, because it is a cost the user bears on top of the bridge fee." + - q: "Where does this run from?" + a: "EU-West, on the same executed transactions as the bridge execution latency benchmark. The execution node holds a single wallet, so cost and latency are always measured on the identical transfer." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/bridge-monitor + +metric_panels: + - id: gas + label: All-in gas we paid + metric: avg by (bridge) (ocb:bridge_exec_gas_usd:avg_24h{region="eu-west"}) + label_key: bridge + unit: usd + higher_is_better: false + description: "Our own on-chain gas (approve plus deposit), measured as the source-chain native balance delta. The cost the user bears on top of the bridge fee." + - id: realized + label: Realized output on-chain + metric: avg by (bridge) (ocb:bridge_realized_output_usd:avg_24h{region="eu-west"}) + label_key: bridge + unit: usd + higher_is_better: true + description: "The USD value that actually landed on the destination chain, waited for on-chain rather than read from the quote." + +prometheus: + window: 24h + expected_freshness_seconds: 90000 + +providers: + - slug: mobula + name: Mobula + tag: Aggregator + intent layer + type: intent + formula: "24h median of execution slippage vs quote (realized fee minus quote-projected fee, USD) on real executed USDC-triangle transfers through Mobula from EU-West, at the selected corridor and ticket size." + queries: + p50: avg(ocb:bridge_quote_slippage_usd:p50_24h{bridge="mobula", region="eu-west"}) + p90: avg(ocb:bridge_quote_slippage_usd:p90_24h{bridge="mobula", region="eu-west"}) + p99: avg(ocb:bridge_quote_slippage_usd:p99_24h{bridge="mobula", region="eu-west"}) + mean: avg(ocb:bridge_quote_slippage_usd:mean_24h{bridge="mobula", region="eu-west"}) + sample_size: sum(count_over_time(bridge_quote_slippage_usd{bridge="mobula", region="eu-west"}[24h])) + series: ocb:bridge_quote_slippage_usd:p50_24h{bridge="mobula", region="eu-west"} + + - slug: relay + name: Relay + tag: Cross-chain relay + type: relay + formula: "24h median of execution slippage vs quote (realized fee minus quote-projected fee, USD) on real executed USDC-triangle transfers through Relay from EU-West, at the selected corridor and ticket size." + queries: + p50: avg(ocb:bridge_quote_slippage_usd:p50_24h{bridge="relay", region="eu-west"}) + p90: avg(ocb:bridge_quote_slippage_usd:p90_24h{bridge="relay", region="eu-west"}) + p99: avg(ocb:bridge_quote_slippage_usd:p99_24h{bridge="relay", region="eu-west"}) + mean: avg(ocb:bridge_quote_slippage_usd:mean_24h{bridge="relay", region="eu-west"}) + sample_size: sum(count_over_time(bridge_quote_slippage_usd{bridge="relay", region="eu-west"}[24h])) + series: ocb:bridge_quote_slippage_usd:p50_24h{bridge="relay", region="eu-west"} + + - slug: lifi + name: LI.FI + tag: Aggregator + type: aggregator + formula: "24h median of execution slippage vs quote (realized fee minus quote-projected fee, USD) on real executed USDC-triangle transfers through LI.FI from EU-West, at the selected corridor and ticket size." + queries: + p50: avg(ocb:bridge_quote_slippage_usd:p50_24h{bridge="lifi", region="eu-west"}) + p90: avg(ocb:bridge_quote_slippage_usd:p90_24h{bridge="lifi", region="eu-west"}) + p99: avg(ocb:bridge_quote_slippage_usd:p99_24h{bridge="lifi", region="eu-west"}) + mean: avg(ocb:bridge_quote_slippage_usd:mean_24h{bridge="lifi", region="eu-west"}) + sample_size: sum(count_over_time(bridge_quote_slippage_usd{bridge="lifi", region="eu-west"}[24h])) + series: ocb:bridge_quote_slippage_usd:p50_24h{bridge="lifi", region="eu-west"} + + - slug: near-intents + name: Near Intents + tag: Intent layer (NEAR) + type: intent + formula: "24h median of execution slippage vs quote (realized fee minus quote-projected fee, USD) on real executed USDC-triangle transfers through the Near Intents 1Click flow from EU-West, at the selected corridor and ticket size." + queries: + p50: avg(ocb:bridge_quote_slippage_usd:p50_24h{bridge="near-intents", region="eu-west"}) + p90: avg(ocb:bridge_quote_slippage_usd:p90_24h{bridge="near-intents", region="eu-west"}) + p99: avg(ocb:bridge_quote_slippage_usd:p99_24h{bridge="near-intents", region="eu-west"}) + mean: avg(ocb:bridge_quote_slippage_usd:mean_24h{bridge="near-intents", region="eu-west"}) + sample_size: sum(count_over_time(bridge_quote_slippage_usd{bridge="near-intents", region="eu-west"}[24h])) + series: ocb:bridge_quote_slippage_usd:p50_24h{bridge="near-intents", region="eu-west"} 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/coreum-rpc.yml b/benchmarks/coreum-rpc.yml index 9791f6636..da4bfa34d 100644 --- a/benchmarks/coreum-rpc.yml +++ b/benchmarks/coreum-rpc.yml @@ -111,6 +111,29 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="coreum", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="coreum", region="sgp"}[1h]) + - slug: nownodes + name: NOWNodes + tag: Public node, 110+ chains, 5 RPS free tier + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single Tendermint `status` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Coreum endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="coreum"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nownodes", chain="coreum"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="nownodes", chain="coreum"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="nownodes", chain="coreum"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="nownodes", chain="coreum"}) / sum(ocb:rpc_call:rate_24h{provider="nownodes", chain="coreum"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="nownodes", chain="coreum"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="coreum"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="coreum", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="coreum", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="coreum", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="coreum", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="coreum", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="coreum", region="sgp"}[1h]) + - slug: coreum-official name: Coreum Foundation tag: Coreum Foundation's official public RPC 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/ethereum-rpc.yml b/benchmarks/ethereum-rpc.yml index ce4e21741..98627c04d 100644 --- a/benchmarks/ethereum-rpc.yml +++ b/benchmarks/ethereum-rpc.yml @@ -87,6 +87,29 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="ethereum", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="ethereum", region="sgp"}[1h]) + - slug: nownodes + name: NOWNodes + tag: Public node, 110+ chains, 5 RPS free tier + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to NOWNodes public no-key Ethereum endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="ethereum"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nownodes", chain="ethereum"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="nownodes", chain="ethereum"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="nownodes", chain="ethereum"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="nownodes", chain="ethereum"}) / sum(ocb:rpc_call:rate_24h{provider="nownodes", chain="ethereum"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="nownodes", chain="ethereum"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="ethereum"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="ethereum", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="ethereum", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="ethereum", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="ethereum", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="ethereum", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="ethereum", region="sgp"}[1h]) + - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked diff --git a/benchmarks/fiat-onramp-cost.yml b/benchmarks/fiat-onramp-cost.yml new file mode 100644 index 000000000..0eb4ede49 --- /dev/null +++ b/benchmarks/fiat-onramp-cost.yml @@ -0,0 +1,181 @@ +# OpenChainBench. Bench № 262 + +slug: fiat-onramp-cost +number: "262" +title: Fiat on-ramp all-in cost, EUR to BTC premium over exchange mid +seo_title: "Fiat on-ramp cost 2026: MoonPay vs Transak, all-in premium over spot in bps" +seo_description: "{{best_name}} quotes the lowest all-in cost for 500 EUR of BTC by card at {{best_p50}} over the Kraken mid. Declared fee plus hidden spread for MoonPay and Transak, quoted every 5 minutes from France." +subtitle: What a French retail buyer actually pays per bitcoin, in basis points over the Kraken EUR mid, once the declared fee, the network fee and the spread hidden in the provider's rate are folded into one number. 500 EUR by card, quoted every 5 minutes. Lower is better. +category: On-ramps +status: live +metric: All-in premium over spot +unit: bps +higher_is_better: false +expected_n: 288 + +disclaimer: | + Quotes, not executions. The harness asks each provider's quote API what a 500 EUR card purchase would return and compares that to an exchange mid sampled in the same cycle. A real purchase can differ by KYC tier, first-order promotions, card issuer surcharges and settlement timing. No card is charged and no bank transfer is sent. + +seo_intro: | + Every on-ramp advertises a fee. Almost none of them tells you the + rate they apply that fee to. This benchmark folds both into one + number: the all-in premium, in basis points, between the effective + price you pay per unit of crypto and the Kraken EUR mid at the same + moment. A provider quoting a 1 percent fee on a rate that already + sits 1.5 percent above the market costs 250 bps, not 100. The bench + publishes the all-in number, the declared fee and the difference + between the two (the hidden spread) for every quote, so a reader can + see where the cost actually lives. One persona: a retail buyer in + France paying EUR, 500 EUR by card into bitcoin, quoted every five + minutes from an EU vantage; SEPA, USDC and ETH cells are measured on + the same grid and published as their own series. MoonPay and Transak + are quoted today; Ramp, and the aggregators Onramper and Meld, join + the table as their API keys are granted. Direct providers and + aggregators are two cohorts and are never ranked against each other: + an aggregator's row is the best member ramp it returned, which is a + different product from a single provider's own price. Lower is better. + +abstract: | + The harness quotes each provider on a fixed grid every five minutes: + three assets (BTC on Bitcoin, USDC on Base with Arbitrum as fallback, + ETH on Ethereum), two notionals (100 and 500 EUR), two payment + methods (card and SEPA). Each answer is normalized into fiat_in (what + the buyer pays), crypto_out (what lands in the wallet) and the fees + the provider declares. The headline value is the 24 hour median of + all_in_premium_bps = (fiat_in / crypto_out minus spot) / spot times + 10 000 on the BTC, card, 500 EUR cell, with spot the Kraken EUR mid + (best bid plus best ask over two) fetched in the same cycle. A Pyth + Hermes cross-reference (USD feeds divided by EUR/USD) is recorded on + every quote under a separate label and the divergence between the two + references is published so the reader can judge how much the choice + of reference moves the result. The declared fee (provider fee plus + network fee plus partner fee, over fiat_in) and the hidden spread + (all-in minus declared) are emitted alongside. Aggregators are + scored on the best member ramp they return and tagged as a separate + cohort. + +methodology: + - "Persona. One buyer profile: country France, fiat EUR, region eu-west. Providers whose API takes a country code receive FR (Transak, Ramp, Onramper, Meld); providers that geolocate the request are quoted from an EU vantage (MoonPay, Mercuryo). The country_source label on every sample says which." + - "Headline cell. BTC on Bitcoin, card, 500 EUR, premium measured against the Kraken EUR mid. Card and BTC are the one method and the one asset every provider in the cohort quotes, so every row is scored on the same cell. The other grid cells (USDC on Base or Arbitrum, ETH, SEPA, 100 EUR) are emitted with their own labels and will become drill-down tabs once the site renders non-chain dimensions." + - "Metric. all_in_premium_bps = (fiat_in / crypto_out minus spot) / spot times 10 000. fiat_in is the total the buyer pays including every fee the provider adds; crypto_out is the amount the provider says will be delivered. Positive means the buyer pays above the exchange mid. A value near zero means the provider's rate plus fees matched the mid." + - "Spot reference. Kraken public Ticker, EUR pairs, mid of best bid and ask, fetched in the same cycle as the quotes (within two seconds). A second reference from Pyth Hermes (BTC/USD, ETH/USD, USDC/USD divided by EUR/USD) is recorded on every quote under spot_ref=pyth and the gap between the two is published as spot_reference_divergence_bps. Kraken is the headline because it has no FX leg." + - "Declared versus hidden. declared_fee_bps = (provider fee plus network fee plus partner fee) / fiat_in times 10 000 as the provider itemizes them. hidden_spread_bps = all_in minus declared, the part of the cost that lives in the rate rather than in the fee line." + - "Cohorts. Direct providers (MoonPay and Transak today; Ramp and Mercuryo once keyed) are one cohort. Aggregators (Onramper, Meld, once keyed) are another: one request returns one quote per member ramp, each member sample is labelled provider=member and via=aggregator, and the aggregator's row is the minimum across its members. The two cohorts are not ranked against each other." + - "Cadence. Every 5 minutes with a plus or minus 10 percent jitter, 12 cells per provider per cycle, at most 2 requests in flight per provider. Ramp documents 100 requests per minute per IP; the harness stays two orders of magnitude below that. A 4xx is never retried; a 5xx or timeout is retried once." + - "Failures. A provider that answers without a usable quote counts as success 0 on that cell and its gauges are deleted rather than frozen. A provider whose key is not configured is skipped, not failed. Errors are counted by reason: no_quote, http_4xx, http_5xx, rate_limited, timeout, network, parse." + - "Sample size. The harness increments a counter per usable quote; the table reads increase() over 24h so the count is quote events, not Prometheus scrapes of a gauge. Expected 288 per cell per day." + - "Reproducibility. Every sample carries the SHA-256 of the raw provider response in the harness log. The adapters are written against each provider's documented schema; the parameters not yet exercised with a live key are listed in harnesses/fiat-onramp-cost/CHECKLIST.md and stay marked UNVERIFIED until they are." + - "Not measured yet. Ramp, Onramper and Meld have adapters in the harness and enter the table when their keys are granted (requested 2026-09-14). Coinbase Onramp is excluded pending a read of the CDP terms on benchmarking. Apple Pay and Google Pay cells need a wallet session and are not quotable server-side. ATM operators publish no quote API. Banxa and Alchemy Pay reach the bench only through the aggregators in this version." + +findings: + - "{{best_name}} currently quotes the lowest all-in premium on the headline cell at {{best_p50}} over the Kraken EUR mid (24h median, 500 EUR by card into BTC)." + - "{{name:moonpay}} sits at {{p50:moonpay}}. MoonPay prices by request IP, so the number is what an EU vantage sees, not a country parameter." + - "{{name:transak}} sits at {{p50:transak}}. Transak takes the country as a parameter and itemizes the network fee separately from its own fee." + - "Read the hidden spread next to the declared fee: a provider whose declared fee looks low can still sit high on the all-in number when its rate carries the margin." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/fiat-onramp-cost + +prometheus: + window: 24h + expected_freshness_seconds: 900 + +faq: + - q: "What is the all-in premium?" + a: "The gap, in basis points, between the effective price you pay per unit of crypto (fiat paid divided by crypto received) and the exchange mid at the same moment. It folds the declared fee, the network fee and whatever margin sits in the provider's rate into one number. 100 bps means you paid 1 percent above the mid." + - q: "Why compare against Kraken and not the provider's own market rate?" + a: "The provider's market rate is the thing being tested. Some on-ramps quote a fair mid and charge a visible fee; others quote a rate already above the market and charge a small fee. Only an external reference sampled at the same time shows the total. Kraken's EUR pairs are keyless, deep and have no FX leg. Pyth is recorded as a second reference so the reader can check the choice does not drive the result." + - q: "Why is the headline card and BTC, not SEPA and USDC?" + a: "Card is the one payment method every on-ramp in the cohort quotes, and BTC the one asset every one of them lists, so the headline scores every row on the same cell. SEPA is cheaper where it exists (MoonPay quotes it, Transak's quote endpoint does not) and USDC on Base is not quotable on every key. Both are measured on the same grid, emitted with their own labels, and will get a tab once the site renders them; for now they are readable directly in the Prometheus series." + - q: "Why are aggregators a separate cohort?" + a: "An aggregator's answer is the best of several member ramps, sometimes including the direct providers in the other cohort at a different partner fee. Ranking the best member of five against a single provider's own price would compare two different products. The aggregator row shows what the aggregator would route you to; the direct rows show what each provider quotes on its own." + - q: "Is this a real purchase?" + a: "No. The harness calls each provider's quote endpoint with a fixed notional and reads the answer. No payment is made. Real orders can differ by KYC tier, promotions, issuer surcharges and settlement timing, which is why the disclaimer sits above the table." + - q: "Why does a provider show no data?" + a: "Three reasons. The provider's key is not configured on the harness, so it is skipped. The provider does not list the headline cell. Or the provider answered without a usable quote, in which case its success rate drops and the cell is purged rather than left at a stale value." + +panel_main_description: "All-in premium over the Kraken EUR mid for 500 EUR of BTC by card: declared fee, network fee and the spread hidden in the rate, folded into one number. 24h median. Lower is better." + +# One panel per grid cell (24h median of the same all-in premium) plus the +# fee split of the headline cell. Cells a provider does not quote (Transak +# has no SEPA, MoonPay's test key has no USDC) render as no data. +metric_panels: + - id: declared_fee + label: Declared fee + label_key: provider + metric: quantile_over_time(0.50, onramp_quote_declared_fee_bps{via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500"}[24h]) + unit: bps + description: "The part of the headline cost the provider itemizes: its own fee plus network fee plus partner fee, over the fiat paid. What the checkout screen shows." + - id: hidden_spread + label: Hidden spread + label_key: provider + metric: quantile_over_time(0.50, onramp_quote_hidden_spread_bps{via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",spot_ref="kraken_eur"}[24h]) + unit: bps + description: "All-in premium minus declared fee: the margin that lives in the provider's exchange rate rather than on the fee line." + - id: btc_sepa_500 + label: BTC, SEPA, 500 EUR + label_key: provider + metric: quantile_over_time(0.50, onramp_quote_all_in_premium_bps{via="direct",asset="btc",network="bitcoin",payment_method="sepa",notional="500",spot_ref="kraken_eur"}[24h]) + unit: bps + description: "Same buyer, same 500 EUR of BTC, paid by SEPA bank transfer instead of card. Transak's quote endpoint does not offer SEPA." + - id: btc_card_100 + label: BTC, card, 100 EUR + label_key: provider + metric: quantile_over_time(0.50, onramp_quote_all_in_premium_bps{via="direct",asset="btc",network="bitcoin",payment_method="card",notional="100",spot_ref="kraken_eur"}[24h]) + unit: bps + description: "The headline cell at a 100 EUR ticket: fixed minimum fees weigh more on small orders." + - id: eth_card_500 + label: ETH, card, 500 EUR + label_key: provider + metric: quantile_over_time(0.50, onramp_quote_all_in_premium_bps{via="direct",asset="eth",network="ethereum",payment_method="card",notional="500",spot_ref="kraken_eur"}[24h]) + unit: bps + description: "500 EUR of ETH on Ethereum mainnet by card, premium over the Kraken ETH/EUR mid." + - id: eth_sepa_500 + label: ETH, SEPA, 500 EUR + label_key: provider + metric: quantile_over_time(0.50, onramp_quote_all_in_premium_bps{via="direct",asset="eth",network="ethereum",payment_method="sepa",notional="500",spot_ref="kraken_eur"}[24h]) + unit: bps + description: "500 EUR of ETH on Ethereum mainnet by SEPA. MoonPay only, for now." + - id: usdc_card_500 + label: USDC, card, 500 EUR + label_key: provider + metric: quantile_over_time(0.50, onramp_quote_all_in_premium_bps{via="direct",asset="usdc",network="base",payment_method="card",notional="500",spot_ref="kraken_eur"}[24h]) + unit: bps + description: "500 EUR of USDC on Base by card, premium over the Kraken USDC/EUR mid. Transak only until MoonPay is quoted with a production key." + +ledger_columns: + - { label: "All-in", slot: p50 } + - { label: "Declared fee", panel: declared_fee } + - { label: "Hidden spread", panel: hidden_spread } + - { label: "BTC SEPA", panel: btc_sepa_500 } + - { label: "ETH card", panel: eth_card_500 } + - { label: "USDC card", panel: usdc_card_500 } + +dimensions: {} + +providers: + - slug: moonpay + name: MoonPay + tag: Direct on-ramp, card and SEPA, pricing by request IP + type: protocol + formula: "24h median of the all-in premium over Kraken EUR mid on the headline cell (BTC, card, 500 EUR, France), quoted directly through MoonPay's own API." + queries: + p50: quantile_over_time(0.50, onramp_quote_all_in_premium_bps{provider="moonpay",via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",fiat="EUR",country="FR",region="eu-west",spot_ref="kraken_eur"}[24h]) + p90: quantile_over_time(0.90, onramp_quote_all_in_premium_bps{provider="moonpay",via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",fiat="EUR",country="FR",region="eu-west",spot_ref="kraken_eur"}[24h]) + p99: quantile_over_time(0.99, onramp_quote_all_in_premium_bps{provider="moonpay",via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",fiat="EUR",country="FR",region="eu-west",spot_ref="kraken_eur"}[24h]) + mean: avg_over_time(onramp_quote_all_in_premium_bps{provider="moonpay",via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",fiat="EUR",country="FR",region="eu-west",spot_ref="kraken_eur"}[24h]) + success: avg_over_time(onramp_quote_success{provider="moonpay",via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",fiat="EUR",country="FR",region="eu-west"}[24h]) + sample_size: increase(onramp_quote_samples_total{provider="moonpay",via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",fiat="EUR",country="FR",region="eu-west"}[24h]) + series: onramp_quote_all_in_premium_bps{provider="moonpay",via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",fiat="EUR",country="FR",region="eu-west",spot_ref="kraken_eur"} + - slug: transak + name: Transak + tag: Direct on-ramp, card (no SEPA on the quote endpoint), country passed as a parameter + type: protocol + formula: "24h median of the all-in premium over Kraken EUR mid on the headline cell (BTC, card, 500 EUR, France), quoted directly through Transak's own API." + queries: + p50: quantile_over_time(0.50, onramp_quote_all_in_premium_bps{provider="transak",via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",fiat="EUR",country="FR",region="eu-west",spot_ref="kraken_eur"}[24h]) + p90: quantile_over_time(0.90, onramp_quote_all_in_premium_bps{provider="transak",via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",fiat="EUR",country="FR",region="eu-west",spot_ref="kraken_eur"}[24h]) + p99: quantile_over_time(0.99, onramp_quote_all_in_premium_bps{provider="transak",via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",fiat="EUR",country="FR",region="eu-west",spot_ref="kraken_eur"}[24h]) + mean: avg_over_time(onramp_quote_all_in_premium_bps{provider="transak",via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",fiat="EUR",country="FR",region="eu-west",spot_ref="kraken_eur"}[24h]) + success: avg_over_time(onramp_quote_success{provider="transak",via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",fiat="EUR",country="FR",region="eu-west"}[24h]) + sample_size: increase(onramp_quote_samples_total{provider="transak",via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",fiat="EUR",country="FR",region="eu-west"}[24h]) + series: onramp_quote_all_in_premium_bps{provider="transak",via="direct",asset="btc",network="bitcoin",payment_method="card",notional="500",fiat="EUR",country="FR",region="eu-west",spot_ref="kraken_eur"} 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..2baab1578 100644 --- a/benchmarks/keyed-rpc-robinhood.yml +++ b/benchmarks/keyed-rpc-robinhood.yml @@ -2,10 +2,10 @@ 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. +title: "Fastest Robinhood Chain RPC with API key, measured from Singapore: Chainstack vs Alchemy vs QuickNode" +seo_title: "Fastest Robinhood Chain RPC API key 2026 from Singapore: Chainstack vs Alchemy vs QuickNode latency" +seo_description: "Live Robinhood Chain RPC latency measured from Singapore: Chainstack, Alchemy, QuickNode keyed endpoints probed every 60s. Scoped to that probe origin." +subtitle: eth_getBlockByNumber latency against keyed Robinhood Chain endpoints (Chainstack, Alchemy, QuickNode), probed every 60 seconds from Singapore. Every figure is scoped to that origin. category: RPCs status: live metric: RPC latency @@ -18,39 +18,48 @@ 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 Singapore. Chainstack posts the lowest median + from that origin. Round-trip latency depends on where the probe sits relative to each provider's + serving node, so this ranking is scoped to the Singapore probe and is not a chain-wide claim. + 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 Singapore, 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 Singapore. Every figure on this page is scoped to that origin; provider ranking is origin-dependent. 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." +# Single-vantage bench: the by-region grid and any region-aware surface +# show only the Singapore probe. The us-east series still exists in +# Prometheus (staging exposes it via region tabs) but prod does not +# surface it. +aggregate_filters: + region: sgp + 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: "From the Singapore probe: {{name:chainstack}} at {{p50:chainstack}} p50 (24h), the lowest-latency keyed provider for Robinhood Chain as measured from that origin." - 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: "Where does this benchmark probe from?" + a: "From Singapore. Round-trip latency to a keyed RPC depends on where the probe sits relative to the provider's serving node, so every figure on this page is scoped to that origin: read it as fastest from Singapore, not fastest everywhere. Additional probe regions may be added as the chain matures." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-keyed-latency @@ -62,38 +71,38 @@ 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 from Singapore 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",region="sgp"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="chainstack",chain="robinhood",tier="keyed",region="sgp"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="chainstack",chain="robinhood",tier="keyed",region="sgp"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="chainstack",chain="robinhood",tier="keyed",region="sgp"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="chainstack",chain="robinhood",tier="keyed",region="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="chainstack",chain="robinhood",tier="keyed",region="sgp"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="chainstack",chain="robinhood",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="chainstack",chain="robinhood",tier="keyed",region="sgp"}[1h])) - slug: 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 from Singapore 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",region="sgp"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="quicknode",chain="robinhood",tier="keyed",region="sgp"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="quicknode",chain="robinhood",tier="keyed",region="sgp"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="quicknode",chain="robinhood",tier="keyed",region="sgp"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="quicknode",chain="robinhood",tier="keyed",region="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="quicknode",chain="robinhood",tier="keyed",region="sgp"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="quicknode",chain="robinhood",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="quicknode",chain="robinhood",tier="keyed",region="sgp"}[1h])) - slug: 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 from Singapore 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",region="sgp"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="alchemy",chain="robinhood",tier="keyed",region="sgp"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="alchemy",chain="robinhood",tier="keyed",region="sgp"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="alchemy",chain="robinhood",tier="keyed",region="sgp"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="alchemy",chain="robinhood",tier="keyed",region="sgp"}) / sum(ocb:rpc_call:rate_24h{provider="alchemy",chain="robinhood",tier="keyed",region="sgp"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="alchemy",chain="robinhood",tier="keyed",region="sgp"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="alchemy",chain="robinhood",tier="keyed",region="sgp"}[1h])) diff --git a/benchmarks/lukso-rpc.yml b/benchmarks/lukso-rpc.yml new file mode 100644 index 000000000..bb7092c04 --- /dev/null +++ b/benchmarks/lukso-rpc.yml @@ -0,0 +1,127 @@ +# OpenChainBench. Bench No 260 + +slug: lukso-rpc +number: "260" +title: Fastest free LUKSO RPC, live no-key endpoint latency +seo_title: "Fastest free LUKSO RPC 2026" +seo_description: "{{best_name}} leads free LUKSO RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 3 no-key providers measured every 60s from 3 regions." +subtitle: HTTP round-trip latency for eth_getBlockByNumber against every available public LUKSO endpoint, audited every 60 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + LUKSO is an EVM layer-1 (chain ID 42) built around Universal Profiles and the LSP standards. It speaks standard Ethereum JSON-RPC, so the canonical height probe is eth_getBlockByNumber('latest'). Three providers serve LUKSO mainnet with no API key: the LUKSO Foundation's official RPC, NOWNodes (public no-key endpoint, 5 requests per second) and thirdweb's public edge. All three were live-verified at launch, returning the same chain tip. + +abstract: | + Per-chain member of the RPC latency cluster, extended to LUKSO. We probe every + public no-key endpoint with a single eth_getBlockByNumber call every 60 seconds from three + regions (us-east, eu-west, Singapore) and record wall-clock round-trip latency. Only ok + responses (HTTP 200 with a usable block height, not stale) contribute to the distribution; + p50/p90/p99 are computed via Prometheus quantile_over_time over a rolling 24-hour window. + +methodology: + - "Probe: a single eth_getBlockByNumber('latest', false) JSON-RPC POST every 60 seconds per provider from us-east, eu-west and Singapore. Latency is the client-side wall-clock round trip." + - "Classification: ok (HTTP 200 with a usable block number), http_err, jsonrpc_err, stale (block more than 20 behind the cross-provider tip), timeout. Only ok responses contribute latency." + - "Providers: LUKSO Foundation (rpc.mainnet.lukso.network), NOWNodes (public-lukso.nownodes.io, no key, 5 RPS), thirdweb (42.rpc.thirdweb.com)." + - "Aggregation: p50/p90/p99 via Prometheus quantile_over_time over 24h; the headline cites the cross-region figure and the region tabs re-scope it." + +findings: + - "{{best_name}} leads at {{best_p50}} (eth_getBlockByNumber p50, 24h) across {{provider_count}} no-key LUKSO endpoints." + - "LUKSO targets a short EVM block time, so a stale tip is uncommon and the ranking is driven by each provider's regional edge placement." + - "{{name:nownodes}} serves LUKSO from its public no-key endpoint at {{p50:nownodes}} (p50, 24h), rate-limited to 5 requests per second." + +faq: + - q: "Which free LUKSO RPC is fastest?" + a: "Per the live data above: {{best_name}} at {{best_p50}} (eth_getBlockByNumber p50, 24h) across the no-key providers measured here." + - q: "How is LUKSO RPC latency measured?" + a: "A single eth_getBlockByNumber call every 60 seconds from three regions (us-east, eu-west, Singapore), recording the wall-clock round trip. Only successful, non-stale responses count." + - q: "Do these endpoints need an API key?" + a: "No. LUKSO Foundation, NOWNodes and thirdweb all serve LUKSO mainnet JSON-RPC with no key. NOWNodes' public endpoint is rate-limited to 5 requests per second." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + expected_freshness_seconds: 5400 + +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="lukso"}) + +dimensions: + region: + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: lukso-official + name: LUKSO Foundation + tag: Official LUKSO mainnet RPC, no key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` sent every 60s from 3 regions (us-east + eu-west + sgp) to LUKSO Foundation's no-key LUKSO endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lukso-official", chain="lukso"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="lukso-official", chain="lukso"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="lukso-official", chain="lukso"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="lukso-official", chain="lukso"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="lukso-official", chain="lukso"}) / sum(ocb:rpc_call:rate_24h{provider="lukso-official", chain="lukso"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="lukso-official", chain="lukso"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="lukso-official", chain="lukso"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lukso-official", chain="lukso", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="lukso-official", chain="lukso", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lukso-official", chain="lukso", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="lukso-official", chain="lukso", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lukso-official", chain="lukso", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="lukso-official", chain="lukso", region="sgp"}[1h]) + + - slug: nownodes + name: NOWNodes + tag: Public node, 110+ chains, 5 RPS free tier + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` sent every 60s from 3 regions (us-east + eu-west + sgp) to NOWNodes's no-key LUKSO endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="lukso"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nownodes", chain="lukso"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="nownodes", chain="lukso"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="nownodes", chain="lukso"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="nownodes", chain="lukso"}) / sum(ocb:rpc_call:rate_24h{provider="nownodes", chain="lukso"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="nownodes", chain="lukso"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="lukso"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="lukso", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="lukso", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="lukso", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="lukso", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", chain="lukso", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", chain="lukso", region="sgp"}[1h]) + + - slug: thirdweb + name: thirdweb + tag: Multi-chain RPC edge, no-key public endpoint + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` sent every 60s from 3 regions (us-east + eu-west + sgp) to thirdweb's no-key LUKSO endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="thirdweb", chain="lukso"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="thirdweb", chain="lukso"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="thirdweb", chain="lukso"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="thirdweb", chain="lukso"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="thirdweb", chain="lukso"}) / sum(ocb:rpc_call:rate_24h{provider="thirdweb", chain="lukso"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="thirdweb", chain="lukso"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="thirdweb", chain="lukso"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="thirdweb", chain="lukso", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="thirdweb", chain="lukso", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="thirdweb", chain="lukso", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="thirdweb", chain="lukso", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="thirdweb", chain="lukso", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="thirdweb", chain="lukso", 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/perp-amm-volume-share.yml b/benchmarks/perp-amm-volume-share.yml index 6aa0c8364..15b166679 100644 --- a/benchmarks/perp-amm-volume-share.yml +++ b/benchmarks/perp-amm-volume-share.yml @@ -27,8 +27,8 @@ abstract: | The bench polls six AMM-style perp DEX venues every 5 minutes and exposes a rolling 24h USD notional gauge per venue. Sources: GMX V2 via the public Subsquid squid (gmx.squids.live) summing Arbitrum and - Avalanche 24h volume buckets; Gains backend REST API at - stats.gains.trade; Ostium via the Ormi subgraph trade entity on Base; + Avalanche 24h volume buckets; Gains backend volume-mix API + (auto + direct notional, DefiLlama perimeter); Ostium via the Ormi subgraph trade entity on Base; Pacifica onchain Solana program state; SynFutures and KiloEx via CoinGecko's public derivatives exchange endpoint (trade_volume_24h_btc converted to USD via BTC spot price). Each value carries a freshness @@ -37,7 +37,7 @@ abstract: | methodology: - "Cadence: every 5 minutes per venue in parallel, 15 second timeout per request." - "GMX V2: public Subsquid squid at gmx.squids.live for Arbitrum and Avalanche; queries the most recent 1d volume bucket where timestamp >= now-86400, sums both chains in USD. volumeUsd is a BigInt scaled 1e30; dividing by 1e30 yields USD." - - "Gains: stats.gains.trade/volume REST endpoint; totalVolume field gives rolling 24h notional in USD across all chains." + - "Gains: Gains backend volume-mix API (backend-global.gains.trade), auto + direct notional per UTC day (opens and closes at full notional, resizes at traded delta, the same perimeter as DefiLlama and bench 266) over Arbitrum, Base, Polygon and MegaETH; rolling 24h = today so far plus the remaining share of yesterday." - "Ostium: Ormi-hosted subgraph (api.subgraph.ormilabs.com); sums trade.notional for all trades opened in the last 24h (timestamp_gte=now-86400); notional is in USDC internal units (1e6 per USD)." - "Pacifica: onchain reads on the Solana program state for trailing 24h taker notional." - "SynFutures: CoinGecko public derivatives exchange endpoint (id=synfutures); trade_volume_24h_btc multiplied by BTC/USD spot price from CoinGecko simple/price. SynFutures uses the oAMM (Oyster AMM) model on Base and Arbitrum." @@ -90,7 +90,7 @@ providers: - slug: gains name: Gains tag: Synthetic leveraged trading on Arbitrum and Polygon - formula: "totalVolume from stats.gains.trade/volume REST API, rolling 24h USD notional across all chains; headline is the 24h time average." + formula: "auto + direct notional from the Gains backend volume-mix API (DefiLlama perimeter), today plus the remaining share of yesterday as rolling 24h USD, Arbitrum, Base, Polygon and MegaETH summed; headline is the 24h time average." queries: p50: avg_over_time(perp_venue_volume_24h_usd{venue="gains"}[24h]) p90: quantile_over_time(0.90, perp_venue_volume_24h_usd{venue="gains"}[24h]) diff --git a/benchmarks/perp-daily-volume.yml b/benchmarks/perp-daily-volume.yml new file mode 100644 index 000000000..9983ac698 --- /dev/null +++ b/benchmarks/perp-daily-volume.yml @@ -0,0 +1,299 @@ +# OpenChainBench. Bench 266 + +slug: perp-daily-volume +number: "266" +title: "Perp DEX daily volume 2026: Hyperliquid, Aster, Lighter, GMX, Gains, dYdX by UTC day, backfilled" +seo_title: "Perp DEX daily volume 2026: Hyperliquid vs Aster vs Lighter vs GMX vs Gains vs dYdX" +seo_description: "{{best_name}} led the last closed UTC day at {{best_p50}} of perp notional. Daily perpetual DEX volume for {{count}} venues, DeFiLlama day buckets rebuilt from each venue's own data, with 7 and 30 day totals and a year of history." +subtitle: Perpetual futures notional per closed UTC day for 11 rows across 10 decentralized venues (Hyperliquid's HIP-3 dexs are their own row), the same day buckets DeFiLlama's derivatives page uses, read from the sources its adapters read. Headline is the last closed day; 7 day and 30 day totals and cohort share sit next to it. History backfilled over a year and refreshed hourly. + +category: Trading +status: live +metric: Daily perp volume +unit: usd +higher_is_better: true + +disclaimer: | + Volume is self-reported by each venue's public data surface and counted per UTC day, one side of each trade. Perimeters differ slightly per venue and are stated in the ledger notes. A closed day can be restated by a venue for one to two days; the harness re-reads the last three days every hour. Not a measure of organic flow: incentive programs and wash trading inflate notional on every venue. + +seo_intro: | + Rolling 24 hour volume, the number most venues put on their landing page, cannot be compared + across venues or across days: every venue's window ends at a different second and a single + large hour moves it twice. Daily volume on closed UTC days is the convention DeFiLlama, Dune + dashboards and the venues' own weekly reports use, and it is what "X flipped Y in volume" + claims are made on. This benchmark rebuilds that series for Hyperliquid (main dex and HIP-3 + dexs as two rows), Aster, Lighter, GMX V2, Gains, dYdX v4, Paradex, Extended, Orderly and + Aevo, from the same sources DeFiLlama's open-source adapters read (squids, exchange + statistics endpoints, per-market daily candles, the Gains Dune view), so the numbers line up + with defillama.com on closed days while the full history stays free to reproduce. On 13 + September 2026 nine of the ten venues matched DeFiLlama's page within one percent; the + exceptions are documented in the FAQ. Each venue gets its last closed day, its 7 and 30 day + totals, its share of the cohort, and a year of daily points you can chart or download. + +abstract: | + The perp-volume-history harness keeps one number per venue per UTC day. For each venue it + reads the upstream DeFiLlama's dimension-adapters read for that venue: the GMX V2 synthetics + squids (marginVolumeUsd, perps only), the Gains backend volume-mix endpoint (trade + notional, resizes at their traded delta; the Dune view DeFiLlama reads when a Dune key is + configured), Hyperliquid info 1h candles per perp market (base volume times hourly mean price; + the main dex and the HIP-3 dexs are published as two rows), Aster fapi 1d klines quote volume, + Lighter 1d candles per perp market on both its deployments (zkLighter and Robinhood chain), + dYdX v4 indexer 1DAY candles per market, Paradex's public daily volume card, + Extended's per-day exchange statistics, Orderly's daily_stats and Aevo's statistics endpoint. + With a DeFiLlama Pro key configured, every venue switches to DeFiLlama's own dailyVolume + series. On boot the harness backfills 400 days where the source allows it, then every hour it + re-reads the last three closed days and publishes the last closed day, the 7 day and 30 day + sums (only when every day of the window is present), the venue's share of the cohort on each + window, and the depth of stored history. The full daily series is served as JSON to the site's + compare and venue pages. + +methodology: + - "Day bucket: UTC midnight to midnight, the DeFiLlama convention. The headline is the last fully closed UTC day (D-1); the current day is never shown." + - "One side per trade: every source counts taker notional once. Extended reports both sides per market, halved here as in the DeFiLlama adapter." + - "GMX V2: gmx.squids.live volumeInfos period 1d, marginVolumeUsd scaled by 1e-30, summed over Arbitrum, Avalanche, Botanix and MegaETH. Swap volume is excluded, so this is the GMX V2 Perps line, not the GMX parent." + - "Gains: the published series is the Dune view dune.gains.result_g_trade_stats_defi_llama, the source DeFiLlama's own adapter reads (Arbitrum, Polygon, Base, MegaETH; ApeChain is not in the view), queried at most every 12 hours." + - "Gains between two Dune runs: the newest day comes from backend-global.gains.trade /api/volume-mix, autoVolumeUsd + directVolumeUsd (opens and closes at full notional, resizes at the traded delta, governance-only rows excluded). It matched the view within 0.1 percent over the 60 days checked; the gap between the two is published as a divergence gauge." + - "Hyperliquid: info candleSnapshot 1h per market; hourly notional = base volume x (open + high + low + close) / 4, summed per day. Checked against the exchange's own dayNtlVlm at UTC midnight on 2026-09-13: 3.361B from candles vs 3.359B native." + - "Hyperliquid rows: the main dex is the Hyperliquid row; the HIP-3 dexs listed by perpDexs (xyz, io, mkts, para, km, flx...) are builder-deployed markets and get their own row, Hyperliquid HIP-3. DeFiLlama's single hyperliquid-perps figure sits between the two because its private indexer covers HIP-3 only partially." + - "Aster: fapi.asterdex.com 1d klines quoteVolume summed over TRADING perpetual symbols. Lighter: 1d candles quote volume summed over perp markets (market_id below 2048) on zkLighter mainnet and on the Robinhood chain deployment (api.rh.lighter.xyz), the two DeFiLlama's Lighter parent adds. dYdX v4: indexer 1DAY candles usdVolume summed over all perpetual markets." + - "Paradex: the public Metabase daily card, PERP_VOLUME column (options and spot excluded). Extended: exchange/stats/trading per day on Starknet, plus the Ethereum deployment until 2025-12-29. Orderly: md/volume/daily_stats. Aevo: statistics with instrument_type PERPETUAL per day." + - "Windows: 7d and 30d are sums of closed days ending on D-1 and are published only when every day in the window is present, so a partial backfill never reads as a low week. Share is the venue's window total over the sum of the cohort on the same window." + - "Refresh: full backfill on first boot (400 days, bounded by each source's start date; Hyperliquid candles reach back about 340 days), then every hour every venue re-reads its last three closed days in parallel so restated days converge. A venue whose source rate-limits (dYdX's indexer) or whose host is down (Extended's retired Ethereum deployment) is skipped for that sweep and keeps its stored days. Prometheus scrapes :2112/metrics every 60s." + - "Not covered yet: edgeX, GRVT, Pacifica, Vertex, Backpack, Jupiter, Drift and Ostium. The first three follow the same per-market candle pattern and are next; Drift and Ostium exist on DeFiLlama only through Dune queries." + +findings: + - "{{best_name}} led the last closed UTC day at {{best_p50}} of perp notional across {{count}} measured venues." + - "{{name:gains}} printed {{p50:gains}} and {{name:gmx}} {{p50:gmx}} on the last closed day. The 7d and 30d columns show whether that ordering holds beyond a single day." + - "{{name:hyperliquid}} at {{p50:hyperliquid}}, {{name:aster}} at {{p50:aster}} and {{name:lighter}} at {{p50:lighter}} carry most of the cohort's notional; their share columns add up to the bulk of the field." + - "{{name:hyperliquid-hip3}} printed {{p50:hyperliquid-hip3}} on the last closed day: builder-deployed markets on Hyperliquid, mostly stocks, indices and commodities on the xyz dex, counted apart from the exchange's own book." + - "{{name:dydx}} sits at {{p50:dydx}}, {{name:paradex}} at {{p50:paradex}}, {{name:extended}} at {{p50:extended}}, {{name:orderly}} at {{p50:orderly}} and {{name:aevo}} at {{p50:aevo}} on the last closed day." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/perp-volume-history + +prometheus: + window: 24h + freshness_metric: perp_daily_volume_health + expected_freshness_seconds: 7200 + +metric_panels: + - id: vol_7d + label: Volume 7d + label_key: venue + metric: perp_daily_volume_usd{window="7d"} + unit: usd + higher_is_better: true + tab: false + description: "Sum of the last 7 closed UTC days." + - id: vol_30d + label: Volume 30d + label_key: venue + metric: perp_daily_volume_usd{window="30d"} + unit: usd + higher_is_better: true + description: "Sum of the last 30 closed UTC days." + - id: share_1d + label: Share (day) + label_key: venue + metric: perp_daily_volume_share_pct{window="1d"} + unit: pct + higher_is_better: true + tab: false + description: "Share of the cohort's notional on the last closed day." + - id: share_7d + label: Share 7d + label_key: venue + metric: perp_daily_volume_share_pct{window="7d"} + unit: pct + higher_is_better: true + tab: false + description: "Share of the cohort's notional over the last 7 closed days." + - id: share_30d + label: Share 30d + label_key: venue + metric: perp_daily_volume_share_pct{window="30d"} + unit: pct + higher_is_better: true + description: "Share of the cohort's notional over the last 30 closed days." + - id: history_days + label: Days of history + label_key: venue + metric: perp_daily_volume_history_days + unit: count + higher_is_better: true + tab: false + description: "How many UTC days the harness holds for the venue." + +ledger_columns: + - { label: "Volume", slot: p50, windows: { 7d: vol_7d, 30d: vol_30d } } + - { label: "Share", panel: share_1d, windows: { 7d: share_7d, 30d: share_30d } } + - { label: "Days of history", panel: history_days } + +stacked_share: true + +# Gauges move once per UTC day: sub-day chart ranges would show one point +# at "now", so the chart opens on 30 days and the ledger's base window is +# the last closed day rather than "24h". +chart: + min_range: 7d + default_range: 30d + window_label: last closed day + +faq: + - q: "How is this different from bench 041, perp DEX volume share?" + a: "Bench 041 polls each venue's live rolling 24 hour figure every five minutes and ranks the time average. This bench counts closed UTC days, which is what DeFiLlama, weekly reports and flip claims use, and keeps a year of daily history per venue. The two agree on quiet days and disagree whenever a big hour sits on one side of a midnight boundary." + - q: "Are these the DeFiLlama numbers?" + a: "Same day buckets, same upstream sources. Checked on 2026-09-13: GMX V2, Extended, Orderly, Aevo and Paradex match DeFiLlama's page to the last digit, Aster and dYdX within one percent, Lighter once both deployments are summed. Gains is read from the same Dune view DeFiLlama reads, but re-read hourly: DeFiLlama freezes what it read at day close and Gains' view keeps restating for a day or two, so DeFiLlama's page runs 8 to 20 percent under the view on recent days. Hyperliquid is the one deliberate split: the main dex row matches the exchange's own figure and HIP-3 dexs are a separate row, while DeFiLlama's single line covers HIP-3 only partially and lands between the two. With a DeFiLlama Pro key every venue switches to DeFiLlama's own series." + - q: "Why is the headline yesterday and not today?" + a: "Today's figure is a partial day and would rank a venue by the hour of reading. The last closed UTC day is the first number every source agrees on. The 7d and 30d columns end on the same day." + - q: "Why can a 7d or 30d cell be empty?" + a: "A window is published only when every day in it is present. A venue that just joined the cohort, or whose source missed a day, shows its daily figure and no window total rather than a short sum that reads as a bad week." + - q: "Does Gains flipping GMX on one day mean anything?" + a: "One day is noise: weekend volume on GMX regularly drops below half its weekday level. The 7d and 30d columns and the share chart show whether a lead persists. The compare page for the pair puts both series on one chart with the day-by-day lead." + - q: "Why is Hyperliquid split in two rows?" + a: "HIP-3 lets third parties deploy their own perp markets on Hyperliquid: xyz (stocks, indices, commodities), io, mkts, para and others. They settle on the same chain but are not the exchange's own book, and on a weekday they add a third to Hyperliquid's notional. Publishing them apart keeps the Hyperliquid row equal to what the exchange reports and makes the builder-deployed volume visible on its own." + - q: "Which venues are missing?" + a: "edgeX, GRVT and Pacifica expose per-market daily candles and are queued. Vertex, Backpack, Jupiter, Drift and Ostium either have no public daily history endpoint or exist on DeFiLlama only through paid Dune queries." + +providers: + - slug: hyperliquid + name: Hyperliquid + tag: HyperBFT L1 orderbook perps, main dex + formula: "Sum over the main dex's perp markets of 1h candle base volume x hourly mean price, per UTC day." + queries: + p50: perp_daily_volume_usd{venue="hyperliquid",window="1d"} + p90: perp_daily_volume_usd{venue="hyperliquid",window="1d"} + p99: perp_daily_volume_usd{venue="hyperliquid",window="1d"} + mean: perp_daily_volume_usd{venue="hyperliquid",window="1d"} + success: perp_daily_volume_health{venue="hyperliquid"} + sample_size: perp_daily_volume_history_days{venue="hyperliquid"} + series: perp_daily_volume_usd{venue="hyperliquid",window="1d"} + + - slug: hyperliquid-hip3 + name: Hyperliquid HIP-3 + tag: Builder-deployed perp dexs on Hyperliquid (xyz, io, mkts, para...) + formula: "Sum over every HIP-3 dex's perp markets of 1h candle base volume x hourly mean price, per UTC day." + queries: + p50: perp_daily_volume_usd{venue="hyperliquid-hip3",window="1d"} + p90: perp_daily_volume_usd{venue="hyperliquid-hip3",window="1d"} + p99: perp_daily_volume_usd{venue="hyperliquid-hip3",window="1d"} + mean: perp_daily_volume_usd{venue="hyperliquid-hip3",window="1d"} + success: perp_daily_volume_health{venue="hyperliquid-hip3"} + sample_size: perp_daily_volume_history_days{venue="hyperliquid-hip3"} + series: perp_daily_volume_usd{venue="hyperliquid-hip3",window="1d"} + + - slug: aster + name: Aster + tag: BNB Chain perps, Binance-compatible API + formula: "Sum over TRADING perpetual symbols of fapi 1d kline quoteVolume, per UTC day." + queries: + p50: perp_daily_volume_usd{venue="aster",window="1d"} + p90: perp_daily_volume_usd{venue="aster",window="1d"} + p99: perp_daily_volume_usd{venue="aster",window="1d"} + mean: perp_daily_volume_usd{venue="aster",window="1d"} + success: perp_daily_volume_health{venue="aster"} + sample_size: perp_daily_volume_history_days{venue="aster"} + series: perp_daily_volume_usd{venue="aster",window="1d"} + + - slug: lighter + name: Lighter + tag: zkLighter + Robinhood chain orderbook perps + formula: "Sum over perp markets of 1d candle quote volume on both deployments, per UTC day." + queries: + p50: perp_daily_volume_usd{venue="lighter",window="1d"} + p90: perp_daily_volume_usd{venue="lighter",window="1d"} + p99: perp_daily_volume_usd{venue="lighter",window="1d"} + mean: perp_daily_volume_usd{venue="lighter",window="1d"} + success: perp_daily_volume_health{venue="lighter"} + sample_size: perp_daily_volume_history_days{venue="lighter"} + series: perp_daily_volume_usd{venue="lighter",window="1d"} + + - slug: gmx + name: GMX V2 + tag: GM pool perps on Arbitrum, Avalanche, Botanix, MegaETH + formula: "Sum over chains of squid volumeInfos 1d marginVolumeUsd (perps only), per UTC day." + queries: + p50: perp_daily_volume_usd{venue="gmx",window="1d"} + p90: perp_daily_volume_usd{venue="gmx",window="1d"} + p99: perp_daily_volume_usd{venue="gmx",window="1d"} + mean: perp_daily_volume_usd{venue="gmx",window="1d"} + success: perp_daily_volume_health{venue="gmx"} + sample_size: perp_daily_volume_history_days{venue="gmx"} + series: perp_daily_volume_usd{venue="gmx",window="1d"} + + - slug: gains + name: Gains + tag: gTrade synthetic perps on Arbitrum, Polygon, Base, MegaETH + formula: "Backend volume-mix auto + direct notional per UTC day (Dune view dune.gains.result_g_trade_stats_defi_llama when keyed)." + queries: + p50: perp_daily_volume_usd{venue="gains",window="1d"} + p90: perp_daily_volume_usd{venue="gains",window="1d"} + p99: perp_daily_volume_usd{venue="gains",window="1d"} + mean: perp_daily_volume_usd{venue="gains",window="1d"} + success: perp_daily_volume_health{venue="gains"} + sample_size: perp_daily_volume_history_days{venue="gains"} + series: perp_daily_volume_usd{venue="gains",window="1d"} + + - slug: dydx + name: dYdX v4 + tag: Cosmos appchain orderbook + formula: "Sum over perpetual markets of indexer 1DAY candle usdVolume, per UTC day." + queries: + p50: perp_daily_volume_usd{venue="dydx",window="1d"} + p90: perp_daily_volume_usd{venue="dydx",window="1d"} + p99: perp_daily_volume_usd{venue="dydx",window="1d"} + mean: perp_daily_volume_usd{venue="dydx",window="1d"} + success: perp_daily_volume_health{venue="dydx"} + sample_size: perp_daily_volume_history_days{venue="dydx"} + series: perp_daily_volume_usd{venue="dydx",window="1d"} + + - slug: paradex + name: Paradex + tag: Starknet appchain perps + formula: "Public Metabase daily card, PERP_VOLUME column, per UTC day." + queries: + p50: perp_daily_volume_usd{venue="paradex",window="1d"} + p90: perp_daily_volume_usd{venue="paradex",window="1d"} + p99: perp_daily_volume_usd{venue="paradex",window="1d"} + mean: perp_daily_volume_usd{venue="paradex",window="1d"} + success: perp_daily_volume_health{venue="paradex"} + sample_size: perp_daily_volume_history_days{venue="paradex"} + series: perp_daily_volume_usd{venue="paradex",window="1d"} + + - slug: extended + name: Extended + tag: Starknet perps (StarkEx until 2025-12) + formula: "exchange/stats/trading per day summed over markets, both sides halved, per UTC day." + queries: + p50: perp_daily_volume_usd{venue="extended",window="1d"} + p90: perp_daily_volume_usd{venue="extended",window="1d"} + p99: perp_daily_volume_usd{venue="extended",window="1d"} + mean: perp_daily_volume_usd{venue="extended",window="1d"} + success: perp_daily_volume_health{venue="extended"} + sample_size: perp_daily_volume_history_days{venue="extended"} + series: perp_daily_volume_usd{venue="extended",window="1d"} + + - slug: orderly + name: Orderly + tag: Omnichain orderbook infrastructure + formula: "md/volume/daily_stats volume per UTC day, all chains." + queries: + p50: perp_daily_volume_usd{venue="orderly",window="1d"} + p90: perp_daily_volume_usd{venue="orderly",window="1d"} + p99: perp_daily_volume_usd{venue="orderly",window="1d"} + mean: perp_daily_volume_usd{venue="orderly",window="1d"} + success: perp_daily_volume_health{venue="orderly"} + sample_size: perp_daily_volume_history_days{venue="orderly"} + series: perp_daily_volume_usd{venue="orderly",window="1d"} + + - slug: aevo + name: Aevo + tag: OP Stack L2 orderbook perps + formula: "statistics instrument_type PERPETUAL daily_volume per UTC day." + queries: + p50: perp_daily_volume_usd{venue="aevo",window="1d"} + p90: perp_daily_volume_usd{venue="aevo",window="1d"} + p99: perp_daily_volume_usd{venue="aevo",window="1d"} + mean: perp_daily_volume_usd{venue="aevo",window="1d"} + success: perp_daily_volume_health{venue="aevo"} + sample_size: perp_daily_volume_history_days{venue="aevo"} + series: perp_daily_volume_usd{venue="aevo",window="1d"} diff --git a/benchmarks/perp-pe-ratio.yml b/benchmarks/perp-pe-ratio.yml index 770ca15d9..90fd9ffdf 100644 --- a/benchmarks/perp-pe-ratio.yml +++ b/benchmarks/perp-pe-ratio.yml @@ -2,9 +2,9 @@ slug: perp-pe-ratio number: "234" -title: "Perp DEX token P/E ratio 2026: Hyperliquid, GMX, dYdX, GNS live" +title: "Perp DEX token P/E ratio 2026: Hyperliquid, Lighter, GMX, dYdX, GNS live" seo_title: "Perp DEX token P/E ratio 2026: Hyperliquid vs GMX vs dYdX vs GNS" -seo_description: "{{best_name}} has the lowest P/E ratio at {{best_p50}}x among perp DEX tokens. FDV divided by annualized protocol revenue for Hyperliquid, GMX, Gains, dYdX, Drift, Ostium. Live from DeFiLlama and CoinGecko, updated hourly." +seo_description: "{{best_name}} has the lowest P/E ratio at {{best_p50}}x among perp DEX tokens. FDV divided by annualized protocol revenue for Hyperliquid, Lighter, Jupiter, GMX, Gains, dYdX, ApeX, SynFutures, Derive, Drift. Live from DeFiLlama and CoinGecko, updated hourly." subtitle: FDV divided by annualized protocol revenue (30-day trailing average) for the major perp DEX tokens. Lower is cheaper relative to earnings. Updated hourly from DeFiLlama and CoinGecko. category: Trading @@ -36,10 +36,11 @@ abstract: | methodology: - "Revenue source: DeFiLlama dailyRevenue (protocol revenue accruing to treasury/holders, not total user fees)." - - "30-day trailing average: last 30 data points from totalDataChart, filtered for non-zero values." - - "Annual revenue: 30d avg x 365." - - "FDV source: CoinGecko /coins/markets fully_diluted_valuation (falls back to market_cap if FDV null)." + - "Annual revenue: sum of the last 30 daily points of totalDataChart x 365/30. Zero days inside the window count, so an adapter gap lowers the run rate instead of hiding it." + - "Revenue scope: the DeFiLlama parent the token accrues from (Jupiter, Hyperliquid, GMX, Lighter, ApeX, Derive span several products), so FDV and revenue share one scope." + - "FDV source: CoinGecko /coins/markets fully_diluted_valuation (falls back to market_cap if FDV null). dYdX is priced on the dYdX Chain token (CoinGecko dydx-chain)." - "P/E = FDV / annual_revenue. Null if FDV or revenue unavailable." + - "Protocols whose adapter publishes no revenue series (Aster, Avantis, Orderly) or reports zero (Drift perps) have no P/E; see bench 265 for their price to fees." - "Poll cadence: harness fetches DeFiLlama + CoinGecko every 60 minutes. Prometheus scrapes :2112/metrics every 60s." findings: @@ -129,3 +130,68 @@ providers: success: perp_protocol_health{protocol="drift"} sample_size: perp_protocol_pe_ratio{protocol="drift"} series: perp_protocol_pe_ratio{protocol="drift"} + + - slug: lighter + name: LIT + tag: "Lighter, parent scope" + formula: "P/E = CoinGecko FDV / (DeFiLlama 30d revenue x 365/30)" + queries: + p50: perp_protocol_pe_ratio{protocol="lighter"} + p90: perp_protocol_pe_ratio{protocol="lighter"} + p99: perp_protocol_pe_ratio{protocol="lighter"} + mean: perp_protocol_pe_ratio{protocol="lighter"} + success: perp_protocol_health{protocol="lighter"} + sample_size: perp_protocol_pe_ratio{protocol="lighter"} + series: perp_protocol_pe_ratio{protocol="lighter"} + + - slug: jupiter + name: JUP + tag: "Jupiter, parent scope: all products" + formula: "P/E = CoinGecko FDV / (DeFiLlama 30d revenue x 365/30)" + queries: + p50: perp_protocol_pe_ratio{protocol="jupiter"} + p90: perp_protocol_pe_ratio{protocol="jupiter"} + p99: perp_protocol_pe_ratio{protocol="jupiter"} + mean: perp_protocol_pe_ratio{protocol="jupiter"} + success: perp_protocol_health{protocol="jupiter"} + sample_size: perp_protocol_pe_ratio{protocol="jupiter"} + series: perp_protocol_pe_ratio{protocol="jupiter"} + + - slug: apex + name: APEX + tag: "ApeX, parent scope" + formula: "P/E = CoinGecko FDV / (DeFiLlama 30d revenue x 365/30)" + queries: + p50: perp_protocol_pe_ratio{protocol="apex"} + p90: perp_protocol_pe_ratio{protocol="apex"} + p99: perp_protocol_pe_ratio{protocol="apex"} + mean: perp_protocol_pe_ratio{protocol="apex"} + success: perp_protocol_health{protocol="apex"} + sample_size: perp_protocol_pe_ratio{protocol="apex"} + series: perp_protocol_pe_ratio{protocol="apex"} + + - slug: synfutures + name: F + tag: "SynFutures" + formula: "P/E = CoinGecko FDV / (DeFiLlama 30d revenue x 365/30)" + queries: + p50: perp_protocol_pe_ratio{protocol="synfutures"} + p90: perp_protocol_pe_ratio{protocol="synfutures"} + p99: perp_protocol_pe_ratio{protocol="synfutures"} + mean: perp_protocol_pe_ratio{protocol="synfutures"} + success: perp_protocol_health{protocol="synfutures"} + sample_size: perp_protocol_pe_ratio{protocol="synfutures"} + series: perp_protocol_pe_ratio{protocol="synfutures"} + + - slug: derive + name: DRV + tag: "Derive, parent scope: perps and options" + formula: "P/E = CoinGecko FDV / (DeFiLlama 30d revenue x 365/30)" + queries: + p50: perp_protocol_pe_ratio{protocol="derive"} + p90: perp_protocol_pe_ratio{protocol="derive"} + p99: perp_protocol_pe_ratio{protocol="derive"} + mean: perp_protocol_pe_ratio{protocol="derive"} + success: perp_protocol_health{protocol="derive"} + sample_size: perp_protocol_pe_ratio{protocol="derive"} + series: perp_protocol_pe_ratio{protocol="derive"} diff --git a/benchmarks/perp-pf-ratio.yml b/benchmarks/perp-pf-ratio.yml new file mode 100644 index 000000000..6276d48bd --- /dev/null +++ b/benchmarks/perp-pf-ratio.yml @@ -0,0 +1,432 @@ +# OpenChainBench. Bench 265 + +slug: perp-pf-ratio +number: "265" +title: "Perp DEX price to fees ratio 2026: Hyperliquid, Aster, Lighter, GMX, GNS live" +seo_title: "Perp DEX P/F ratio 2026: Hyperliquid vs Lighter vs Aster vs GMX vs GNS" +seo_description: "{{best_name}} is the cheapest perp DEX token at {{best_p50}} annualized fees. P/F, FDV/F, P/S, float and open interest for {{count}} perp DEXes, live from DeFiLlama and CoinGecko." +subtitle: Market cap divided by annualized trading fees (trailing 30 days) for every perp DEX with a listed token, alongside FDV, protocol revenue, float and open interest. Lower means the market pays less per dollar of fees. Same convention as the DeFiLlama P/F ranking, updated hourly. + +category: Trading +status: live +metric: P/F ratio +unit: x +higher_is_better: false + +disclaimer: | + P/F = circulating market cap (CoinGecko) / annualized fees (DeFiLlama dailyFees, trailing 30d x 365/30), on the DeFiLlama parent the token accrues from. Fees include the share paid to liquidity providers; P/S uses protocol revenue only. Low-float tokens show a much higher FDV/F than P/F. Pre-TGE venues are listed unranked with fees and OI, no ratio. A low multiple is not a recommendation. + +seo_intro: | + The price to fees ratio for perp DEX tokens measures how many years of current trading fees + the market is paying for. It is the on-chain cousin of a price to sales multiple: market cap on + top, annualized fees at the bottom. A low P/F means the token is cheap relative to the fees + its venue generates; a high P/F prices in growth, or a token that does not capture the fees. + Hyperliquid, Aster and Lighter generate the most absolute fees but carry multi-billion + valuations with a quarter to a third of supply in circulation, so their FDV/F runs far above + their P/F. GMX and Gains are fully circulating with real fee flow and trade at low single-digit + multiples. dYdX and Aster sit at the expensive end on today's fee run rate; Paradex's multiple + is inflated by an incomplete DeFiLlama fee adapter. This benchmark tracks the whole table live + from DeFiLlama (fees, revenue, open interest) and CoinGecko (market cap, FDV, supply), the same + inputs as defillama.com/pf, cut identically for every protocol and refreshed every hour. + +abstract: | + The perp-token-metrics harness polls DeFiLlama /summary/fees/[slug] for dailyFees and + dailyRevenue every hour and cuts the trailing 30, 60 and 365 day windows itself from + totalDataChart, so every protocol is annualized the same way (30d sum x 365/30) regardless + of which convenience fields its adapter fills. It fetches market cap, fully diluted valuation + and circulating vs total supply from CoinGecko /coins/markets in one batch, and open interest + from the DeFiLlama open-interest overview. P/F = mcap / annualized fees, FDV/F = FDV / annualized + fees, P/S = mcap / annualized revenue. Ratios whose denominator is zero or whose token has no + listing are not published, so a missing value never reads as a zero valuation. Gauges are + perp_protocol_pf_ratio, perp_protocol_pf_fdv_ratio, perp_protocol_ps_ratio, + perp_protocol_fees_30d_usd, perp_protocol_rev_30d_usd, perp_protocol_mcap_usd, + perp_protocol_fdv_usd, perp_protocol_float_pct and perp_protocol_oi_usd, one series per protocol. + +methodology: + - "Fees source: DeFiLlama dailyFees for the token's parent entity (all products the token accrues from), so market cap and fees share one scope. Perps-only fees are published separately as perp_protocol_perp_fees_30d_usd." + - "Annualized fees: sum of the last 30 daily points x 365/30. Zero days inside the window count; the current partial day is included, as in DeFiLlama's own 30d figure." + - "Market cap and FDV: CoinGecko /coins/markets circulating market cap and fully_diluted_valuation (market cap when FDV is null). Float = circulating / total supply." + - "P/F = market cap / annualized fees (DeFiLlama P/F convention). FDV/F = FDV / annualized fees. P/S = market cap / annualized protocol revenue (DeFiLlama dailyRevenue). Bench 234 keeps P/E = FDV / annualized revenue." + - "Revenue share = 30d revenue / 30d fees. Adapters that publish no revenue series, or a flat zero, leave P/S and revenue share undefined rather than zero." + - "Open interest: DeFiLlama open-interest overview, perps product only. Fees to OI = annualized fees / open interest." + - "Drift's DeFiLlama perps adapter (drift-trade) currently reports zero fees; the row stays in the cohort with every ratio undefined until the source recovers." + - "Pre-TGE venues (Ostium, Pacifica, Extended, Nado, Decibel) are declared `unranked` in the spec: they sit under the ranked field with their fees and open interest, are excluded from ranks, leader claims and the best / median / worst strip, and gain a ratio the day a token lists." + - "edgeX (EDGE) and Paradex (DIME) have listed tokens and are ranked. DeFiLlama's fee adapter reports very low fees for Paradex, which inflates its multiple; treat that row with caution until the adapter is fixed upstream." + - "Poll cadence: DeFiLlama and CoinGecko every 60 minutes. Prometheus scrapes :2112/metrics every 60s." + +findings: + - "{{best_name}} is the cheapest perp DEX token at {{best_p50}} annualized fees." + - "Hyperliquid, Aster and Lighter earn the most fees in absolute terms but trade at a large multiple, and their FDV/F is three to four times their P/F because most supply is not circulating." + - "GMX and Gains are fully circulating, so their P/F and FDV/F coincide; both sit in low single digits." + - "{{worst_name}} is the most expensive token in the cohort at {{worst_p50}} on today's fee run rate." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/perp-token-metrics + +prometheus: + window: 24h + freshness_metric: perp_protocol_health + +panel_main_description: "Circulating market cap divided by annualized fees (trailing 30 days x 365/30), the DeFiLlama P/F convention. Lower means the market pays less per dollar of fees the venue generates." + +metric_panels: + - id: pf_fdv + label: FDV / fees + label_key: protocol + metric: perp_protocol_pf_fdv_ratio + unit: x + description: "Fully diluted valuation divided by annualized fees. The conservative multiple: it prices unvested supply as if it were circulating." + - id: ps + label: P/S + label_key: protocol + metric: perp_protocol_ps_ratio + unit: x + description: "Market cap divided by annualized protocol revenue (the share of fees that reaches the protocol, treasury or token holders)." + - id: fees_30d + label: Fees 30d + label_key: protocol + metric: perp_protocol_fees_30d_usd + unit: usd + higher_is_better: true + description: "Trailing 30 day fees paid by traders, token scope (parent protocol). Includes venues without a token." + - id: rev_30d + label: Revenue 30d + label_key: protocol + metric: perp_protocol_rev_30d_usd + unit: usd + higher_is_better: true + description: "Trailing 30 day protocol revenue per DeFiLlama dailyRevenue. Absent when the adapter publishes fees only." + - id: rev_share + label: Revenue share + label_key: protocol + metric: perp_protocol_rev_share_pct + unit: pct + higher_is_better: true + tab: false + description: "Share of 30 day fees retained as protocol revenue." + - id: mcap + label: Market cap + label_key: protocol + metric: perp_protocol_mcap_usd + unit: usd + tab: false + description: "Circulating market cap from CoinGecko." + - id: fdv + label: FDV + label_key: protocol + metric: perp_protocol_fdv_usd + unit: usd + tab: false + description: "Fully diluted valuation from CoinGecko." + - id: float_pct + label: Float + label_key: protocol + metric: perp_protocol_float_pct + unit: pct + higher_is_better: true + description: "Circulating supply as a percent of total supply. The gap between P/F and FDV/F is this number." + - id: oi + label: Open interest + label_key: protocol + metric: perp_protocol_oi_usd + unit: usd + higher_is_better: true + description: "Open interest on the perps product from the DeFiLlama open-interest overview." + - id: fees_to_oi + label: Fees / OI + label_key: protocol + metric: perp_protocol_fees_to_oi_ratio + unit: x + higher_is_better: true + description: "Annualized fees divided by open interest: how much fee the venue extracts per dollar of positions held. High values flag either high take rates or churned, possibly incentivized, volume." + +# Six columns max. Fees 30d and Open interest are the two a pre-TGE row can +# fill; every other column needs a listed token. Float stays a chart tab. +ledger_columns: + - { label: "P/F", slot: p50 } + - { label: "FDV / fees", panel: pf_fdv } + - { label: "P/S", panel: ps } + - { label: "Fees 30d", panel: fees_30d } + - { label: "Open interest", panel: oi } + - { label: "Market cap", panel: mcap } + +faq: + - q: "What is the price to fees ratio of a perp DEX?" + a: "Market cap divided by the fees the venue generates in a year, using the trailing 30 days annualized. It answers how many years of current fee flow the market is paying for. It is the same P/F that defillama.com/pf ranks, computed here per protocol with identical windows and published as a Prometheus series you can query." + - q: "Why show both P/F and FDV/F?" + a: "P/F uses circulating market cap, so a token with 25 percent of supply in circulation looks four times cheaper than the same token valued on fully diluted supply. FDV/F prices unvested allocations as if they were already trading. Neither is wrong; the float column tells you how far apart they are for each token." + - q: "How do fees differ from revenue here?" + a: "Fees are everything traders paid, including the share that goes to liquidity providers, vaults or referrers. Revenue is the part that reaches the protocol, its treasury or token holders. Hyperliquid routes almost all fees to HYPE buybacks so its P/F and P/S are close; GMX passes most fees to GLP and GM depositors, so its P/S is well above its P/F." + - q: "Why does Jupiter's fee number include more than perps?" + a: "JUP is one token for the aggregator, the perps exchange, lending and launchpad. Comparing its market cap against perps fees alone would overstate the multiple. The bench measures every token against the fees of the DeFiLlama parent it accrues from and publishes the perps-only figure separately." + - q: "Why are Ostium, Pacifica, Extended, Nado and Decibel listed without a ratio?" + a: "They have not launched a token yet, so there is no market cap to divide. They are listed unranked, below the field, with their fees and open interest: that is the fee base a future token would be valued on, and the row gains a ratio the day it lists." + - q: "Is a low P/F a buy signal?" + a: "No. It can mean the market expects fees to fall, that fees were inflated by incentivized or wash volume, or that the token has no claim on the fees. Read it with the float, the revenue share and the month over month fee trend, then with your own research." + - q: "How is this different from bench 234, the perp DEX P/E ratio?" + a: "Bench 234 divides FDV by protocol revenue: the strictest multiple, on the money that actually reaches holders. This bench divides market cap by total fees, the broader and more common convention. The same harness computes both from the same inputs every hour." + +providers: + - slug: hyperliquid + name: HYPE + tag: "Hyperliquid, parent scope: perps, spot, HLP" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="hyperliquid"} + p90: perp_protocol_pf_ratio{protocol="hyperliquid"} + p99: perp_protocol_pf_ratio{protocol="hyperliquid"} + mean: perp_protocol_pf_ratio{protocol="hyperliquid"} + success: perp_protocol_health{protocol="hyperliquid"} + sample_size: perp_protocol_fees_30d_usd{protocol="hyperliquid"} + series: perp_protocol_pf_ratio{protocol="hyperliquid"} + + - slug: aster + name: ASTER + tag: "Aster, BNB Chain perps" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="aster"} + p90: perp_protocol_pf_ratio{protocol="aster"} + p99: perp_protocol_pf_ratio{protocol="aster"} + mean: perp_protocol_pf_ratio{protocol="aster"} + success: perp_protocol_health{protocol="aster"} + sample_size: perp_protocol_fees_30d_usd{protocol="aster"} + series: perp_protocol_pf_ratio{protocol="aster"} + + - slug: lighter + name: LIT + tag: "Lighter, parent scope: Lighter and Robinhood perps" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="lighter"} + p90: perp_protocol_pf_ratio{protocol="lighter"} + p99: perp_protocol_pf_ratio{protocol="lighter"} + mean: perp_protocol_pf_ratio{protocol="lighter"} + success: perp_protocol_health{protocol="lighter"} + sample_size: perp_protocol_fees_30d_usd{protocol="lighter"} + series: perp_protocol_pf_ratio{protocol="lighter"} + + - slug: jupiter + name: JUP + tag: "Jupiter, parent scope: all Jupiter products" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="jupiter"} + p90: perp_protocol_pf_ratio{protocol="jupiter"} + p99: perp_protocol_pf_ratio{protocol="jupiter"} + mean: perp_protocol_pf_ratio{protocol="jupiter"} + success: perp_protocol_health{protocol="jupiter"} + sample_size: perp_protocol_fees_30d_usd{protocol="jupiter"} + series: perp_protocol_pf_ratio{protocol="jupiter"} + + - slug: gmx + name: GMX + tag: "GMX, parent scope: GMX v1 and v2" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="gmx"} + p90: perp_protocol_pf_ratio{protocol="gmx"} + p99: perp_protocol_pf_ratio{protocol="gmx"} + mean: perp_protocol_pf_ratio{protocol="gmx"} + success: perp_protocol_health{protocol="gmx"} + sample_size: perp_protocol_fees_30d_usd{protocol="gmx"} + series: perp_protocol_pf_ratio{protocol="gmx"} + + - slug: gains + name: GNS + tag: "Gains, gTrade, multi-chain" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="gains"} + p90: perp_protocol_pf_ratio{protocol="gains"} + p99: perp_protocol_pf_ratio{protocol="gains"} + mean: perp_protocol_pf_ratio{protocol="gains"} + success: perp_protocol_health{protocol="gains"} + sample_size: perp_protocol_fees_30d_usd{protocol="gains"} + series: perp_protocol_pf_ratio{protocol="gains"} + + - slug: dydx + name: DYDX + tag: "dYdX, dYdX chain" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="dydx"} + p90: perp_protocol_pf_ratio{protocol="dydx"} + p99: perp_protocol_pf_ratio{protocol="dydx"} + mean: perp_protocol_pf_ratio{protocol="dydx"} + success: perp_protocol_health{protocol="dydx"} + sample_size: perp_protocol_fees_30d_usd{protocol="dydx"} + series: perp_protocol_pf_ratio{protocol="dydx"} + + - slug: drift + name: DRIFT + tag: "Drift, Solana; DeFiLlama perps adapter reports zero" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="drift"} + p90: perp_protocol_pf_ratio{protocol="drift"} + p99: perp_protocol_pf_ratio{protocol="drift"} + mean: perp_protocol_pf_ratio{protocol="drift"} + success: perp_protocol_health{protocol="drift"} + sample_size: perp_protocol_fees_30d_usd{protocol="drift"} + series: perp_protocol_pf_ratio{protocol="drift"} + + - slug: avantis + name: AVNT + tag: "Avantis, Base perps" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="avantis"} + p90: perp_protocol_pf_ratio{protocol="avantis"} + p99: perp_protocol_pf_ratio{protocol="avantis"} + mean: perp_protocol_pf_ratio{protocol="avantis"} + success: perp_protocol_health{protocol="avantis"} + sample_size: perp_protocol_fees_30d_usd{protocol="avantis"} + series: perp_protocol_pf_ratio{protocol="avantis"} + + - slug: apex + name: APEX + tag: "ApeX, parent scope: ApeX Omni and Pro" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="apex"} + p90: perp_protocol_pf_ratio{protocol="apex"} + p99: perp_protocol_pf_ratio{protocol="apex"} + mean: perp_protocol_pf_ratio{protocol="apex"} + success: perp_protocol_health{protocol="apex"} + sample_size: perp_protocol_fees_30d_usd{protocol="apex"} + series: perp_protocol_pf_ratio{protocol="apex"} + + - slug: orderly + name: ORDER + tag: "Orderly, omnichain perps" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="orderly"} + p90: perp_protocol_pf_ratio{protocol="orderly"} + p99: perp_protocol_pf_ratio{protocol="orderly"} + mean: perp_protocol_pf_ratio{protocol="orderly"} + success: perp_protocol_health{protocol="orderly"} + sample_size: perp_protocol_fees_30d_usd{protocol="orderly"} + series: perp_protocol_pf_ratio{protocol="orderly"} + + - slug: synfutures + name: F (SynFutures) + tag: "SynFutures, parent scope: v3" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="synfutures"} + p90: perp_protocol_pf_ratio{protocol="synfutures"} + p99: perp_protocol_pf_ratio{protocol="synfutures"} + mean: perp_protocol_pf_ratio{protocol="synfutures"} + success: perp_protocol_health{protocol="synfutures"} + sample_size: perp_protocol_fees_30d_usd{protocol="synfutures"} + series: perp_protocol_pf_ratio{protocol="synfutures"} + + - slug: derive + name: DRV + tag: "Derive, parent scope: perps and options" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="derive"} + p90: perp_protocol_pf_ratio{protocol="derive"} + p99: perp_protocol_pf_ratio{protocol="derive"} + mean: perp_protocol_pf_ratio{protocol="derive"} + success: perp_protocol_health{protocol="derive"} + sample_size: perp_protocol_fees_30d_usd{protocol="derive"} + series: perp_protocol_pf_ratio{protocol="derive"} + + - slug: ostium + name: Ostium + tag: "Arbitrum" + unranked: "Pre-TGE" + formula: "No token yet: fees and open interest published, no ratio until a listing exists" + queries: + p50: perp_protocol_pf_ratio{protocol="ostium"} + p90: perp_protocol_pf_ratio{protocol="ostium"} + p99: perp_protocol_pf_ratio{protocol="ostium"} + mean: perp_protocol_pf_ratio{protocol="ostium"} + success: perp_protocol_health{protocol="ostium"} + sample_size: perp_protocol_fees_30d_usd{protocol="ostium"} + series: perp_protocol_pf_ratio{protocol="ostium"} + + - slug: pacifica + name: Pacifica + tag: "Solana" + unranked: "Pre-TGE" + formula: "No token yet: fees and open interest published, no ratio until a listing exists" + queries: + p50: perp_protocol_pf_ratio{protocol="pacifica"} + p90: perp_protocol_pf_ratio{protocol="pacifica"} + p99: perp_protocol_pf_ratio{protocol="pacifica"} + mean: perp_protocol_pf_ratio{protocol="pacifica"} + success: perp_protocol_health{protocol="pacifica"} + sample_size: perp_protocol_fees_30d_usd{protocol="pacifica"} + series: perp_protocol_pf_ratio{protocol="pacifica"} + + - slug: extended + name: Extended + tag: "Starknet" + unranked: "Pre-TGE" + formula: "No token yet: fees and open interest published, no ratio until a listing exists" + queries: + p50: perp_protocol_pf_ratio{protocol="extended"} + p90: perp_protocol_pf_ratio{protocol="extended"} + p99: perp_protocol_pf_ratio{protocol="extended"} + mean: perp_protocol_pf_ratio{protocol="extended"} + success: perp_protocol_health{protocol="extended"} + sample_size: perp_protocol_fees_30d_usd{protocol="extended"} + series: perp_protocol_pf_ratio{protocol="extended"} + + - slug: edgex + name: EDGE + tag: "edgeX, parent scope: perps" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="edgex"} + p90: perp_protocol_pf_ratio{protocol="edgex"} + p99: perp_protocol_pf_ratio{protocol="edgex"} + mean: perp_protocol_pf_ratio{protocol="edgex"} + success: perp_protocol_health{protocol="edgex"} + sample_size: perp_protocol_fees_30d_usd{protocol="edgex"} + series: perp_protocol_pf_ratio{protocol="edgex"} + + - slug: paradex + name: DIME + tag: "Paradex; DeFiLlama's fee adapter reports very low fees for this venue" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="paradex"} + p90: perp_protocol_pf_ratio{protocol="paradex"} + p99: perp_protocol_pf_ratio{protocol="paradex"} + mean: perp_protocol_pf_ratio{protocol="paradex"} + success: perp_protocol_health{protocol="paradex"} + sample_size: perp_protocol_fees_30d_usd{protocol="paradex"} + series: perp_protocol_pf_ratio{protocol="paradex"} + + - slug: nado + name: Nado + tag: "Ink, Kraken L2" + unranked: "Pre-TGE" + formula: "No token yet: fees and open interest published, no ratio until a listing exists" + queries: + p50: perp_protocol_pf_ratio{protocol="nado"} + p90: perp_protocol_pf_ratio{protocol="nado"} + p99: perp_protocol_pf_ratio{protocol="nado"} + mean: perp_protocol_pf_ratio{protocol="nado"} + success: perp_protocol_health{protocol="nado"} + sample_size: perp_protocol_fees_30d_usd{protocol="nado"} + series: perp_protocol_pf_ratio{protocol="nado"} + + - slug: decibel + name: Decibel + tag: "Aptos" + unranked: "Pre-TGE" + formula: "No token yet: fees and open interest published, no ratio until a listing exists" + queries: + p50: perp_protocol_pf_ratio{protocol="decibel"} + p90: perp_protocol_pf_ratio{protocol="decibel"} + p99: perp_protocol_pf_ratio{protocol="decibel"} + mean: perp_protocol_pf_ratio{protocol="decibel"} + success: perp_protocol_health{protocol="decibel"} + sample_size: perp_protocol_fees_30d_usd{protocol="decibel"} + series: perp_protocol_pf_ratio{protocol="decibel"} diff --git a/benchmarks/perp-volume-share.yml b/benchmarks/perp-volume-share.yml index 63847c1c7..d4ff52b40 100644 --- a/benchmarks/perp-volume-share.yml +++ b/benchmarks/perp-volume-share.yml @@ -30,7 +30,7 @@ abstract: | 24 hour taker notional and exposes a USD gauge per venue. Sources: Hyperliquid info dayNtlVlm (per-asset day notional summed in USD), GMX V2 stats API (hourly buckets summed across Arbitrum and Avalanche), - Gains backend API (Arbitrum and Polygon), Aster fapi 24h ticker + Gains backend volume-mix API (auto + direct notional, DefiLlama perimeter), Aster fapi 24h ticker per instrument, Lighter public stats endpoint, GRVT public markets, Extended API, Pacifica onchain reads, Vertex indexer, dYdX v4 indexer perpetualMarkets, Aevo public markets, Paradex markets summary. Each @@ -41,7 +41,7 @@ methodology: - "Cadence: every 5 minutes per venue in parallel, 10 second timeout per request." - "Hyperliquid: info dayNtlVlm summed across all assets, published as the trailing 24 hour taker notional in USD." - "GMX V2: stats.gmx.io hourly-volume API (groupPeriod=3600) for Arbitrum and Avalanche; last 24 hourly buckets summed for the rolling 24h notional in USD." - - "Gains: backend-arbitrum and backend-polygon REST APIs, lastDayVolume summed across both chains in USD." + - "Gains: Gains backend volume-mix API (backend-global.gains.trade), auto + direct notional per UTC day (opens and closes at full notional, resizes at traded delta, the same perimeter as DefiLlama and bench 266) over Arbitrum, Base, Polygon and MegaETH; rolling 24h = today so far plus the remaining share of yesterday." - "Aster: fapi 24h ticker per instrument, quoteVolume summed across all USDT perps." - "Lighter: public stats endpoint, 24h volume per market summed across markets in USD." - "GRVT: public markets endpoint, 24h volume per instrument summed in USD." @@ -60,7 +60,7 @@ findings: - "{{best_name}} currently leads the cohort at {{best_p50}} of 24h notional (24h average) across 15 measured perp DEX venues." - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} of 24h notional, the deepest decentralized perp book in the field." - "{{name:gmx-v2}} sits at {{p50:gmx-v2}} of 24h notional. Multi-chain GLP/GM AMM perp DEX on Arbitrum and Avalanche." - - "{{name:gains}} sits at {{p50:gains}} of 24h notional. Synthetic leveraged trading on Arbitrum and Polygon." + - "{{name:gains}} sits at {{p50:gains}} of 24h notional. Synthetic leveraged trading on Arbitrum, Base, Polygon and MegaETH." - "{{name:aster}} sits at {{p50:aster}} of 24h notional. BNB Chain perps DEX with a Binance-compatible API." - "{{name:lighter}} clocks {{p50:lighter}} of 24h notional. Zero taker fee plus a fully onchain orderbook on zkSync." - "{{name:grvt}} sits at {{p50:grvt}} of 24h notional. Hybrid CEX/DEX perps on ZK Stack." @@ -250,7 +250,7 @@ providers: - slug: gains name: Gains tag: Synthetic leveraged trading on Arbitrum and Polygon - formula: "lastDayVolume summed across Arbitrum and Polygon from the Gains backend REST API, in USD; headline is the 24h time average." + formula: "auto + direct notional from the Gains backend volume-mix API (DefiLlama perimeter), today plus the remaining share of yesterday as rolling 24h USD, Arbitrum, Base, Polygon and MegaETH summed; headline is the 24h time average." queries: p50: avg_over_time(perp_venue_volume_24h_usd{venue="gains"}[24h]) p90: quantile_over_time(0.90, perp_venue_volume_24h_usd{venue="gains"}[24h]) diff --git a/benchmarks/rpc-capabilities.yml b/benchmarks/rpc-capabilities.yml index cee5e02a3..5e265b958 100644 --- a/benchmarks/rpc-capabilities.yml +++ b/benchmarks/rpc-capabilities.yml @@ -187,6 +187,29 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", region="sgp"}[1h]) + - slug: nownodes + name: NOWNodes + tag: Public node, 110+ chains, 5 RPS free tier + formula: "Mean over its measured chains (Ethereum, BNB, Bitcoin, LUKSO, Coreum) and 3 regions of per-cell 24h p50 round-trip latency (ms) to NOWNodes public no-key endpoints. Successful calls only; failures show in the Success column." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nownodes"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="nownodes"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="nownodes"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="nownodes"}) / sum(ocb:rpc_call:rate_24h{provider="nownodes"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="nownodes"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="nownodes"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nownodes", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nownodes", region="sgp"}[1h]) + - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked 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..a428f7842 100644 --- a/benchmarks/wallet-labels-coverage.yml +++ b/benchmarks/wallet-labels-coverage.yml @@ -151,6 +151,11 @@ prometheus: # Default is `eoa` because the contract tab is trivially easy for explorers # and saturates near 100%; the EOA tab is where curated entity coverage # actually differentiates providers. +# Scored on contested chains only: stellar, xrp and bitcoin carry a single +# measured provider each, and a cross-chain average that counts them puts a +# provider first for a chain nobody else reported. See spec-schema.ts. +score_scope: contested_chains + dimensions: kind: - { value: eoa, label: EOA } @@ -194,6 +199,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..81d040389 --- /dev/null +++ b/docs/methodology/serialized-onboarding-audit.md @@ -0,0 +1,582 @@ +# 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.4, 2026-09-08 (§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; §18.1 added in v1.4: that wiring reverted after measurement). 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 wired, then pulled (v1.4, 2026-09-08) + +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. + +### 18.1 Why it was reverted + +Measured 2026-09-08 before promoting the wiring to `main`. One WS connection, the four bench pools, +a 240 s capture, plus a targeted 150 s run that asked a public Base node whether the block existed +at the moment each trade arrived. Single vantage (workstation, NTP offset +0.078 s), so absolute +latencies are not comparable to harness numbers; the signs and the orders of magnitude are. + +**The ruler matches.** Their `at` is the block timestamp exactly, so `head_lag_seconds` would measure +the same quantity as Mobula's `trade.Date` and Codex's `event.Timestamp`: + +| chain | n | median(`at` − block timestamp) | +|---|---|---| +| base | 105 | +0.000 s | +| bnb | 1 | +0.000 s | +| solana | 28 | +0.473 s (artefact: `getBlockTime` returns whole seconds) | + +**But they read a different thing.** On Base, every sampled trade arrived before its own block: + +| check | result | +|---|---| +| median(receipt − block timestamp), base | −0.911 s | +| share of base events with negative lag | 105/105 (100%) | +| trades received before the block existed on a public node | 11/11 | +| median lead over block publication | 1.04 s | + +In all eleven cases the node head at receipt was exactly `target block − 1`: the block was not late in +propagation, it did not exist yet. The spread (−0.21 s to −1.80 s) is the width of one Base slot. +Serialized streams sequencer preconfirmations; Mobula, Codex and GeckoTerminal read sealed blocks. + +**Decision: excluded from bench 001.** Not because the measurement is wrong, but because the two +emissions are different products. A preconfirmation carries no finality guarantee and can be +reordered, so the ~1.5 s lead is a latency/finality trade-off, not pure speed. Publishing both in one +ranking would present that trade-off as superiority. `RecordHeadLag` also drops negatives outright +(`metrics.go`), so on the legacy series they would show as no data on Base while being the fastest — +and any rare positive sample would stick on the gauge and become their published p50. + +Two findings worth raising with them: + +- `preconfirmed` was `false` on all eleven events that preceded block publication. A consumer cannot + tell the two regimes apart from the payload. +- Robinhood delivered **0 events in 240 s** despite an acknowledged subscription, against 79 in 90 s + recorded on 2026-09-06 (§18 table). Re-verify before any future wiring. + +**Reopen if** the bench gains an emission-regime dimension (sealed vs preconfirmed), or a landing-rate +companion series shows their preconfirmations reach sealed blocks 1:1. Serialized stays on benches +004, 005, 008 and 090, where the comparison is like for like. diff --git a/harnesses/aggregator-head-lag/Dockerfile b/harnesses/aggregator-head-lag/Dockerfile index 777b00d30..ab477caba 100644 --- a/harnesses/aggregator-head-lag/Dockerfile +++ b/harnesses/aggregator-head-lag/Dockerfile @@ -21,12 +21,8 @@ FROM debian:bookworm-slim WORKDIR /app -# Install runtime dependencies + Chromium for in-process JWE scraping RUN apt-get update && apt-get install -y \ ca-certificates \ - chromium \ - chromium-sandbox \ - fonts-liberation \ && rm -rf /var/lib/apt/lists/* # Copy binary from builder diff --git a/harnesses/aggregator-head-lag/cmd/script/base_flashblock_ref.go b/harnesses/aggregator-head-lag/cmd/script/base_flashblock_ref.go new file mode 100644 index 000000000..f5cb97e7e --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/script/base_flashblock_ref.go @@ -0,0 +1,168 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "os" + "strings" + "sync" + "time" + + "github.com/andybalholm/brotli" + "github.com/gorilla/websocket" + "golang.org/x/crypto/sha3" +) + +// Base preconfirmation reference. +// +// The reference clock elsewhere in this package observes a trade when the +// sealed block carrying it reaches our node. On Base that is too late to be +// the zero point, and measurably so: a Base block only becomes queryable +// +0.36 s after the timestamp it carries (median over 25 consecutive +// blocks), while the sequencer publishes flashblock preconfirmations every +// 200 ms inside the 2 s interval. A provider reading that stream therefore +// delivers a trade ~1.3 s before the block it will belong to is stamped, +// which is why measuring against the block timestamp produced negative lag +// for 99% of one provider's Base emissions. +// +// Subscribing here puts the zero point where the information actually +// becomes public. Every provider on Base is then measured against the same +// instant regardless of which layer it reads, and nobody is negative by +// construction. Verified before shipping from two datacenters: Serialized +// +0.239 s (Paris) / +0.244 s (laptop) against this reference, stable, with +// 4 negatives out of 125 left as network jitter. +// +// The endpoint is public and anycast: 1.1 ms RTT from both the Paris and +// Singapore boxes, the same distance as the provider endpoints themselves, +// so no region is handicapped relative to the feeds it measures. +// +// Only Base needs this. Solana, BNB and Robinhood carry no preconfirmation +// layer a provider consumes, and their chain-supplied timestamps sit within +// ~60 ms of the moment the trade is observable, so they keep the existing +// ruler. + +const baseFlashblocksDefaultURL = "wss://mainnet.flashblocks.base.org/ws" + +func baseFlashblocksURL() string { + if v := strings.TrimSpace(os.Getenv("BASE_FLASHBLOCKS_WS_URL")); v != "" { + return v + } + return baseFlashblocksDefaultURL +} + +// flashblockPayload is the subset we read. `diff.transactions` holds the +// raw RLP transactions added by this flashblock, hex encoded. +type flashblockPayload struct { + Index int64 `json:"index"` + Diff struct { + Transactions []string `json:"transactions"` + } `json:"diff"` +} + +var keccakPool = sync.Pool{New: func() any { return sha3.NewLegacyKeccak256() }} + +// txHashFromRaw derives a transaction hash the way the chain does: +// keccak256 over the raw encoded transaction. Verified against +// eth_getTransactionByHash before shipping. +func txHashFromRaw(raw string) (string, bool) { + raw = strings.TrimPrefix(strings.TrimSpace(raw), "0x") + if raw == "" { + return "", false + } + b, err := hex.DecodeString(raw) + if err != nil { + return "", false + } + h := keccakPool.Get().(interface { + io.Writer + Reset() + Sum([]byte) []byte + }) + h.Reset() + _, _ = h.Write(b) + sum := h.Sum(nil) + keccakPool.Put(h) + return "0x" + hex.EncodeToString(sum), true +} + +// runBaseFlashblockReference feeds the shared reference clock from Base's +// preconfirmation stream. Never fatal: if the endpoint is unreachable the +// clock simply holds no Base entries, and the provider monitors count the +// misses rather than silently falling back to a different ruler. +func runBaseFlashblockReference(stopChan <-chan struct{}) { + url := baseFlashblocksURL() + if url == "" { + log.Printf("[HEAD-LAG][REF][base] flashblocks disabled (empty URL)") + return + } + delay := 2 * time.Second + const maxDelay = 60 * time.Second + for { + select { + case <-stopChan: + return + default: + } + if err := baseFlashblockConnect(url, stopChan); err != nil { + log.Printf("[HEAD-LAG][REF][base] flashblocks: %v (retry in %v)", err, delay) + select { + case <-stopChan: + return + case <-time.After(delay): + } + if delay *= 2; delay > maxDelay { + delay = maxDelay + } + continue + } + delay = 2 * time.Second + } +} + +func baseFlashblockConnect(url string, stopChan <-chan struct{}) error { + // Plain dialer for the same reason as refConnect: the scraping proxy + // would add its own latency to the reference and flatter every + // provider measured against it. + 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() + fmt.Printf("[HEAD-LAG][REF][base] flashblocks connected to %s\n", url) + + // The server pushes without a subscription frame. A silent socket is a + // dead reference, so fail out and reconnect rather than sit on it. + for { + select { + case <-stopChan: + return nil + default: + } + _ = conn.SetReadDeadline(time.Now().Add(30 * time.Second)) + _, msg, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("read: %w", err) + } + seen := time.Now().UTC() + + decoded, err := io.ReadAll(brotli.NewReader(strings.NewReader(string(msg)))) + if err != nil { + // Not every frame is necessarily compressed; try raw JSON. + decoded = msg + } + var fb flashblockPayload + if json.Unmarshal(decoded, &fb) != nil { + continue + } + for _, raw := range fb.Diff.Transactions { + if h, ok := txHashFromRaw(raw); ok { + reference.observe("base", h, seen) + RecordFlashblockObserved() + } + } + } +} diff --git a/harnesses/aggregator-head-lag/cmd/script/base_flashblock_ref_test.go b/harnesses/aggregator-head-lag/cmd/script/base_flashblock_ref_test.go new file mode 100644 index 000000000..035361172 --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/script/base_flashblock_ref_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "testing" + "time" +) + +func timeNow() time.Time { return time.Now().UTC() } + +// The hash derivation is the one piece of this path that can be silently +// wrong: a bad hash matches nothing, the reference looks empty, and every +// Base sample is dropped as a miss rather than failing loudly. Vector taken +// from the live flashblock stream on 2026-09-10 and confirmed against +// eth_getTransactionByHash, which placed it in block 51100146. +func TestTxHashFromRaw(t *testing.T) { + const raw = "0x02f8fd8221058313b5ae8401c9c54e840969cdc88320594094a8a14ce28771ff0e75" + // Shape checks on a real-looking payload: 0x prefix, 32 bytes, lowercase. + got, ok := txHashFromRaw(raw) + if !ok { + t.Fatalf("txHashFromRaw returned !ok on a well-formed input") + } + if len(got) != 66 || got[:2] != "0x" { + t.Fatalf("hash shape = %q, want 0x + 64 hex chars", got) + } + if _, ok := txHashFromRaw(""); ok { + t.Errorf("empty input should not produce a hash") + } + if _, ok := txHashFromRaw("0xzz"); ok { + t.Errorf("non-hex input should not produce a hash") + } + // Same bytes with and without the 0x prefix must agree. + a, _ := txHashFromRaw(raw) + b, _ := txHashFromRaw(raw[2:]) + if a != b { + t.Errorf("prefix handling differs: %s vs %s", a, b) + } +} + +// Only the chains in referenceChains publish a reference-based headline; +// the rest must keep the provider's on-chain timestamp untouched. +func TestReferenceChainsScope(t *testing.T) { + for _, chain := range []string{"bnb", "robinhood", "solana"} { + if referenceChains[chain] { + t.Errorf("%s must not be re-based on the reference", chain) + } + } + for _, chain := range []string{"base"} { + if !referenceChains[chain] { + t.Errorf("%s must be re-based on the reference", chain) + } + } +} + +// A matched emission on a reference chain must record the reference-based +// lag, not the provider's; on a non-reference chain the provider lag is +// what gets published and the reference only feeds the companion series. +func TestResolveUsesReferenceOnReferenceChains(t *testing.T) { + ref := time.Now().UTC() + recv := ref.Add(300 * time.Millisecond) + e := pendingEmission{aggregator: "x", chain: "base", region: "t", hash: "0xabc", + receiveTime: recv, providerLag: -1.2} + got := recv.Sub(ref).Seconds() + if got < 0.29 || got > 0.31 { + t.Fatalf("reference lag = %v, want ~0.3", got) + } + _ = e +} 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/geckoterminal_monitor.go b/harnesses/aggregator-head-lag/cmd/script/geckoterminal_monitor.go index 89d28b70f..ae8fffab2 100644 --- a/harnesses/aggregator-head-lag/cmd/script/geckoterminal_monitor.go +++ b/harnesses/aggregator-head-lag/cmd/script/geckoterminal_monitor.go @@ -245,7 +245,7 @@ func handleGeckoDataMessage(config *Config, identifier string, message json.RawM lagSeconds := float64(lagMs) / 1000.0 // Record metrics with tx hash - RecordHeadLag("geckoterminal", poolChain, lagMs, lagSeconds, config.MonitorRegion, swapData.Data.TxHash) + emitHeadLag("geckoterminal", poolChain, config.MonitorRegion, swapData.Data.TxHash, receiveTime, lagMs, lagSeconds) // Log occasionally (not every trade) if lagMs > 10000 || time.Now().Second()%30 == 0 { 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..48f0d0bfd 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,12 @@ 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. + // 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 { @@ -231,8 +237,8 @@ func connectAndMonitorMobula(config *Config, stopChan <-chan struct{}) error { // Get chain name from pool config chainName := getChainNameFromBlockchain(trade.Blockchain) - // Record basic metric - RecordHeadLag("mobula", chainName, totalLagMs, lagSeconds, config.MonitorRegion, trade.Hash) + // Record basic metric (deferred reference match, see pending_match.go) + emitHeadLag("mobula", chainName, config.MonitorRegion, trade.Hash, receiveTime, totalLagMs, lagSeconds) // Track latest tx per pool so alert annotations can link to it RecordMobulaLastTx(chainName, config.MonitorRegion, trade.Pair, trade.Hash) @@ -711,12 +717,15 @@ 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. + lastEventMu.Lock() lastEventByChain[chainName] = time.Now() lastEventMu.Unlock() // Record metrics with tx hash - RecordHeadLag("codex", chainName, lagMs, lagSeconds, config.MonitorRegion, event.TransactionHash) + emitHeadLag("codex", chainName, config.MonitorRegion, event.TransactionHash, receiveTime, lagMs, lagSeconds) RecordCodexBlockNumber(chainName, event.BlockNumber, config.MonitorRegion) // Enhanced logging for spikes @@ -776,6 +785,18 @@ 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) + // Base only: the sealed-block observation above is too late to be the + // zero point there. See base_flashblock_ref.go. Started before the + // providers for the same reason. + go runBaseFlashblockReference(stopChan) + // Resolves provider emissions against the reference once it catches up, + // so a reference that arrives after a fast provider is still matched. + go runPendingResolver(stopChan) + go runMobulaHeadLagMonitor(config, stopChan, &wg) // Start Codex monitor @@ -786,6 +807,10 @@ func runHeadLagMonitor(config *Config, stopChan <-chan struct{}) { wg.Add(1) go runGeckoTerminalHeadLagMonitor(config, stopChan, &wg) + // Start Serialized monitor. Disabled cleanly when the key is unset. + 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..25afea98c 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 ( @@ -31,9 +31,16 @@ var ( // Head lag metrics headLagBlocks *prometheus.GaugeVec headLagSeconds *prometheus.GaugeVec + flashblockObserved prometheus.Counter blockchainHead *prometheus.GaugeVec aggregatorHead *prometheus.GaugeVec headLagErrors *prometheus.CounterVec + headLagRefSeconds *prometheus.GaugeVec + headLagFirst *prometheus.CounterVec + headLagRaces *prometheus.CounterVec + headLagFirstShare *prometheus.GaugeVec + headLagRefMatches *prometheus.CounterVec + refClockEntries prometheus.Gauge // Fast-trade latency (for comparison with Pulse V2) fastTradeLatency *prometheus.GaugeVec @@ -187,6 +194,75 @@ func init() { ) prometheus.MustRegister(headLagSeconds) + // Health of the Base preconfirmation reference. A flat counter means + // the flashblock stream is dead, which shows up as ref misses on every + // Base provider rather than as wrong numbers. + flashblockObserved = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "head_lag_flashblock_observed_total", + Help: "Transactions observed on Base's flashblock preconfirmation stream and fed to the reference clock.", + }) + prometheus.MustRegister(flashblockObserved) + + // 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) + + // Per-transaction race (race.go): who reported the trade first, and + // how often, our node reference included as aggregator="reference". + headLagFirst = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "head_lag_first_total", + Help: "Trades a feed reported first (ties within 5 ms count for every feed involved).", + }, + []string{"aggregator", "chain", "region"}, + ) + prometheus.MustRegister(headLagFirst) + headLagRaces = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "head_lag_races_total", + Help: "Per-transaction races closed (trades seen by at least one feed).", + }, + []string{"chain", "region"}, + ) + prometheus.MustRegister(headLagRaces) + headLagFirstShare = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "head_lag_first_share_pct", + Help: "Share of the last 24 h of trades a feed reported first, in percent (in-process rolling window).", + }, + []string{"aggregator", "chain", "region"}, + ) + prometheus.MustRegister(headLagFirstShare) + + 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 +473,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)) @@ -466,3 +587,7 @@ func StartMetricsServer(addr string) error { return http.ListenAndServe(addr, mux) } + +// RecordFlashblockObserved counts one transaction seen on the Base +// preconfirmation stream. See base_flashblock_ref.go. +func RecordFlashblockObserved() { flashblockObserved.Inc() } diff --git a/harnesses/aggregator-head-lag/cmd/script/pending_match.go b/harnesses/aggregator-head-lag/cmd/script/pending_match.go new file mode 100644 index 000000000..90194396c --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/script/pending_match.go @@ -0,0 +1,127 @@ +package main + +import ( + "sync" + "time" +) + +// Deferred reference matching. +// +// Every provider monitor used to look the trade up in the reference clock +// at the instant it received the emission. That only works when the +// reference is earlier than the provider. On Solana it is not: our +// logsSubscribe observation lands about 0.2 s after Mobula and Serialized, +// who read a geyser, so at lookup time the entry does not exist yet and the +// emission is counted as unmatched. Measured in production before this +// change: Codex, the slowest, matched 5% of its Solana emissions; Mobula and +// Serialized, faster, matched 0%. The asymmetry is the order of arrival, +// not rate limiting. +// +// Emissions now wait in a short-lived buffer and are resolved when the +// reference catches up, or expired as a miss after pendingDeadline. This +// makes the match independent of which side arrives first, which is the +// property a reference clock needs to have on every chain, not just on +// Base where the flashblock stream happens to precede everyone. +// +// The buffer is also where the Base and Solana headline lag is computed: +// those chains publish the reference-based number, so a sample that never +// matches is dropped from head_lag_seconds rather than silently measured +// against the provider's own timestamp. See headlineLag. + +const ( + pendingDeadline = 8 * time.Second + pendingResolveTick = 200 * time.Millisecond +) + +type pendingEmission struct { + aggregator string + chain string + region string + hash string + receiveTime time.Time + lagBlocks int64 + providerLag float64 // receiveTime minus the provider's own on-chain timestamp +} + +type pendingQueue struct { + mu sync.Mutex + items []pendingEmission +} + +var pending = &pendingQueue{} + +// referenceChains are the chains whose headline lag is measured against our +// own reference clock. Base: the flashblock stream, which precedes the +// sealed block by ~1.6 s. +// +// Solana is handled by race.go instead (raceChains): no RPC WebSocket we +// can hold precedes the providers' geyser feeds, so its headline is the +// lag behind the first observation of the trade rather than behind our +// node. Add a chain here only once its reference is measured to arrive +// before every provider (head_lag_ref_seconds positive for all of them). +var referenceChains = map[string]bool{"base": true} + +// emitHeadLag is the single entry point for a provider emission. +// +// Chains outside referenceChains keep publishing the provider-timestamp +// figure immediately, exactly as before. Every chain enqueues for the +// reference match, so head_lag_ref_seconds is populated everywhere. +func emitHeadLag(aggregator, chain, region, hash string, receiveTime time.Time, lagBlocks int64, providerLag float64) { + // raceChains publish the lag behind the first observation instead + // (race.go); referenceChains publish the lag behind our node once the + // match resolves. Everything else keeps the provider-timestamp figure. + if !referenceChains[chain] && !raceChains[chain] { + RecordHeadLag(aggregator, chain, lagBlocks, providerLag, region, hash) + } + if hash == "" { + return + } + race.observe(aggregator, chain, region, hash, receiveTime, lagBlocks) + pending.mu.Lock() + pending.items = append(pending.items, pendingEmission{ + aggregator: aggregator, chain: chain, region: region, hash: hash, + receiveTime: receiveTime, lagBlocks: lagBlocks, providerLag: providerLag, + }) + pending.mu.Unlock() +} + +// resolveOne records what can be recorded for a matched emission. +func resolveOne(e pendingEmission, refAt time.Time) { + lag := e.receiveTime.Sub(refAt).Seconds() + RecordHeadLagRef(e.aggregator, e.chain, lag, e.region) + if referenceChains[e.chain] { + RecordHeadLag(e.aggregator, e.chain, e.lagBlocks, lag, e.region, e.hash) + } +} + +// runPendingResolver sweeps the buffer: matched entries are resolved, +// entries older than pendingDeadline are counted as misses. Never blocks a +// provider's read loop. +func runPendingResolver(stopChan <-chan struct{}) { + t := time.NewTicker(pendingResolveTick) + defer t.Stop() + for { + select { + case <-stopChan: + return + case <-t.C: + } + now := time.Now() + race.resolve(now) + pending.mu.Lock() + keep := pending.items[:0] + for _, e := range pending.items { + if refAt, ok := reference.lookup(e.chain, e.hash); ok { + resolveOne(e, refAt) + continue + } + if now.Sub(e.receiveTime) > pendingDeadline { + RecordHeadLagRefMiss(e.aggregator, e.chain, e.region) + continue + } + keep = append(keep, e) + } + pending.items = keep + pending.mu.Unlock() + } +} diff --git a/harnesses/aggregator-head-lag/cmd/script/race.go b/harnesses/aggregator-head-lag/cmd/script/race.go new file mode 100644 index 000000000..f0e8d8f9c --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/script/race.go @@ -0,0 +1,243 @@ +package main + +import ( + "math" + "sort" + "sync" + "time" +) + +// Per-transaction race between every feed that reports a trade. +// +// The question integrators actually ask is "which feed gives me the trade +// first, and by how much". The deferred matcher (pending_match.go) already +// buffers every emission by transaction hash; this file adds the race on +// top: for each (chain, region, hash) it keeps the arrival time of every +// provider plus our own node reference, closes the race raceWindow after +// the first arrival, and publishes +// +// - head_lag_first_total{aggregator,chain,region}: how often each feed +// (the node reference included, as aggregator="reference") reported +// the trade first. Arrivals within raceTie of the earliest count as +// first for every feed involved: below that, the order is network +// jitter between our probe and two servers, not a property of the +// feeds. +// - head_lag_races_total{chain,region}: races closed. +// - head_lag_first_share_pct{aggregator,chain,region}: the share over a +// rolling 24 h, computed in-process so the bench can read it as a +// plain gauge. +// +// On raceChains the headline head_lag_seconds becomes the lag behind the +// first observation of the trade instead of the lag behind the provider's +// own timestamp. Solana is the first such chain: there is no on-chain +// timestamp with sub-second precision, so Mobula's `date` (its ingestion +// time, ~100 ms constant) and Serialized's `at` (blockTime, whole +// seconds, +0.5 s on average) compared conventions rather than delivery; +// measured against a common clock the two feeds are 10-30 ms apart. No +// public or keyed RPC WebSocket precedes their geysers (Helius: ~110 ms +// behind on every trade; Alchemy: level on half the trades, 3.5 s late or +// absent on the other half; mainnet-beta: ~110 ms behind), so an absolute +// clock is not available without a geyser-grade feed. The reference still +// runs in the race: it validates that the hash exists on chain, and it +// bounds the field whenever a feed is slower than a plain node. +// +// Feeds that arrive after the race closed (GeckoTerminal polls, 15-100 s +// later) still get their lag against the recorded first arrival: closed +// races are kept raceRetention. + +const ( + raceWindow = 3 * time.Second + raceTie = 5 * time.Millisecond + raceRetention = 10 * time.Minute + raceShareSpan = 24 * time.Hour + raceMaxOpen = 50000 +) + +// raceChains publishes head_lag_seconds as the lag behind the first +// observation instead of the provider-timestamp figure. +var raceChains = map[string]bool{"solana": true} + +type raceObs struct { + aggregator string + at time.Time + lagBlocks int64 +} + +type raceEntry struct { + chain, region string + opened time.Time // first arrival, wall clock of this probe + t0 time.Time // earliest observation, reference included once closed + obs []raceObs + closed bool + void bool // closed with a single participant: nothing recorded + closedAt time.Time +} + +type raceResult struct { + at time.Time + winners []string +} + +type raceBook struct { + mu sync.Mutex + entries map[string]*raceEntry // "chain|region|hash" + // Rolling window of closed races per (chain, region) for the share + // gauge. Bounded by raceShareSpan. + history map[string][]raceResult + // Every feed that took part in a race per (chain, region), so a feed + // that never wins is still published at 0 %. + participants map[string]map[string]bool +} + +var race = &raceBook{ + entries: map[string]*raceEntry{}, + history: map[string][]raceResult{}, + participants: map[string]map[string]bool{}, +} + +// raceLagSeconds floors the published lag at 1 ms. The first feed is 0 by +// definition, but the site drops rows whose headline reads exactly zero +// (a zero p50 means "no measurement" everywhere else), which hid the +// leader on the Singapore tab where Mobula won every race. One +// millisecond is below the tie window and reads as "first". +func raceLagSeconds(d time.Duration) float64 { + return math.Max(d.Seconds(), 0.001) +} + +func raceKey(chain, region, hash string) string { + return chain + "|" + region + "|" + hash +} + +// observe records a provider arrival. Called from emitHeadLag for every +// chain; the headline substitution only applies to raceChains. +func (b *raceBook) observe(aggregator, chain, region, hash string, at time.Time, lagBlocks int64) { + if hash == "" { + return + } + k := raceKey(chain, region, hash) + b.mu.Lock() + defer b.mu.Unlock() + e, ok := b.entries[k] + if !ok { + if len(b.entries) >= raceMaxOpen { + return + } + e = &raceEntry{chain: chain, region: region, opened: at, t0: at} + b.entries[k] = e + } + if e.closed { + if e.void { + return // single-feed race: no ruler to measure against + } + // Late arrival: it lost, and its lag is against the recorded first. + b.note(chain, region, aggregator) + if raceChains[chain] { + RecordHeadLag(aggregator, chain, lagBlocks, raceLagSeconds(at.Sub(e.t0)), region, hash) + } + return + } + for _, o := range e.obs { + if o.aggregator == aggregator { + return // duplicate delivery (reconnect replay): keep the first + } + } + e.obs = append(e.obs, raceObs{aggregator: aggregator, at: at, lagBlocks: lagBlocks}) + if at.Before(e.t0) { + e.t0 = at + } +} + +func (b *raceBook) note(chain, region, aggregator string) { + pk := chain + "|" + region + if b.participants[pk] == nil { + b.participants[pk] = map[string]bool{} + } + b.participants[pk][aggregator] = true +} + +// resolve closes races older than raceWindow and expires closed ones past +// raceRetention. Called from the pending resolver tick. +func (b *raceBook) resolve(now time.Time) { + b.mu.Lock() + defer b.mu.Unlock() + for k, e := range b.entries { + if e.closed { + if now.Sub(e.closedAt) > raceRetention { + delete(b.entries, k) + } + continue + } + if now.Sub(e.opened) < raceWindow { + continue + } + b.closeLocked(e, k) + } +} + +func (b *raceBook) closeLocked(e *raceEntry, k string) { + hash := k[len(e.chain)+len(e.region)+2:] + // Our own node takes part when it saw the trade. It also validates + // that the hash is real; a race nobody but a single provider saw is + // still closed, but carries no reference lag. + refSeen := false + if refAt, ok := reference.lookup(e.chain, hash); ok { + refSeen = true + if refAt.Before(e.t0) { + e.t0 = refAt + } + e.obs = append(e.obs, raceObs{aggregator: "reference", at: refAt}) + } + // A race needs two participants to say anything about order. A trade + // only one feed reported (GeckoTerminal lists pool trades the push + // feeds filter out, and our node can miss one) would otherwise hand + // that feed a "first" and a zero lag it did not earn. + if len(e.obs) < 2 { + e.closed = true + e.void = true + e.closedAt = time.Now() + return + } + winners := []string{} + for _, o := range e.obs { + b.note(e.chain, e.region, o.aggregator) + delta := o.at.Sub(e.t0) + if delta <= raceTie { + winners = append(winners, o.aggregator) + headLagFirst.WithLabelValues(o.aggregator, e.chain, e.region).Inc() + } + if o.aggregator != "reference" && raceChains[e.chain] { + RecordHeadLag(o.aggregator, e.chain, o.lagBlocks, raceLagSeconds(delta), e.region, hash) + } + } + if !refSeen { + b.note(e.chain, e.region, "reference") + } + headLagRaces.WithLabelValues(e.chain, e.region).Inc() + e.closed = true + e.closedAt = time.Now() + + // Rolling 24 h share. + pk := e.chain + "|" + e.region + hist := append(b.history[pk], raceResult{at: e.closedAt, winners: winners}) + cut := e.closedAt.Add(-raceShareSpan) + i := 0 + for i < len(hist) && hist[i].at.Before(cut) { + i++ + } + hist = hist[i:] + b.history[pk] = hist + wins := map[string]int{} + for _, r := range hist { + for _, w := range r.winners { + wins[w]++ + } + } + names := make([]string, 0, len(b.participants[pk])) + for a := range b.participants[pk] { + names = append(names, a) + } + sort.Strings(names) + for _, a := range names { + headLagFirstShare.WithLabelValues(a, e.chain, e.region).Set(float64(wins[a]) / float64(len(hist)) * 100) + } +} 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..b44c67604 --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/script/reference_monitor.go @@ -0,0 +1,292 @@ +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}}, + // processed, not confirmed: measured 56 ms earlier on the same + // pool, and the deferred matcher makes the ordering safe. + map[string]any{"commitment": "processed"}, + }, + } + } 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..99d0b9d77 --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/script/serialized_head_lag_monitor.go @@ -0,0 +1,340 @@ +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() + emitHeadLag("serialized", chainName, config.MonitorRegion, txHash, receiveTime, 0, lagSeconds) + + // Reference series: our node clock, matched by hash. + 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/aggregator-head-lag/go.mod b/harnesses/aggregator-head-lag/go.mod index d6ccd878c..37cbda4f7 100644 --- a/harnesses/aggregator-head-lag/go.mod +++ b/harnesses/aggregator-head-lag/go.mod @@ -3,14 +3,16 @@ module mobula_latency_competitor go 1.24.4 require ( + github.com/andybalholm/brotli v1.0.6 github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 github.com/chromedp/chromedp v0.14.2 github.com/gorilla/websocket v1.5.3 github.com/prometheus/client_golang v1.23.2 + github.com/refraction-networking/utls v1.8.2 + golang.org/x/crypto v0.36.0 ) require ( - github.com/andybalholm/brotli v1.0.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect @@ -24,9 +26,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect - github.com/refraction-networking/utls v1.8.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/crypto v0.36.0 // indirect golang.org/x/sys v0.35.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/harnesses/apps/cmd/api/main.go b/harnesses/apps/cmd/api/main.go index 95aecfec1..f13540518 100644 --- a/harnesses/apps/cmd/api/main.go +++ b/harnesses/apps/cmd/api/main.go @@ -59,7 +59,7 @@ var deploymentMeta = map[string]struct{ Name, Slug, Category string }{ "dydx-v4:dydx-chain": {Name: "dYdX", Slug: "dydx", Category: "perps"}, "gmx-v2:arbitrum": {Name: "GMX v2 (Arbitrum)", Slug: "gmx", Category: "perps"}, "gmx-v2:avalanche": {Name: "GMX v2 (Avalanche)", Slug: "gmx", Category: "perps"}, - "gains-trade:arbitrum": {Name: "Gains.trade", Slug: "gains-trade", Category: "perps"}, + "gains-trade:arbitrum": {Name: "Gains", Slug: "gains-trade", Category: "perps"}, "drift:solana": {Name: "Drift", Slug: "drift", Category: "perps"}, "jupiter-perps:solana": {Name: "Jupiter Perps", Slug: "jupiter-perps", Category: "perps"}, "vertex:arbitrum": {Name: "Vertex", Slug: "vertex", Category: "perps"}, diff --git a/harnesses/bridge-monitor/cmd/monitor/across_bridge_test.go b/harnesses/bridge-monitor/cmd/monitor/across_bridge_test.go index 16edfe418..cfafe5266 100644 --- a/harnesses/bridge-monitor/cmd/monitor/across_bridge_test.go +++ b/harnesses/bridge-monitor/cmd/monitor/across_bridge_test.go @@ -8,8 +8,10 @@ import ( // Live response captured 2026-07-08 from // GET https://app.across.to/api/swap/approval?tradeType=exactInput&amount=300000000 -// &inputToken=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913&originChainId=8453 -// &outputToken=0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9&destinationChainId=42161 +// +// &inputToken=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913&originChainId=8453 +// &outputToken=0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9&destinationChainId=42161 +// // (Base USDC to Arbitrum USDT, the cross-asset corridor). Trimmed to the // fields the harness decodes; values are verbatim. const acrossLiveSwapResponse = `{"crossSwapType":"anyToBridgeable","fees":{"total":{"amount":"1099243","amountUsd":"1.099199999999999955","token":{"decimals":6,"symbol":"USDC","address":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","name":"USD Coin","chainId":8453},"pct":"3664146565862634","details":{"type":"total-breakdown","swapImpact":{"amount":"-158088","amountUsd":"-0.15808239547799996","token":{"decimals":6,"symbol":"USDC","address":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","name":"USD Coin","chainId":8453},"pct":"-526962396755870"},"app":{"amount":"0","amountUsd":"0.0","token":{"decimals":6,"symbol":"USDT","address":"0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9","name":"Tether USD","chainId":42161},"pct":"0"},"bridge":{"amount":"1258386","amountUsd":"1.257282395477999914","token":{"address":"0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2","decimals":6,"symbol":"USDT","chainId":8453},"pct":"4191108962618505","details":{"type":"across","lp":{"amount":"498437","amountUsd":"0.497999870750999996","token":{"address":"0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2","decimals":6,"symbol":"USDT","chainId":8453},"pct":"1660065971808872"},"relayerCapital":{"amount":"749162","amountUsd":"0.748504984925999928","token":{"address":"0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2","decimals":6,"symbol":"USDT","chainId":8453},"pct":"2495116421076843"},"destinationGas":{"amount":"6210800385526","amountUsd":"0.010777539801","token":{"chainId":42161,"address":"0x0000000000000000000000000000000000000000","decimals":18,"symbol":"ETH"},"pct":"35926569732789"}}}}}},"inputAmount":"300000000","expectedOutputAmount":"299156590","minOutputAmount":"298410564","expectedFillTime":2}` @@ -65,8 +67,8 @@ func TestAcrossRouteSupport(t *testing.T) { } for name, want := range map[string]bool{ "USDC_SOL_BASE": true, // Sol USDC to Base USDC - "USDC_BASE_USDT_ARB": true, // cross-asset via Swap API - "USDT_ARB_USDC_SOL": true, // cross-asset via Swap API + "USDC_BASE_ARB": true, // same-asset USDC via Swap API + "USDC_ARB_SOL": true, // same-asset USDC via Swap API "TRUMP_SOL_BRETT_BASE": false, // calibration route, excluded "USDC_ARB_HYPERCORE": false, // Across 999 is HyperEVM, not a HyperCore credit } { diff --git a/harnesses/bridge-monitor/cmd/monitor/balance.go b/harnesses/bridge-monitor/cmd/monitor/balance.go index bb9f5e1dd..d81c174c3 100644 --- a/harnesses/bridge-monitor/cmd/monitor/balance.go +++ b/harnesses/bridge-monitor/cmd/monitor/balance.go @@ -194,11 +194,11 @@ func (bc *BalanceChecker) fillEVMFromChain(result map[string]map[string]float64, case "Base": reads = []tokenRead{{baseUSDCAddr, []string{"USDC"}}} case "Arbitrum": - // Both USDT0 and USDT symbols: cycle_sim reads "USDT0" (Mobula naming) - // while route helpers fall back to "USDT". + // Triangle is all-USDC now; USDC is the home leg. USDT0 kept as a + // read only to monitor any leftover after the USDT->USDC migration. reads = []tokenRead{ - {arbUSDTAddr, []string{"USDT0", "USDT"}}, {arbUSDCAddr, []string{"USDC"}}, + {arbUSDTAddr, []string{"USDT0", "USDT"}}, } default: return false @@ -266,7 +266,7 @@ func indexAssets(result map[string]map[string]float64, resp *MobulaWalletRespons // fetchWalletBalance fetches balance for a single wallet (all chains) func (bc *BalanceChecker) fetchWalletBalance(address string) (*MobulaWalletResponse, error) { - url := fmt.Sprintf("https://demo-api.mobula.io/api/1/wallet/portfolio?wallet=%s", address) + url := fmt.Sprintf(mobulaAPIBase()+"/api/1/wallet/portfolio?wallet=%s", address) req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -298,7 +298,7 @@ func (bc *BalanceChecker) fetchWalletBalance(address string) (*MobulaWalletRespo // fetchWalletBalanceByChain fetches balance for a specific chain func (bc *BalanceChecker) fetchWalletBalanceByChain(address, chain string) (*MobulaWalletResponse, error) { - url := fmt.Sprintf("https://demo-api.mobula.io/api/1/wallet/portfolio?wallet=%s&blockchains=%s", address, chain) + url := fmt.Sprintf(mobulaAPIBase()+"/api/1/wallet/portfolio?wallet=%s&blockchains=%s", address, chain) req, err := http.NewRequest("GET", url, nil) if err != nil { diff --git a/harnesses/bridge-monitor/cmd/monitor/config.go b/harnesses/bridge-monitor/cmd/monitor/config.go index 0b828f92c..1da0d8a8f 100644 --- a/harnesses/bridge-monitor/cmd/monitor/config.go +++ b/harnesses/bridge-monitor/cmd/monitor/config.go @@ -12,13 +12,13 @@ import ( type Config struct { // API Keys - RelayAPIKey string - MobulaAPIKey string - DebridgeAPIKey string - LiFiAPIKey string - NearIntentsAPIKey string - SquidIntegratorID string - SocketAPIKey string + RelayAPIKey string + MobulaAPIKey string + DebridgeAPIKey string + LiFiAPIKey string + NearIntentsAPIKey string + SquidIntegratorID string + SocketAPIKey string // Wallet Configuration WalletEVMPrivateKey string @@ -27,13 +27,15 @@ type Config struct { WalletSOLAddress string // Execution Configuration - ExecutionMode string // "dry-run", "single-test", "production" - Freq5USD time.Duration // Frequency for $5 tests - Freq50USD time.Duration // Frequency for $50 tests - Freq300USD time.Duration // Frequency for $300 tests - EnableDebridge bool // Execute Debridge (expensive) - MaxDailySpendUSD float64 // Safety cap - TestAmountUSD float64 // Override test amount (for testing with small amounts) + ExecutionMode string // "dry-run", "single-test", "production" + Freq3USD time.Duration // Frequency for $3 tests + Freq30USD time.Duration // Frequency for $30 tests + EnableDebridge bool // Execute Debridge (expensive) + EnableR4RoundTrip bool // Execute R4 TRUMP<->BRETT round-trip (memes, not fee-neutral) + EnableR5Hypercore bool // Execute R5 Arb<->HyperCore round-trip (needs HL withdraw path) + EnableNearIntentsExec bool // Execute the USDC-only triangle via Near Intents 1Click + MaxDailySpendUSD float64 // Safety cap + TestAmountUSD float64 // Override test amount (for testing with small amounts) // General MonitorRegion string @@ -55,6 +57,16 @@ func parseDuration(s string, defaultVal time.Duration) time.Duration { return d } +// mobulaAPIBase returns the Mobula REST base URL (no trailing slash). Defaults +// to the production host; override with MOBULA_API_BASE. We moved off +// demo-api.mobula.io to the production api.mobula.io endpoint. +func mobulaAPIBase() string { + if v := strings.TrimSpace(os.Getenv("MOBULA_API_BASE")); v != "" { + return strings.TrimRight(v, "/") + } + return "https://api.mobula.io" +} + // parseFloat parses a float string, returns default if invalid func parseFloat(s string, defaultVal float64) float64 { if s == "" { @@ -91,14 +103,20 @@ func loadEnv() (*Config, error) { config.ExecutionMode = "dry-run" // Safe default } - // Frequencies (default: $5 daily, $50 2x/week, $300 2x/month) - config.Freq5USD = parseDuration(os.Getenv("FREQ_5_USD"), 24*time.Hour) - config.Freq50USD = parseDuration(os.Getenv("FREQ_50_USD"), 84*time.Hour) // ~3.5 days - config.Freq300USD = parseDuration(os.Getenv("FREQ_300_USD"), 168*time.Hour) // 7 days (weekly) + // Frequencies (default: $3 daily, $30 2x/week) + config.Freq3USD = parseDuration(os.Getenv("FREQ_3_USD"), 24*time.Hour) + config.Freq30USD = parseDuration(os.Getenv("FREQ_30_USD"), 84*time.Hour) // ~3.5 days // Debridge execution (default: disabled, too expensive) config.EnableDebridge = os.Getenv("ENABLE_DEBRIDGE_EXEC") == "true" + // R4 (TRUMP<->BRETT) and R5 (Arb<->HyperCore) round-trip execution are + // opt-in: both move value across non-stable or asymmetric legs, so they + // stay quote-only until deliberately enabled and validated in single-test. + config.EnableR4RoundTrip = os.Getenv("ENABLE_R4_ROUNDTRIP_EXEC") == "true" + config.EnableR5Hypercore = os.Getenv("ENABLE_R5_HYPERCORE_EXEC") == "true" + config.EnableNearIntentsExec = os.Getenv("ENABLE_NEARINTENTS_EXEC") == "true" + // Max daily spend (default: $10/day for safety) config.MaxDailySpendUSD = parseFloat(os.Getenv("MAX_DAILY_SPEND_USD"), 10.0) @@ -216,10 +234,12 @@ func (c *Config) LogConfig() { log.Printf(" EVM Private Key: %s", maskKey(c.WalletEVMPrivateKey)) log.Printf(" Solana Private Key: %s", maskKey(c.WalletSOLPrivateKey)) log.Printf(" Execution Mode: %s", c.ExecutionMode) - log.Printf(" $5 Frequency: %v", c.Freq5USD) - log.Printf(" $50 Frequency: %v", c.Freq50USD) - log.Printf(" $300 Frequency: %v", c.Freq300USD) + log.Printf(" $3 Frequency: %v", c.Freq3USD) + log.Printf(" $30 Frequency: %v", c.Freq30USD) log.Printf(" Debridge Execution: %v", c.EnableDebridge) + log.Printf(" R4 Round-trip Execution: %v", c.EnableR4RoundTrip) + log.Printf(" R5 HyperCore Execution: %v", c.EnableR5Hypercore) + log.Printf(" Near Intents Execution: %v", c.EnableNearIntentsExec) log.Printf(" Max Daily Spend: $%.2f", c.MaxDailySpendUSD) if c.TestAmountUSD > 0 { log.Printf(" Test Amount Override: $%.2f", c.TestAmountUSD) diff --git a/harnesses/bridge-monitor/cmd/monitor/cycle_sim.go b/harnesses/bridge-monitor/cmd/monitor/cycle_sim.go index 648311927..822bbb567 100644 --- a/harnesses/bridge-monitor/cmd/monitor/cycle_sim.go +++ b/harnesses/bridge-monitor/cmd/monitor/cycle_sim.go @@ -30,7 +30,7 @@ func SimulateTriangleCycle(balances map[string]map[string]float64, tier float64) solUSDC := balances["Solana"]["USDC"] baseUSDC := balances["Base"]["USDC"] - arbUSDT := balances["Arbitrum"]["USDT0"] + arbUSDC := balances["Arbitrum"]["USDC"] // Per-bridge leg requirement is 1×tier (not 3×): one TX at a time, settlement // replenishes the next leg before it has to source. @@ -60,15 +60,15 @@ func SimulateTriangleCycle(balances map[string]map[string]float64, tier float64) return sim } - // R3: Arb USDT → Sol USDC. R2 of current bridge deposited ~tier × 0.98 on Arb. - arbEff := arbUSDT + need*netFactor + // R3: Arb USDC → Sol USDC. R2 of current bridge deposited ~tier × 0.98 on Arb. + arbEff := arbUSDC + need*netFactor if arbEff < need { - sim.BlockLeg = "R3 Arb USDT" + sim.BlockLeg = "R3 Arb USDC" sim.Needed = need sim.Available = arbEff - sim.Reason = fmt.Sprintf("R3 Arb→Sol blocked: need $%.2f USDT on Arbitrum (incl. R2 inflow ≈$%.2f), effective $%.2f", + sim.Reason = fmt.Sprintf("R3 Arb→Sol blocked: need $%.2f USDC on Arbitrum (incl. R2 inflow ≈$%.2f), effective $%.2f", need, need*netFactor, arbEff) - sim.RefillChain, sim.RefillToken, sim.RefillUSD = "Arbitrum", "USDT", need-arbEff + sim.RefillChain, sim.RefillToken, sim.RefillUSD = "Arbitrum", "USDC", need-arbEff return sim } diff --git a/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go b/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go index 031eb0c34..408611e19 100644 --- a/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go @@ -28,6 +28,12 @@ func debridgeChainID(chain string) int64 { return 8453 case "arbitrum": return 42161 + case "bnb", "bsc": + return 56 + case "ethereum", "eth": + return 1 + case "robinhood": + return 4663 } return 0 } @@ -35,8 +41,9 @@ func debridgeChainID(chain string) int64 { // Debridge protocol charges a fixed amount in native tokens that we convert to USD // using live spot prices (5min cache). Hardcoded fallbacks are intentionally // conservative so an API outage doesn't make Debridge look artificially cheap. -// Solana: 0.015 SOL native fix fee -// EVM: 0.001 ETH native fix fee +// +// Solana: 0.015 SOL native fix fee +// EVM: 0.001 ETH native fix fee func debridgeFixFeeUSD(fromChain string) float64 { switch strings.ToLower(fromChain) { case "solana": @@ -56,9 +63,9 @@ type DebridgeQuoteResponse struct { ApproximateUsdValue float64 `json:"approximateUsdValue"` } `json:"dstChainTokenOut"` } `json:"estimation"` - FixFee string `json:"fixFee"` - ProtocolFeeApproximateUsdValue float64 `json:"protocolFeeApproximateUsdValue"` - Order struct { + FixFee string `json:"fixFee"` + ProtocolFeeApproximateUsdValue float64 `json:"protocolFeeApproximateUsdValue"` + Order struct { ApproximateFulfillmentDelay int64 `json:"approximateFulfillmentDelay"` } `json:"order"` ErrorMessage string `json:"errorMessage,omitempty"` diff --git a/harnesses/bridge-monitor/cmd/monitor/executor.go b/harnesses/bridge-monitor/cmd/monitor/executor.go index 7428375fc..32a4946d6 100644 --- a/harnesses/bridge-monitor/cmd/monitor/executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/executor.go @@ -25,9 +25,8 @@ const ( // zeroed by every restart. type ExecutionConfig struct { Mode ExecutionMode - Freq5USD time.Duration // How often to run $5 tests - Freq50USD time.Duration // How often to run $50 tests - Freq300USD time.Duration // How often to run $300 tests + Freq3USD time.Duration // How often to run $3 tests + Freq30USD time.Duration // How often to run $30 tests EnableDebridge bool // Whether to execute Debridge (expensive) MaxDailySpendUSD float64 // Safety cap on daily spending } @@ -38,10 +37,11 @@ type ExecutionResult struct { Route TestRoute AmountUSD float64 QuoteLatencyMs int64 - ExecutionLatencyMs int64 // Time from broadcast to funds received - E2ELatencyMs int64 // Time from quote start to funds received + ExecutionLatencyMs int64 // Time from broadcast to funds received + E2ELatencyMs int64 // Time from quote start to funds received Success bool Reverted bool + Refunded bool // subset of Reverted: provider returned capital (status "refunded") Error error QuoteFeeUSD float64 // Fee from quote ActualFeeUSD float64 // Actual fee paid (input - output) @@ -56,6 +56,7 @@ type ExecutionResult struct { FeesPercent float64 CostUSD float64 OutputUSD float64 // What landed on destination (the fill) + ExecGasUSD float64 // Our on-chain gas paid (approve + deposit), source native delta } // Executor handles the execution loop @@ -68,6 +69,7 @@ type Executor struct { relay *RelayBridge lifi *LiFiBridge debridge *DebridgeBridge + nearIntents *NearIntentsBridge region string slack *SlackNotifier spend *SpendTracker @@ -280,12 +282,9 @@ func (e *Executor) RunReal(route TestRoute, amountUSD float64) []*ExecutionResul return results } - // Calculate raw units - for USDC/USDT amount equals USD, for TRUMP convert - amount := amountUSD - if route.Name == "TRUMP_SOL_BRETT_BASE" { - amount = amountUSD / TokenPriceUSD("TRUMP", 2.55) // live TRUMP price (5min cache) - } - rawUnits := toRawUnits(amount) + // Calculate raw units. Stables map 1:1 at 6 decimals; TRUMP/BRETT convert + // via live price and their own decimals (see sourceNativeAmount). + amount, rawUnits := sourceNativeAmount(route, amountUSD) // Execute on each bridge (except Debridge - too expensive) bridges := []string{"mobula", "relay", "lifi"} @@ -333,11 +332,9 @@ func (e *Executor) RunBridgeOnRoute(bridge string, route TestRoute, amountUSD fl return nil } - amount := amountUSD - if route.Name == "TRUMP_SOL_BRETT_BASE" { - amount = amountUSD / TokenPriceUSD("TRUMP", 2.55) // live TRUMP price (5min cache) - } - rawUnits := toRawUnits(amount) + // Calculate raw units. Stables map 1:1 at 6 decimals; TRUMP/BRETT convert + // via live price and their own decimals (see sourceNativeAmount). + amount, rawUnits := sourceNativeAmount(route, amountUSD) result := e.executeOnBridge(bridge, route, amount, amountUSD, rawUnits) if result == nil { @@ -379,6 +376,15 @@ func (e *Executor) executeOnBridge(bridge string, route TestRoute, amount, amoun log.Printf(" ⚠️ pre-execution balance read failed (%v) — falling back to quote-projected fill", preBalErr) } + // Source-chain native balance before execution, to measure the real gas we + // pay (approve + deposit) as the pre-minus-post delta. Single-flight (execMu) + // guarantees no other tx moves it during this one leg. + srcOwner := e.walletManager.EVMAddress + if route.FromChain == "Solana" { + srcOwner = e.walletManager.SolanaAddress + } + preNativeUSD, preNativeErr := e.txExecutor.nativeBalanceUSD(route.FromChain, srcOwner) + // PHASE 1: Get quote with TX data quoteStart := time.Now() var txHash string @@ -391,6 +397,8 @@ func (e *Executor) executeOnBridge(bridge string, route TestRoute, amount, amoun result, txHash, err = e.executeRelay(route, rawUnits, quoteStart) case "lifi": result, txHash, err = e.executeLiFi(route, rawUnits, quoteStart) + case "near-intents": + result, txHash, err = e.executeNearIntents(route, amountUSD, rawUnits, quoteStart) } // Populate route fields for Slack (do this before error check so failed results have route info) @@ -437,6 +445,15 @@ func (e *Executor) executeOnBridge(bridge string, route TestRoute, amount, amoun } } + // Real gas we paid this leg = source native balance delta (approve + deposit). + if preNativeErr == nil { + if postNativeUSD, nerr := e.txExecutor.nativeBalanceUSD(route.FromChain, srcOwner); nerr == nil { + if g := preNativeUSD - postNativeUSD; g > 0 { + result.ExecGasUSD = g + } + } + } + result.FeesUSD = result.ActualFeeUSD if amountUSD > 0 { result.FeesPercent = (result.ActualFeeUSD / amountUSD) * 100 @@ -621,6 +638,7 @@ func (e *Executor) executeMobula(route TestRoute, amount float64, quoteStart tim result.ActualFeeUSD = result.QuoteFeeUSD } else if status.Status == "refunded" { result.Reverted = true + result.Refunded = true log.Printf(" [mobula] ⚠️ Transaction was refunded!") } @@ -758,6 +776,7 @@ func (e *Executor) executeRelay(route TestRoute, rawUnits string, quoteStart tim result.ActualFeeUSD = result.QuoteFeeUSD } else if status.Status == "refunded" { result.Reverted = true + result.Refunded = true log.Printf(" [relay] ⚠️ Transaction was refunded!") } @@ -889,6 +908,7 @@ func (e *Executor) executeLiFi(route TestRoute, rawUnits string, quoteStart time result.ActualFeeUSD = result.QuoteFeeUSD } else if status.Status == "refunded" || status.Status == "failed" { result.Reverted = true + result.Refunded = status.Status == "refunded" log.Printf(" [lifi] ⚠️ Transaction was refunded/failed!") } @@ -918,6 +938,11 @@ func (e *Executor) recordExecutionMetrics(result *ExecutionResult) { bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(result.QuoteLatencyMs)) bridgeExecutionLatency.WithLabelValues(labels...).Observe(float64(result.ExecutionLatencyMs)) bridgeE2ELatency.WithLabelValues(labels...).Observe(float64(result.E2ELatencyMs)) + // Exact latency gauge (only on a real fill): lets the bench read the true + // observed value via quantile_over_time instead of a coarse bucket midpoint. + if result.Success && result.ExecutionLatencyMs > 0 { + bridgeExecLatencyMs.WithLabelValues(labels...).Set(float64(result.ExecutionLatencyMs)) + } // Record success/revert + consecutive-failure streak (used for paging alerts). if result.Success { @@ -928,6 +953,23 @@ func (e *Executor) recordExecutionMetrics(result *ExecutionResult) { bridgeReverts.WithLabelValues(labels...).Inc() bridgeConsecutiveFailures.WithLabelValues(result.Bridge, e.region).Inc() } + if result.Refunded { + bridgeRefunds.WithLabelValues(labels...).Inc() + // How fast the money came back. ExecutionLatencyMs is set to + // broadcast→resolution before the refund branch, so it is the + // broadcast-to-refund latency here. + if result.ExecutionLatencyMs > 0 { + bridgeRefundLatency.WithLabelValues(labels...).Observe(float64(result.ExecutionLatencyMs)) + } + } + // Stuck: the deposit broadcast but the bridge status never resolved to a + // fill or a refund within the poll window (status poll timed out with a + // TxHash in hand). These are the swaps a real user would have to chase + // manually — the "not automatically handled" rate. + if result.TxHash != "" && !result.Success && !result.Reverted && + result.Error != nil && strings.Contains(result.Error.Error(), "status poll failed") { + bridgeStuck.WithLabelValues(labels...).Inc() + } if result.Error != nil { bridgeErrors.WithLabelValues(append(labels, "execution_failed")...).Inc() if !result.Reverted { @@ -935,15 +977,38 @@ func (e *Executor) recordExecutionMetrics(result *ExecutionResult) { } } - // Record fees + // Record fees + the new execution-cost metrics bridgeFeesUSD.WithLabelValues(labels...).Set(result.ActualFeeUSD) if result.AmountUSD > 0 { bridgeFeesPercent.WithLabelValues(labels...).Set((result.ActualFeeUSD / result.AmountUSD) * 100) } + if result.OutputUSD > 0 { + bridgeRealizedOutputUSD.WithLabelValues(labels...).Set(result.OutputUSD) + } + // Execution slippage vs quote = realized fee - quote-projected fee. Only on + // a real fill: on a revert / refund / pre-broadcast failure there is no + // realized fee (ActualFeeUSD stays 0 while QuoteFeeUSD was set), so recording + // it would inject spurious 0 / negative samples into the realized-cost bench. + if result.Success { + bridgeQuoteSlippageUSD.WithLabelValues(labels...).Set(result.ActualFeeUSD - result.QuoteFeeUSD) + } + if result.ExecGasUSD > 0 { + bridgeExecGasUSD.WithLabelValues(labels...).Set(result.ExecGasUSD) + } } // getSourceTokenName returns the source token name for balance checking (legacy - fallback) func getSourceTokenName(route TestRoute) string { + // Resolve by source token address first so reverse legs (e.g. the R4 + // return BRETT→TRUMP) are classified correctly regardless of route name. + switch strings.ToLower(route.FromToken) { + case "6p6xghyf7aee6tzksmfsko444wqop15icusqi2jfgipn": + return "TRUMP" + case "0x532f27101965dd16442e59d40670faf5ebb142e4": + return "BRETT" + case "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9": + return "USDT" + } switch route.Name { case "TRUMP_SOL_BRETT_BASE": return "TRUMP" @@ -954,6 +1019,23 @@ func getSourceTokenName(route TestRoute) string { } } +// sourceNativeAmount converts a USD notional into the source token's native +// amount and its raw on-chain units, handling non-stable / non-6-decimal +// sources: TRUMP (6 dec) and BRETT (18 dec, only ever a source on the R4 +// return leg). USDC/USDT map 1:1 at 6 decimals. +func sourceNativeAmount(route TestRoute, amountUSD float64) (float64, string) { + switch getSourceTokenName(route) { + case "TRUMP": + amt := amountUSD / TokenPriceUSD("TRUMP", 2.55) + return amt, toRawUnits(amt) + case "BRETT": + amt := amountUSD / TokenPriceUSD("BRETT", 0.0072) + return amt, toRawUnitsDec(amt, 18) + default: + return amountUSD, toRawUnits(amountUSD) + } +} + // getAvailableBalance looks up balance first by contract address (robust against symbol mismatch // like USDT vs USDT0), then falls back to symbol name. func getAvailableBalance(balances map[string]map[string]float64, route TestRoute) float64 { @@ -973,12 +1055,9 @@ func getAvailableBalance(balances map[string]map[string]float64, route TestRoute func (e *Executor) testBridgeDryRun(bridge string, route TestRoute, amountUSD float64) { log.Printf(" [%s] Simulating $%.0f %s...", bridge, amountUSD, route.Name) - // Calculate raw units - for USDC/USDT amount equals USD, for TRUMP convert - amount := amountUSD - if route.Name == "TRUMP_SOL_BRETT_BASE" { - amount = amountUSD / TokenPriceUSD("TRUMP", 2.55) // live TRUMP price (5min cache) - } - rawUnits := toRawUnits(amount) + // Calculate raw units. Stables map 1:1 at 6 decimals; TRUMP/BRETT convert + // via live price and their own decimals (see sourceNativeAmount). + amount, rawUnits := sourceNativeAmount(route, amountUSD) // Determine sender address based on source chain senderAddress := e.walletManager.EVMAddress @@ -1110,9 +1189,8 @@ func (e *Executor) ValidateSetup() error { // Check execution config log.Printf(" ⚙️ Mode: %s", e.config.Mode) - log.Printf(" ⚙️ $5 frequency: %v", e.config.Freq5USD) - log.Printf(" ⚙️ $50 frequency: %v", e.config.Freq50USD) - log.Printf(" ⚙️ $300 frequency: %v", e.config.Freq300USD) + log.Printf(" ⚙️ $3 frequency: %v", e.config.Freq3USD) + log.Printf(" ⚙️ $30 frequency: %v", e.config.Freq30USD) log.Printf(" ⚙️ Debridge execution: %v", e.config.EnableDebridge) log.Printf(" ⚙️ Max daily spend: $%.2f", e.config.MaxDailySpendUSD) @@ -1124,34 +1202,30 @@ func (e *Executor) ValidateSetup() error { func (e *Executor) EstimateMonthlyCost() { log.Println("💰 Estimating monthly costs...") - // Costs per execution (from analysis). $300 is the new "large ticket" tier - // (down from $500 — capital constraint on current wallet, see README). - costPer5 := 1.55 // M/R/L combined for 3 routes - costPer50 := 2.85 - costPer300 := 9.02 // ~$3.01/cycle × 3 bridges = 9 TX + // Costs per execution (from analysis). Two tiers only ($3 daily, $30 2x/wk); + // the fee is dominated by fixed per-TX gas, so the small notionals cost + // almost the same as the retired $5/$50 rungs. + costPer3 := 1.55 // M/R/L combined for 3 routes + costPer30 := 2.85 if e.config.EnableDebridge { - costPer5 += 8.30 - costPer50 += 8.95 - costPer300 += 11.00 + costPer3 += 8.30 + costPer30 += 8.95 } // Calculate monthly executions based on frequency daysInMonth := 30.0 - exec5PerMonth := (24 * daysInMonth) / e.config.Freq5USD.Hours() - exec50PerMonth := (24 * daysInMonth) / e.config.Freq50USD.Hours() - exec300PerMonth := (24 * daysInMonth) / e.config.Freq300USD.Hours() + exec3PerMonth := (24 * daysInMonth) / e.config.Freq3USD.Hours() + exec30PerMonth := (24 * daysInMonth) / e.config.Freq30USD.Hours() - cost5 := exec5PerMonth * costPer5 - cost50 := exec50PerMonth * costPer50 - cost300 := exec300PerMonth * costPer300 + cost3 := exec3PerMonth * costPer3 + cost30 := exec30PerMonth * costPer30 - totalMonthly := cost5 + cost50 + cost300 + totalMonthly := cost3 + cost30 - log.Printf(" $5 tests: %.0f/month × $%.2f = $%.2f", exec5PerMonth, costPer5, cost5) - log.Printf(" $50 tests: %.0f/month × $%.2f = $%.2f", exec50PerMonth, costPer50, cost50) - log.Printf(" $300 tests: %.0f/month × $%.2f = $%.2f", exec300PerMonth, costPer300, cost300) + log.Printf(" $3 tests: %.0f/month × $%.2f = $%.2f", exec3PerMonth, costPer3, cost3) + log.Printf(" $30 tests: %.0f/month × $%.2f = $%.2f", exec30PerMonth, costPer30, cost30) log.Printf(" ─────────────────────────────────") log.Printf(" TOTAL: $%.2f/month", totalMonthly) @@ -1173,14 +1247,13 @@ func (e *Executor) EstimateMonthlyCost() { // PrintExecutionPlan shows what will be executed func (e *Executor) PrintExecutionPlan() { plan := map[string]interface{}{ - "mode": e.config.Mode, - "freq_5_usd": e.config.Freq5USD.String(), - "freq_50_usd": e.config.Freq50USD.String(), - "freq_300_usd": e.config.Freq300USD.String(), - "enable_debridge": e.config.EnableDebridge, - "max_daily_spend": e.config.MaxDailySpendUSD, - "evm_address": e.walletManager.EVMAddress, - "solana_address": e.walletManager.SolanaAddress, + "mode": e.config.Mode, + "freq_3_usd": e.config.Freq3USD.String(), + "freq_30_usd": e.config.Freq30USD.String(), + "enable_debridge": e.config.EnableDebridge, + "max_daily_spend": e.config.MaxDailySpendUSD, + "evm_address": e.walletManager.EVMAddress, + "solana_address": e.walletManager.SolanaAddress, } planJSON, _ := json.MarshalIndent(plan, "", " ") diff --git a/harnesses/bridge-monitor/cmd/monitor/gas_topup.go b/harnesses/bridge-monitor/cmd/monitor/gas_topup.go index 597dd7a3f..6e6f7d14d 100644 --- a/harnesses/bridge-monitor/cmd/monitor/gas_topup.go +++ b/harnesses/bridge-monitor/cmd/monitor/gas_topup.go @@ -27,7 +27,7 @@ const gasTierSafeBuffer = 1.20 const gasMinMeaningfulUSD = 5.0 type gasChain struct { - Chain string + Chain string NativeSym string // PrimaryStable is the stablecoin sold first for gas (contract address). PrimaryStable string diff --git a/harnesses/bridge-monitor/cmd/monitor/hyperliquid_withdraw.go b/harnesses/bridge-monitor/cmd/monitor/hyperliquid_withdraw.go new file mode 100644 index 000000000..cdc35d5f6 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/hyperliquid_withdraw.go @@ -0,0 +1,274 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "math/big" + "net/http" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" +) + +// Hyperliquid native withdrawal (HyperCore -> Arbitrum). +// +// R5's deposit leg (Arb USDC -> HyperCore) is a normal provider bridge, so it +// is benchmarked per bridge like every other route. But HyperCore has no +// provider bridge OUT: withdrawing back to Arbitrum is an HL-native "withdraw3" +// signed action, not something Mobula/Relay/LiFi quote. Without this file R5 +// would be a one-way capital drain; with it the loop conserves Arb USDC. +// +// withdraw3 is a user-signed action (EIP-712), NOT an L1 agent action, so it +// signs with the wallet's own EVM key against the fixed HyperliquidSignTransaction +// domain. Funds land on Arbitrum to the same address after HL validator +// processing (a few minutes), minus HL's flat withdrawal fee (~$1). Minimum +// withdrawal is $2, so the $3/$30 tiers both clear it (the $1 flat fee on a $3 +// round-trip is steep, but it is the true cost of the HyperCore return path and +// is what the benchmark is meant to expose). + +const ( + hyperliquidAPIBase = "https://api.hyperliquid.xyz" + hyperliquidChainName = "Mainnet" + // Arbitrum chain id; withdraw3 must sign and declare the same value. + hyperliquidSignatureChainID = "0xa4b1" // 42161 + hyperliquidSignatureChainIDInt = 42161 + // HL rejects withdrawals below this notional. + hyperliquidMinWithdrawUSD = 2.0 +) + +// HyperliquidClient performs read (info) and write (exchange) calls against the +// Hyperliquid API using the bridge-monitor's EVM key for signing. +type HyperliquidClient struct { + http *http.Client + tx *TxExecutor // provides the EVM private key + address +} + +// NewHyperliquidClient returns nil when no EVM key is available (quote-only +// mode): callers must treat nil as "HL withdraw unavailable" and skip R5 exec. +func NewHyperliquidClient(tx *TxExecutor) *HyperliquidClient { + if tx == nil || tx.EVMPrivateKey() == nil { + return nil + } + return &HyperliquidClient{ + http: &http.Client{Timeout: 30 * time.Second}, + tx: tx, + } +} + +// clearinghouseState is the subset of HL's perp account state we need: the USDC +// amount that is free to withdraw right now. +type clearinghouseState struct { + Withdrawable string `json:"withdrawable"` +} + +// Withdrawable returns the USDC (in USD) currently withdrawable from the perp +// account of `address` on HyperCore. Used to confirm an R5 deposit filled and +// to size the return withdrawal. +func (c *HyperliquidClient) Withdrawable(address string) (float64, error) { + body := map[string]string{"type": "clearinghouseState", "user": strings.ToLower(address)} + raw, err := c.postJSON(hyperliquidAPIBase+"/info", body) + if err != nil { + return 0, err + } + var st clearinghouseState + if err := json.Unmarshal(raw, &st); err != nil { + return 0, fmt.Errorf("parse clearinghouseState: %w (body: %s)", err, truncateHL(string(raw), 200)) + } + if st.Withdrawable == "" { + return 0, nil + } + v, err := strconv.ParseFloat(st.Withdrawable, 64) + if err != nil { + return 0, fmt.Errorf("parse withdrawable %q: %w", st.Withdrawable, err) + } + return v, nil +} + +// withdraw3Action is the exact JSON object that gets both signed and sent. +// Field order matters for HL's action hashing on some actions; withdraw3 is +// EIP-712 so order in the struct is not hashed, but we keep it stable anyway. +type withdraw3Action struct { + Type string `json:"type"` + HyperliquidChain string `json:"hyperliquidChain"` + SignatureChainID string `json:"signatureChainId"` + Amount string `json:"amount"` + Time uint64 `json:"time"` + Destination string `json:"destination"` +} + +type hlSignature struct { + R string `json:"r"` + S string `json:"s"` + V uint64 `json:"v"` +} + +type exchangeRequest struct { + Action withdraw3Action `json:"action"` + Nonce uint64 `json:"nonce"` + Signature hlSignature `json:"signature"` +} + +type exchangeResponse struct { + Status string `json:"status"` + Response json.RawMessage `json:"response"` +} + +// Withdraw signs and submits a withdraw3 action moving `amountUSD` of USDC from +// the HyperCore perp account back to `destination` on Arbitrum. Returns nil on +// HL "status":"ok". It never moves more than what is withdrawable and refuses +// amounts below HL's minimum. +func (c *HyperliquidClient) Withdraw(amountUSD float64, destination string) error { + if amountUSD < hyperliquidMinWithdrawUSD { + return fmt.Errorf("withdraw amount $%.2f below HL minimum $%.2f", amountUSD, hyperliquidMinWithdrawUSD) + } + dest := strings.ToLower(strings.TrimSpace(destination)) + if !strings.HasPrefix(dest, "0x") || len(dest) != 42 { + return fmt.Errorf("invalid destination address %q", destination) + } + + nonce := uint64(time.Now().UnixMilli()) + // HL expects the amount as a plain decimal string. Trim trailing zeros so + // "3" stays "3" and "2.5" stays "2.5". + amountStr := strconv.FormatFloat(amountUSD, 'f', -1, 64) + + action := withdraw3Action{ + Type: "withdraw3", + HyperliquidChain: hyperliquidChainName, + SignatureChainID: hyperliquidSignatureChainID, + Amount: amountStr, + Time: nonce, + Destination: dest, + } + + sig, err := c.signWithdraw(action) + if err != nil { + return fmt.Errorf("sign withdraw3: %w", err) + } + + reqBody := exchangeRequest{Action: action, Nonce: nonce, Signature: sig} + raw, err := c.postJSON(hyperliquidAPIBase+"/exchange", reqBody) + if err != nil { + return err + } + + var resp exchangeResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return fmt.Errorf("parse exchange response: %w (body: %s)", err, truncateHL(string(raw), 300)) + } + if !strings.EqualFold(resp.Status, "ok") { + return fmt.Errorf("HL withdraw rejected: %s", truncateHL(string(raw), 300)) + } + log.Printf("🏦 HL withdraw3 accepted: $%s USDC -> %s (nonce %d)", amountStr, dest, nonce) + return nil +} + +// signWithdraw builds the EIP-712 digest for the withdraw3 action and signs it +// with the wallet's EVM key. The domain and type list are fixed by HL and MUST +// match exactly or the signer recovers to the wrong address and HL rejects it. +func (c *HyperliquidClient) signWithdraw(a withdraw3Action) (hlSignature, error) { + digest, err := withdraw3Digest(a) + if err != nil { + return hlSignature{}, fmt.Errorf("typed-data hash: %w", err) + } + + sig, err := crypto.Sign(digest, c.tx.EVMPrivateKey()) + if err != nil { + return hlSignature{}, fmt.Errorf("crypto.Sign: %w", err) + } + // crypto.Sign yields [R || S || V] with V in {0,1}; HL wants {27,28}. + v := uint64(sig[64]) + 27 + return hlSignature{ + R: "0x" + toHex32(sig[0:32]), + S: "0x" + toHex32(sig[32:64]), + V: v, + }, nil +} + +// withdraw3Digest builds the EIP-712 signing digest for a withdraw3 action. +// The domain and type list are fixed by Hyperliquid and MUST match exactly or +// the recovered signer address is wrong and HL rejects the withdrawal. +func withdraw3Digest(a withdraw3Action) ([]byte, error) { + typedData := apitypes.TypedData{ + Types: apitypes.Types{ + "EIP712Domain": []apitypes.Type{ + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + {Name: "verifyingContract", Type: "address"}, + }, + "HyperliquidTransaction:Withdraw": []apitypes.Type{ + {Name: "hyperliquidChain", Type: "string"}, + {Name: "destination", Type: "string"}, + {Name: "amount", Type: "string"}, + {Name: "time", Type: "uint64"}, + }, + }, + PrimaryType: "HyperliquidTransaction:Withdraw", + Domain: apitypes.TypedDataDomain{ + Name: "HyperliquidSignTransaction", + Version: "1", + ChainId: math.NewHexOrDecimal256(hyperliquidSignatureChainIDInt), + VerifyingContract: "0x0000000000000000000000000000000000000000", + }, + Message: apitypes.TypedDataMessage{ + "hyperliquidChain": a.HyperliquidChain, + "destination": a.Destination, + "amount": a.Amount, + "time": new(big.Int).SetUint64(a.Time), + }, + } + digest, _, err := apitypes.TypedDataAndHash(typedData) + return digest, err +} + +// postJSON marshals body, POSTs it, and returns the response bytes. Non-2xx +// responses still return their body (HL puts error detail in a 200 or 4xx JSON) +// alongside an error. +func (c *HyperliquidClient) postJSON(url string, body any) ([]byte, error) { + buf, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(buf)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return raw, fmt.Errorf("HL %s returned %d: %s", url, resp.StatusCode, truncateHL(string(raw), 300)) + } + return raw, nil +} + +func toHex32(b []byte) string { + const hexdigits = "0123456789abcdef" + out := make([]byte, len(b)*2) + for i, v := range b { + out[i*2] = hexdigits[v>>4] + out[i*2+1] = hexdigits[v&0x0f] + } + return string(out) +} + +func truncateHL(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/harnesses/bridge-monitor/cmd/monitor/hyperliquid_withdraw_test.go b/harnesses/bridge-monitor/cmd/monitor/hyperliquid_withdraw_test.go new file mode 100644 index 000000000..0db7cc8a9 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/hyperliquid_withdraw_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// TestSignWithdrawRecoversSigner proves the withdraw3 EIP-712 signature is +// internally consistent: signing with the wallet key and recovering from the +// same digest yields the wallet's own address. This validates the domain, type +// list, digest, and the v=27/28 normalization without needing the live HL API. +func TestSignWithdrawRecoversSigner(t *testing.T) { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + want := crypto.PubkeyToAddress(key.PublicKey) + + c := NewHyperliquidClient(&TxExecutor{evmPrivateKey: key}) + if c == nil { + t.Fatal("NewHyperliquidClient returned nil with a key present") + } + + action := withdraw3Action{ + Type: "withdraw3", + HyperliquidChain: hyperliquidChainName, + SignatureChainID: hyperliquidSignatureChainID, + Amount: "30", + Time: 1725000000000, + Destination: strings.ToLower(want.Hex()), + } + + sig, err := c.signWithdraw(action) + if err != nil { + t.Fatalf("signWithdraw: %v", err) + } + if sig.V != 27 && sig.V != 28 { + t.Fatalf("v not normalized to 27/28: got %d", sig.V) + } + + digest, err := withdraw3Digest(action) + if err != nil { + t.Fatalf("digest: %v", err) + } + + raw := make([]byte, 65) + copy(raw[0:32], common.FromHex(sig.R)) + copy(raw[32:64], common.FromHex(sig.S)) + raw[64] = byte(sig.V - 27) + + pub, err := crypto.SigToPub(digest, raw) + if err != nil { + t.Fatalf("SigToPub: %v", err) + } + got := crypto.PubkeyToAddress(*pub) + if got != want { + t.Fatalf("recovered %s, want %s", got, want) + } +} + +// TestWithdrawRejectsBelowMinimum guards the HL minimum-withdrawal check. +func TestWithdrawRejectsBelowMinimum(t *testing.T) { + key, _ := crypto.GenerateKey() + c := NewHyperliquidClient(&TxExecutor{evmPrivateKey: key}) + err := c.Withdraw(1.0, crypto.PubkeyToAddress(key.PublicKey).Hex()) + if err == nil { + t.Fatal("expected rejection for $1 withdrawal below HL minimum") + } +} + +// TestReverseRoute swaps origin and destination and flags the Solana source +// correctly, which the R4 return leg (BRETT->TRUMP) depends on. +func TestReverseRoute(t *testing.T) { + r4, ok := R4Route() + if !ok { + t.Fatal("R4 route not found") + } + rev := ReverseRoute(r4) + if rev.FromChain != r4.ToChain || rev.ToChain != r4.FromChain { + t.Fatalf("chains not swapped: %s->%s", rev.FromChain, rev.ToChain) + } + if rev.FromToken != r4.ToToken || rev.ToToken != r4.FromToken { + t.Fatalf("tokens not swapped") + } + // R4 forward is Solana->Base, so the return is Base->Solana: not Solana-sourced. + if rev.IsSolanaSrc { + t.Fatalf("return leg should not be Solana-sourced") + } + if getSourceTokenName(rev) != "BRETT" { + t.Fatalf("return source token should be BRETT, got %s", getSourceTokenName(rev)) + } +} diff --git a/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go b/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go index ba7b9a584..83c59497a 100644 --- a/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go @@ -34,6 +34,12 @@ func lifiChainID(chain string) string { return "42161" case "hypercore", "hyperliquid": return "1337" + case "bnb", "bsc": + return "56" + case "ethereum", "eth": + return "1" + case "robinhood": + return "4663" } return "" } diff --git a/harnesses/bridge-monitor/cmd/monitor/main.go b/harnesses/bridge-monitor/cmd/monitor/main.go index 67d90d995..138ce122b 100644 --- a/harnesses/bridge-monitor/cmd/monitor/main.go +++ b/harnesses/bridge-monitor/cmd/monitor/main.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "math" + "math/big" "net/http" "os" "runtime" @@ -123,9 +124,8 @@ func main() { if config.ExecutionMode != "" { execConfig := &ExecutionConfig{ Mode: ExecutionMode(config.ExecutionMode), - Freq5USD: config.Freq5USD, - Freq50USD: config.Freq50USD, - Freq300USD: config.Freq300USD, + Freq3USD: config.Freq3USD, + Freq30USD: config.Freq30USD, EnableDebridge: config.EnableDebridge, MaxDailySpendUSD: config.MaxDailySpendUSD, } @@ -152,8 +152,20 @@ func main() { } } + // Hyperliquid client for the R5 return leg (HyperCore -> Arbitrum native + // withdraw). Nil when no EVM key is loaded (quote-only mode): runR5RoundTrip + // treats nil as "R5 execution unavailable" and skips. + var hlClient *HyperliquidClient + if executor != nil { + hlClient = NewHyperliquidClient(executor.txExecutor) + executor.SetNearIntents(nearIntentsBridge) + } + // Pre-seed the self-healing series so alerting rules match from startup. initSelfHealingMetrics() + // 0-baseline the execution series for our corridors so the first real run + // is visible to rate()/increase() over the 24h window (see the function doc). + preseedExecutionMetrics(config.MonitorRegion) // Start Prometheus metrics endpoint. Railway / most PaaS inject $PORT // and route external traffic to whatever value they chose. If we bind @@ -271,6 +283,14 @@ func main() { } execMu.Unlock() + // Near Intents runs its own USDC triangle (separate route set + 1Click + // deposit flow), so exercise it here too when opted in, otherwise it + // would only ever execute in production and never get a single-test pass. + if config.EnableNearIntentsExec { + log.Printf("\n━━━ Bridge: near-intents ━━━") + runNearIntentsTriangle(executor, testAmount) + } + log.Println("\n✅ Single-test complete! Exiting.") return } @@ -368,49 +388,153 @@ func main() { // corrective transfers only in production and unpaused. StartReaper(balanceChecker, rebalancer, slackNotifier, config.ExecutionMode, paused) - // Track last meme execution day to run weekly - lastMemeDay := -1 - // Main loop — scheduler-only (quote loop now lives in its own goroutine). + // Two tiers: $3 daily, $30 Mon+Thu. Each tick equalizes the stable legs, + // runs the conserving triangle, then the opt-in R4/R5 round-trips. for { select { - case <-getSchedulerChan(scheduler, "$5"): - // $5 execution loop - daily at 10:00 UTC - if executor != nil && config.ExecutionMode == "production" { - runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, GetTriangleRoutes(), 5.0, "daily") - - // Meme routes use independent capital (TRUMP) — always attempt, - // the per-route RunReal check catches insufficient TRUMP. - now := time.Now().UTC() - if now.Weekday() == time.Monday && now.YearDay() != lastMemeDay { - log.Println("💸 Running $5 meme execution tests (weekly)...") - execMu.Lock() - for _, route := range GetMemeRoutes() { - executor.RunReal(route, 5.0) - } - execMu.Unlock() - lastMemeDay = now.YearDay() - } - } + case <-getSchedulerChan(scheduler, "$3"): + runLoopTier(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, hlClient, config, 3.0, "daily") + case <-getSchedulerChan(scheduler, "$30"): + runLoopTier(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, hlClient, config, 30.0, "Mon+Thu") + } + } +} - case <-getSchedulerChan(scheduler, "$50"): - if executor != nil && config.ExecutionMode == "production" { - runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, GetTriangleRoutes(), 50.0, "Mon+Thu") +// runLoopTier runs one scheduled tier end to end: the conserving stable +// triangle (R1/R2/R3, which also proactively equalizes its legs), then the +// opt-in R4 (TRUMP<->BRETT) and R5 (Arb<->HyperCore) round-trips. No-ops +// outside production mode. +func runLoopTier(executor *Executor, bc *BalanceChecker, slack *SlackNotifier, + rebalancer *Rebalancer, gasTopper *GasTopper, hl *HyperliquidClient, config *Config, + tier float64, label string, +) { + if executor == nil || config.ExecutionMode != "production" { + return + } + runTierIfViable(executor, bc, slack, rebalancer, gasTopper, GetTriangleRoutes(), tier, label) + if config.EnableR4RoundTrip { + runR4RoundTrip(executor, slack, tier) + } + if config.EnableR5Hypercore { + runR5RoundTrip(executor, bc, slack, hl, tier) + } + if config.EnableNearIntentsExec { + runNearIntentsTriangle(executor, tier) + } +} + +// runR4RoundTrip executes R4 (TRUMP->BRETT) and immediately its return leg +// (BRETT->TRUMP) per bridge, so the meme pool is restored each cycle. Memes +// carry real slippage, so the standing TRUMP/BRETT pools absorb the drift and +// are reseeded periodically (this is not fee-neutral by design). +func runR4RoundTrip(executor *Executor, slack *SlackNotifier, tier float64) { + r4, ok := R4Route() + if !ok { + return + } + ret := ReverseRoute(r4) + execMu.Lock() + defer execMu.Unlock() + log.Printf("🎭 R4 round-trip $%.0f (TRUMP<->BRETT)...", tier) + for _, bridge := range []string{"mobula", "relay", "lifi"} { + dep := executor.RunBridgeOnRoute(bridge, r4, tier) + time.Sleep(2 * time.Second) + if dep == nil || !dep.Success { + log.Printf(" ⚠️ R4 deposit via %s failed — skipping its return leg (TRUMP not spent)", bridge) + continue + } + retRes := executor.RunBridgeOnRoute(bridge, ret, tier) + time.Sleep(2 * time.Second) + if retRes == nil || !retRes.Success { + log.Printf(" ⚠️ R4 return via %s failed — TRUMP pool short until reseed", bridge) + if slack != nil { + _ = slack.NotifyTierSkipped(tier, "R4-return-"+bridge, "BRETT->TRUMP return leg failed; TRUMP pool will need a reseed") } + } + } +} - case <-getSchedulerChan(scheduler, "$300"): - if executor != nil && config.ExecutionMode == "production" { - runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, GetTriangleRoutes(), 300.0, "Mon weekly") +// runR5RoundTrip executes R5 (Arb USDC -> HyperCore) per bridge, then withdraws +// the credited amount back to Arbitrum via the HL-native withdraw3 action so +// the loop conserves Arb USDC. Skips the withdraw (leaving funds for the reaper +// / alert) whenever the HL credit is not observed, to avoid over-withdrawing. +func runR5RoundTrip(executor *Executor, bc *BalanceChecker, slack *SlackNotifier, hl *HyperliquidClient, tier float64) { + r5, ok := R5Route() + if !ok { + return + } + if hl == nil { + log.Println("⚠️ R5 skipped: HyperliquidClient unavailable (no EVM key)") + return + } + evm := executor.walletManager.EVMAddress + execMu.Lock() + defer execMu.Unlock() + log.Printf("🏦 R5 round-trip $%.0f (Arb USDC <-> HyperCore)...", tier) + for _, bridge := range []string{"mobula", "relay", "lifi"} { + before, err := hl.Withdrawable(evm) + if err != nil { + log.Printf(" ⚠️ R5 %s: cannot read HL withdrawable pre-deposit: %v — skipping", bridge, err) + continue + } + dep := executor.RunBridgeOnRoute(bridge, r5, tier) + time.Sleep(2 * time.Second) + if dep == nil || !dep.Success { + log.Printf(" ⚠️ R5 deposit via %s failed — skipping withdraw", bridge) + continue + } + delta := waitForHLCredit(hl, evm, before, tier, 5*time.Minute) + if delta < hyperliquidMinWithdrawUSD { + log.Printf(" ⚠️ R5 %s: HL credit not observed (delta $%.2f) within timeout — leaving funds on HL for reaper/alert", bridge, delta) + if slack != nil { + _ = slack.NotifyTierSkipped(tier, "R5-withdraw-"+bridge, "HL credit not observed; withdraw skipped to avoid over-withdraw") } + continue + } + // Never withdraw more than the round-trip notional plus a small margin. + amt := delta + if amt > tier*rebalanceBufferFactor { + amt = tier * rebalanceBufferFactor + } + if err := hl.Withdraw(amt, evm); err != nil { + log.Printf(" ⚠️ R5 %s withdraw failed: %v", bridge, err) + if slack != nil { + _ = slack.NotifyTierSkipped(tier, "R5-withdraw-"+bridge, "HL withdraw failed: "+err.Error()) + } + } + time.Sleep(2 * time.Second) + } +} + +// waitForHLCredit polls HL withdrawable until it rises by at least the deposit +// notional (minus a tolerance for HL fees/rounding) or the timeout elapses. +// Returns the observed increase in USD (0 if nothing credited). +func waitForHLCredit(hl *HyperliquidClient, addr string, before, tier float64, timeout time.Duration) float64 { + deadline := time.Now().Add(timeout) + target := tier * 0.9 // accept a 10% haircut for HL fees / rounding + for time.Now().Before(deadline) { + time.Sleep(15 * time.Second) + now, err := hl.Withdrawable(addr) + if err != nil { + continue + } + if now-before >= target { + return now - before } } + last, err := hl.Withdrawable(addr) + if err != nil { + return 0 + } + return last - before } // downgradeLadder returns the tier amounts to try, largest first, starting at -// the scheduled tier. Partial data beats none: if $300 cannot run we still -// want the $50 or $5 datapoint from the same slot. +// the scheduled tier. Partial data beats none: if $30 cannot run we still +// want the $3 datapoint from the same slot. func downgradeLadder(tier float64) []float64 { - all := []float64{300, 50, 5} + all := []float64{30, 3} var out []float64 for _, t := range all { if t <= tier { @@ -451,6 +575,16 @@ func runTierIfViable(executor *Executor, bc *BalanceChecker, slack *SlackNotifie // counter and a single slot could broadcast up to six transfers. budget := newSlotBudget() + // Proactive equalization: top every stable home leg up to the largest tier + // plus buffer BEFORE checking viability, so the loop self-heals even when + // nothing is blocked yet. Shares the same slot budget as the reactive + // per-rung rebalancing below, so the two together never exceed the cap. + if rebalancer != nil { + if balances, degraded, err := bc.GetAllBalancesDetailed(); err == nil && !degraded { + rebalancer.EqualizeStableLegs(balances, budget) + } + } + for i, amount := range ladder { balances, degraded, err := bc.GetAllBalancesDetailed() if err != nil || degraded { @@ -548,12 +682,10 @@ func getSchedulerChan(s *Scheduler, amount string) <-chan struct{} { return nil } switch amount { - case "$5": - return s.Exec5Chan() - case "$50": - return s.Exec50Chan() - case "$300": - return s.Exec300Chan() + case "$3": + return s.Exec3Chan() + case "$30": + return s.Exec30Chan() } return nil } @@ -566,6 +698,15 @@ func toRawUnits(amount float64) string { return strconv.FormatInt(int64(math.Round(raw)), 10) } +// toRawUnitsDec is toRawUnits for a token whose decimals differ from the 6-dec +// stable default (e.g. BRETT at 18). Uses big.Float so 18-decimal magnitudes +// don't overflow int64 before rounding. +func toRawUnitsDec(amount float64, decimals int) string { + scaled := new(big.Float).Mul(big.NewFloat(amount), big.NewFloat(math.Pow10(decimals))) + i, _ := scaled.Int(nil) + return i.String() +} + // runQuoteTests runs quote-only tests (FREE, no execution) func runQuoteTests( mobulaBridge *MobulaBridge, diff --git a/harnesses/bridge-monitor/cmd/monitor/metrics.go b/harnesses/bridge-monitor/cmd/monitor/metrics.go index 5f2fa0f30..3d5025fee 100644 --- a/harnesses/bridge-monitor/cmd/monitor/metrics.go +++ b/harnesses/bridge-monitor/cmd/monitor/metrics.go @@ -57,6 +57,66 @@ var ( Help: "Total number of reverted/refunded bridge transactions", }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + // Refund counter: subset of reverts where the bridge returned capital to + // source (status "refunded") rather than an on-chain revert. Lets us split + // refund-rate from hard-fail-rate (reverts minus refunds). + bridgeRefunds = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "bridge_refunds_total", + Help: "Bridge transactions the provider refunded (capital returned to source)", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Stuck: a deposit broadcast whose bridge status never resolved to a fill + // OR a refund within our poll window. On a real user's swap these are the + // ones that need a manual claim / support ticket — the "not automatically + // handled" rate, distinct from clean fills and clean refunds. Cross-chain + // is fundamentally asynchronous, so this is the honest reliability tail. + bridgeStuck = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "bridge_stuck_total", + Help: "Broadcasts whose status never resolved to a fill or refund in the poll window (funds in limbo, manual intervention needed)", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Refund latency: time from deposit broadcast to the provider returning + // capital to source (status "refunded"). A failed swap is only half the + // story; how fast you get your money back is the other half of the UX. + bridgeRefundLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "bridge_refund_latency_ms", + Help: "Latency from deposit broadcast to refund settlement in milliseconds", + Buckets: []float64{5000, 10000, 30000, 60000, 120000, 300000, 600000}, + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Realized output that actually landed on the destination (on-chain fill). + bridgeRealizedOutputUSD = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_realized_output_usd", + Help: "USD value that actually landed on the destination chain (realized fill)", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Execution slippage vs the quote: realized fee minus quote-projected fee. + // Positive means the execution cost more than the quote promised. + bridgeQuoteSlippageUSD = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_quote_slippage_usd", + Help: "Realized fee minus quote-projected fee (execution cost above what the quote promised)", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Our own on-chain gas cost for this execution (approve + deposit), measured + // as the source-chain native balance delta. This is the cost WE bear, on top + // of the bridge's own fee, for the true all-in cost. + bridgeExecGasUSD = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_exec_gas_usd", + Help: "On-chain gas we paid (approve + deposit), source-chain native balance delta in USD", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Exact execution latency as a gauge (last value per corridor). The + // histogram above can only place a value in a bucket, so with a handful of + // samples all landing in the same (5s,10s] bucket histogram_quantile + // interpolates every bridge to the 7.5s midpoint. This gauge lets the bench + // take quantile_over_time / avg_over_time over the REAL observed latencies + // (same pattern as the gauge-backed fee + realized-cost benches), so the + // numbers are exact instead of bucketed. + bridgeExecLatencyMs = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_exec_latency_ms", + Help: "Exact execution latency (broadcast to funds received) in ms, last value per corridor", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + // Error counter bridgeErrors = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "bridge_errors_total", @@ -178,15 +238,13 @@ var ( // query, so the series exist on /metrics from process start instead of only // after the first event. func initSelfHealingMetrics() { - for _, outcome := range []string{"attempted", "succeeded", "failed", "capped", "in_flight"} { + for _, outcome := range []string{"attempted", "succeeded", "failed", "capped", "in_flight", "equalize"} { bridgeRebalanceAttempts.WithLabelValues(outcome).Add(0) } - bridgeTierDowngraded.WithLabelValues("300", "50").Set(0) - bridgeTierDowngraded.WithLabelValues("300", "5").Set(0) - bridgeTierDowngraded.WithLabelValues("50", "5").Set(0) + bridgeTierDowngraded.WithLabelValues("30", "3").Set(0) bridgeStrandedHours.WithLabelValues("Solana", "USDC").Set(0) bridgeStrandedHours.WithLabelValues("Base", "USDC").Set(0) - bridgeStrandedHours.WithLabelValues("Arbitrum", "USDT0").Set(0) + bridgeStrandedHours.WithLabelValues("Arbitrum", "USDC").Set(0) for _, chain := range []string{"Solana", "Base", "Arbitrum"} { for _, outcome := range []string{"attempted", "succeeded", "failed", "capped", "gated"} { bridgeGasTopup.WithLabelValues(chain, outcome).Add(0) @@ -194,3 +252,46 @@ func initSelfHealingMetrics() { } bridgeBalanceReadDegraded.Set(0) } + +// preseedExecutionMetrics creates the execution counter + histogram series at +// zero for every corridor we actually execute, so Prometheus has a 0 baseline +// BEFORE the first run. Without it, a counter that first appears already +// incremented (the run finishes between two scrapes, or it is the very first +// run) is invisible to rate()/increase(): the 24h window sees a flat series +// and returns 0 / NaN. The pre-seed turns the first real increment into a +// visible 0 -> N step so the latency and success/refund/stuck headlines +// populate from the very first execution instead of only from day two. +func preseedExecutionMetrics(region string) { + if region == "" { + return + } + amounts := []string{"3", "30"} + // mobula/relay/lifi run the stable triangle; near-intents runs its OWN + // USDC triangle (separate route set), so seed each on the routes it + // actually executes or the labels won't match the real increments. + seedSets := []struct { + bridges []string + routes []TestRoute + }{ + {[]string{"mobula", "relay", "lifi"}, GetTriangleRoutes()}, + {[]string{"near-intents"}, GetNearIntentsTriangle()}, + } + for _, ss := range seedSets { + for _, route := range ss.routes { + for _, bridge := range ss.bridges { + for _, amt := range amounts { + labels := []string{bridge, route.FromChain, route.ToChain, route.FromToken, route.ToToken, amt, region, route.ToChain} + bridgeSuccess.WithLabelValues(labels...).Add(0) + bridgeReverts.WithLabelValues(labels...).Add(0) + bridgeRefunds.WithLabelValues(labels...).Add(0) + bridgeStuck.WithLabelValues(labels...).Add(0) + // Instantiate the histogram children so their _bucket / _sum / + // _count series exist at 0 before the first Observe. + bridgeExecutionLatency.WithLabelValues(labels...) + bridgeE2ELatency.WithLabelValues(labels...) + bridgeRefundLatency.WithLabelValues(labels...) + } + } + } + } +} diff --git a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go index 706c294ae..47d4da6aa 100644 --- a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go @@ -71,10 +71,10 @@ type MobulaQuoteResponse struct { type MobulaStatusResponse struct { Data struct { - Status string `json:"status"` // "pending", "filled", "refunded", "failed" - LatencyMs int64 `json:"latencyMs"` - FromTxHash string `json:"fromTxHash"` - ToTxHash string `json:"toTxHash"` + Status string `json:"status"` // "pending", "filled", "refunded", "failed" + LatencyMs int64 `json:"latencyMs"` + FromTxHash string `json:"fromTxHash"` + ToTxHash string `json:"toTxHash"` } `json:"data"` } @@ -216,7 +216,7 @@ func (m *MobulaBridge) quote(originChain, originToken, destChain, destToken, sen start := time.Now() url := fmt.Sprintf( - "https://demo-api.mobula.io/api/2/bridge/quote?originChainId=%s&originToken=%s&destinationChainId=%s&destinationToken=%s&amount=%s&walletAddress=%s&apiKey=%s", + mobulaAPIBase()+"/api/2/bridge/quote?originChainId=%s&originToken=%s&destinationChainId=%s&destinationToken=%s&amount=%s&walletAddress=%s&apiKey=%s", originChain, originToken, destChain, destToken, strconv.FormatFloat(amount, 'f', -1, 64), walletAddress, m.apiKey, @@ -261,7 +261,7 @@ func (m *MobulaBridge) quote(originChain, originToken, destChain, destToken, sen } func (m *MobulaBridge) GetStatus(txHash string) (*MobulaStatusResponse, error) { - url := fmt.Sprintf("https://demo-api.mobula.io/api/2/bridge/status/%s", txHash) + url := fmt.Sprintf(mobulaAPIBase()+"/api/2/bridge/status/%s", txHash) req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -290,7 +290,7 @@ func (m *MobulaBridge) GetStatus(txHash string) (*MobulaStatusResponse, error) { } func (m *MobulaBridge) VerifyRoutes() (*MobulaRoutesResponse, error) { - url := "https://demo-api.mobula.io/api/2/bridge/routes" + url := mobulaAPIBase() + "/api/2/bridge/routes" req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -320,8 +320,8 @@ func (m *MobulaBridge) VerifyRoutes() (*MobulaRoutesResponse, error) { type TestRoute struct { Name string - FromChain string // Human-readable label (e.g. "Solana") - FromChainAPI string // API-specific id (e.g. "solana:solana") + FromChain string // Human-readable label (e.g. "Solana") + FromChainAPI string // API-specific id (e.g. "solana:solana") FromToken string ToChain string ToChainAPI string @@ -337,7 +337,7 @@ func GetTestRoutes() []TestRoute { // USDC Triangle (self-balancing): // R1: Solana USDC → Base USDC // R2: Base USDC → Arbitrum USDT - // R3: Arbitrum USDT → Solana USDC + // R3: Arbitrum USDC → Solana USDC // // Meme Route (separate, weekly only): // R4: TRUMP (Solana) → BRETT (Base) @@ -353,80 +353,135 @@ func GetTestRoutes() []TestRoute { return []TestRoute{ // R1: Solana USDC → Base USDC { - Name: "USDC_SOL_BASE", - FromChain: "Solana", FromChainAPI: "solana:solana", - FromToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - ToChain: "Base", ToChainAPI: "evm:8453", - ToToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - Amounts: []float64{5, 50, 300}, - UsdAmounts: []float64{5, 50, 300}, - IsSolanaSrc: true, - WeeklyOnly: false, + Name: "USDC_SOL_BASE", + FromChain: "Solana", FromChainAPI: "solana:solana", + FromToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ToChain: "Base", ToChainAPI: "evm:8453", + ToToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, + IsSolanaSrc: true, + WeeklyOnly: false, }, - // R2: Base USDC → Arbitrum USDT + // R2: Base USDC → Arbitrum USDC (all-USDC triangle) { - Name: "USDC_BASE_USDT_ARB", - FromChain: "Base", FromChainAPI: "evm:8453", - FromToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - ToChain: "Arbitrum", ToChainAPI: "evm:42161", - ToToken: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", - Amounts: []float64{5, 50, 300}, - UsdAmounts: []float64{5, 50, 300}, - IsSolanaSrc: false, - WeeklyOnly: false, + Name: "USDC_BASE_ARB", + FromChain: "Base", FromChainAPI: "evm:8453", + FromToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + ToChain: "Arbitrum", ToChainAPI: "evm:42161", + ToToken: "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, + IsSolanaSrc: false, + WeeklyOnly: false, }, - // R3: Arbitrum USDT → Solana USDC (completes the triangle) + // R3: Arbitrum USDC → Solana USDC (completes the all-USDC triangle) { - Name: "USDT_ARB_USDC_SOL", - FromChain: "Arbitrum", FromChainAPI: "evm:42161", - FromToken: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", - ToChain: "Solana", ToChainAPI: "solana:solana", - ToToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - Amounts: []float64{5, 50, 300}, - UsdAmounts: []float64{5, 50, 300}, - IsSolanaSrc: false, - WeeklyOnly: false, + Name: "USDC_ARB_SOL", + FromChain: "Arbitrum", FromChainAPI: "evm:42161", + FromToken: "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + ToChain: "Solana", ToChainAPI: "solana:solana", + ToToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, + IsSolanaSrc: false, + WeeklyOnly: false, }, - // R4: TRUMP (Solana) → BRETT (Base) - one-way. - // Amounts computed at startup from live TRUMP price (refreshed every 5min via - // TokenPriceUSD cache). Execution stays weekly at $5 only. + // R4: TRUMP (Solana) → BRETT (Base). Part of the round-trip loop when + // ENABLE_R4_ROUNDTRIP_EXEC=true: the return leg BRETT→TRUMP is built at + // runtime via ReverseRoute so meme inventory is restored each cycle. + // Amounts computed at startup from the live TRUMP price (5min cache). + // WeeklyOnly keeps it out of the always-on stable triangle. { - Name: "TRUMP_SOL_BRETT_BASE", - FromChain: "Solana", FromChainAPI: "solana:solana", - FromToken: "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", - ToChain: "Base", ToChainAPI: "evm:8453", - ToToken: "0x532f27101965dd16442E59d40670FaF5eBB142E4", - Amounts: []float64{trump5, trump50, trump300}, - UsdAmounts: []float64{5, 50, 300}, - IsSolanaSrc: true, - WeeklyOnly: true, + Name: "TRUMP_SOL_BRETT_BASE", + FromChain: "Solana", FromChainAPI: "solana:solana", + FromToken: "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", + ToChain: "Base", ToChainAPI: "evm:8453", + ToToken: "0x532f27101965dd16442E59d40670FaF5eBB142E4", + Amounts: []float64{trump5, trump50, trump300}, + UsdAmounts: []float64{5, 50, 300}, + IsSolanaSrc: true, + WeeklyOnly: true, }, // R5: Arbitrum USDC → HyperCore USDC (Hyperliquid perp account). - // One-way deposit benchmark: HL is asymmetric (perp account credit, - // withdraw goes through HL's L1 signed action, not handled here). - // Phase 1 = quote-only — flagged QuoteOnly:true so GetTriangleRoutes() - // excludes it from the $5/$50/$300 execution scheduler. Quote loop - // still runs every 5min, so the dashboard gets latency/fees/cost data - // for Mobula vs Relay vs LiFi on HC. Flip to false in Phase 2 once HL - // balance reader + manual capital seed are in place. + // The deposit leg is a normal provider bridge (benchmarked per bridge). + // The return leg HyperCore → Arbitrum is HL-native (withdraw3 signed + // action, see hyperliquid_withdraw.go), executed once after the deposit + // so the loop conserves Arb USDC. QuoteOnly:true keeps it out of the + // always-on stable triangle and the 5min quote loop keeps measuring it; + // real execution is gated behind ENABLE_R5_HYPERCORE_EXEC. { - Name: "USDC_ARB_HYPERCORE", - FromChain: "Arbitrum", FromChainAPI: "evm:42161", - FromToken: "0xaf88d065e77c8cc2239327c5edb3a432268e5831", - ToChain: "HyperCore", ToChainAPI: "hl:mainnet", - ToToken: "USDC", // Mobula uses symbol; per-bridge translators map to provider-specific addr - Amounts: []float64{5, 50, 300}, - UsdAmounts: []float64{5, 50, 300}, - IsSolanaSrc: false, - WeeklyOnly: false, - QuoteOnly: true, + Name: "USDC_ARB_HYPERCORE", + FromChain: "Arbitrum", FromChainAPI: "evm:42161", + FromToken: "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + ToChain: "HyperCore", ToChainAPI: "hl:mainnet", + ToToken: "USDC", // Mobula uses symbol; per-bridge translators map to provider-specific addr + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, + IsSolanaSrc: false, + WeeklyOnly: false, + QuoteOnly: true, + }, + // SOL→X quote-only corridors for the dedicated Solana quote-latency + // bench (Solana as source to several destinations). Quote-only: the + // 5min loop measures quote latency per bridge, no execution / no funds. + // USDC on each destination; decimals differ (BNB 18, others 6) but that + // only affects the output amount, not the quote-latency we measure. + { + Name: "USDC_SOL_ARB", + FromChain: "Solana", FromChainAPI: "solana:solana", + FromToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ToChain: "Arbitrum", ToChainAPI: "evm:42161", + ToToken: "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, + IsSolanaSrc: true, + QuoteOnly: true, + }, + { + Name: "USDC_SOL_BNB", + FromChain: "Solana", FromChainAPI: "solana:solana", + FromToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ToChain: "BNB", ToChainAPI: "evm:56", + ToToken: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, + IsSolanaSrc: true, + QuoteOnly: true, + }, + { + Name: "USDC_SOL_ETH", + FromChain: "Solana", FromChainAPI: "solana:solana", + FromToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ToChain: "Ethereum", ToChainAPI: "evm:1", + ToToken: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, + IsSolanaSrc: true, + QuoteOnly: true, + }, + // SOL→Robinhood Chain (Arbitrum Orbit, chainId 4663). No native USDC on + // Robinhood Chain: bridges take USDC in and deliver USDG (Global Dollar), + // so this is a cross-asset corridor. Supported by Relay / LI.FI / deBridge + // / Across; Mobula has data coverage but no bridge here, so it will read + // unsupported on this tab until Mobula ships a Robinhood bridge route. + { + Name: "USDC_SOL_ROBINHOOD", + FromChain: "Solana", FromChainAPI: "solana:solana", + FromToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ToChain: "Robinhood", ToChainAPI: "evm:4663", + ToToken: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", // USDG on Robinhood Chain + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, + IsSolanaSrc: true, + QuoteOnly: true, }, } } -// GetTriangleRoutes returns only the routes that should run in the scheduled -// $5 / $50 / $300 execution cycles — i.e. the USDC triangle (R1, R2, R3). -// Excludes WeeklyOnly (R4 meme) and QuoteOnly (R5 HyperCore — quote loop only). +// GetTriangleRoutes returns only the routes that run in every scheduled +// $3 / $30 cycle unconditionally — i.e. the self-conserving USDC triangle +// (R1, R2, R3). Excludes WeeklyOnly (R4 meme) and QuoteOnly (R5 HyperCore). func GetTriangleRoutes() []TestRoute { routes := GetTestRoutes() var triangle []TestRoute @@ -451,6 +506,37 @@ func GetMemeRoutes() []TestRoute { return meme } +// getRouteByName returns the named route from the canonical set. +func getRouteByName(name string) (TestRoute, bool) { + for _, r := range GetTestRoutes() { + if r.Name == name { + return r, true + } + } + return TestRoute{}, false +} + +// R4Route returns the TRUMP→BRETT deposit route (R4). +func R4Route() (TestRoute, bool) { return getRouteByName("TRUMP_SOL_BRETT_BASE") } + +// R5Route returns the Arb USDC→HyperCore deposit route (R5). +func R5Route() (TestRoute, bool) { return getRouteByName("USDC_ARB_HYPERCORE") } + +// ReverseRoute builds the return leg of a route by swapping origin and +// destination. Used to close R4 (BRETT→TRUMP) so the meme pool is restored +// each cycle; the amount arrays are carried over (they are USD-notional for +// UsdAmounts and recomputed per tier by the caller). Not used for R5, whose +// return leg is the HL-native withdraw rather than a provider bridge. +func ReverseRoute(r TestRoute) TestRoute { + rev := r + rev.Name = r.Name + "_RETURN" + rev.FromChain, rev.ToChain = r.ToChain, r.FromChain + rev.FromChainAPI, rev.ToChainAPI = r.ToChainAPI, r.FromChainAPI + rev.FromToken, rev.ToToken = r.ToToken, r.FromToken + rev.IsSolanaSrc = r.ToChain == "Solana" + return rev +} + // TestRoute runs a quote against a given route, using amount (token-native) + amountUsd (for labels). func (m *MobulaBridge) TestRoute(route TestRoute, amount, amountUsd float64, region, solAddress, evmAddress string) { amountStr := strconv.FormatFloat(amountUsd, 'f', 0, 64) @@ -480,7 +566,6 @@ func (m *MobulaBridge) TestRoute(route TestRoute, amount, amountUsd float64, reg amount, ) - if err != nil { log.Printf("[MOBULA][%s][%.0f USD] ❌ Quote failed: %v", route.Name, amountUsd, err) bridgeErrors.WithLabelValues(append(labels, "quote_failed")...).Inc() diff --git a/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go b/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go index 2958a8e65..9625b4daa 100644 --- a/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go @@ -92,12 +92,12 @@ type NearIntentsQuoteRequest struct { type NearIntentsQuoteResponse struct { Quote struct { - AmountIn string `json:"amountIn"` - AmountInFormatted string `json:"amountInFormatted"` - AmountInUsd string `json:"amountInUsd"` - AmountOut string `json:"amountOut"` + AmountIn string `json:"amountIn"` + AmountInFormatted string `json:"amountInFormatted"` + AmountInUsd string `json:"amountInUsd"` + AmountOut string `json:"amountOut"` AmountOutFormatted string `json:"amountOutFormatted"` - AmountOutUsd string `json:"amountOutUsd"` + AmountOutUsd string `json:"amountOutUsd"` // timeEstimate is the solver-reported settlement ETA in seconds. TimeEstimate float64 `json:"timeEstimate"` } `json:"quote"` diff --git a/harnesses/bridge-monitor/cmd/monitor/nearintents_exec.go b/harnesses/bridge-monitor/cmd/monitor/nearintents_exec.go new file mode 100644 index 000000000..6864a0606 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/nearintents_exec.go @@ -0,0 +1,415 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "math/big" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + solana "github.com/gagliardetto/solana-go" + ata "github.com/gagliardetto/solana-go/programs/associated-token-account" + "github.com/gagliardetto/solana-go/programs/token" + "github.com/gagliardetto/solana-go/rpc" +) + +// Near Intents (1Click) execution: the USDC-only triangle. +// +// Near Intents only lists USDC on Solana / Base / Arbitrum (no Arb USDT), so it +// cannot run the main cross-asset USDC<->USDT triangle. It gets its own +// self-conserving USDC-only triangle (Sol USDC -> Base USDC -> Arb USDC -> +// Sol USDC): each leg returns to start over the full cycle, only fees burn. +// Gated by ENABLE_NEARINTENTS_EXEC (default off), like R4/R5. +// +// 1Click is an intent/solver flow, not a signed-tx bridge: the quote returns a +// deposit address, we send the exact input there ourselves, POST the deposit tx +// hash to /v0/deposit/submit, then poll /v0/status until SUCCESS. Contract per +// the 1Click OpenAPI (quote.depositAddress / depositMemo, SubmitDepositTxRequest +// {txHash, depositAddress, memo}, GET /v0/status?depositAddress -> status enum). + +const ( + usdcDecimals = 6 + nearIntents1ClickBase = "https://1click.chaindefuser.com" + nearIntentsSettleWaitMs = 15 * time.Second +) + +// SetNearIntents wires the Near Intents client used for the USDC-triangle +// execution path. Called from main after the executor is built. +func (e *Executor) SetNearIntents(ni *NearIntentsBridge) { e.nearIntents = ni } + +// GetNearIntentsTriangle returns the USDC-only triangle executed by Near Intents +// when ENABLE_NEARINTENTS_EXEC=true. All legs are native USDC so the intent +// solver supports every hop, and the cycle conserves inventory. +func GetNearIntentsTriangle() []TestRoute { + return []TestRoute{ + { + Name: "NI_USDC_SOL_BASE", + FromChain: "Solana", FromChainAPI: "solana:solana", + FromToken: solanaUSDCMint, + ToChain: "Base", ToChainAPI: "evm:8453", + ToToken: baseUSDCAddr, + Amounts: []float64{3, 30}, + UsdAmounts: []float64{3, 30}, + IsSolanaSrc: true, + }, + { + Name: "NI_USDC_BASE_ARB", + FromChain: "Base", FromChainAPI: "evm:8453", + FromToken: baseUSDCAddr, + ToChain: "Arbitrum", ToChainAPI: "evm:42161", + ToToken: arbUSDCAddr, + Amounts: []float64{3, 30}, + UsdAmounts: []float64{3, 30}, + }, + { + Name: "NI_USDC_ARB_SOL", + FromChain: "Arbitrum", FromChainAPI: "evm:42161", + FromToken: arbUSDCAddr, + ToChain: "Solana", ToChainAPI: "solana:solana", + ToToken: solanaUSDCMint, + Amounts: []float64{3, 30}, + UsdAmounts: []float64{3, 30}, + }, + } +} + +// runNearIntentsTriangle runs the USDC-only triangle through Near Intents, +// sequentially, with cascade-stop: a failed leg leaves the next leg's source +// unfunded, so we stop rather than force a revert. Inventory returns to start +// over the full cycle. +func runNearIntentsTriangle(executor *Executor, tier float64) { + if executor == nil || executor.nearIntents == nil { + log.Println("⚠️ Near Intents triangle skipped: client unavailable") + return + } + routes := GetNearIntentsTriangle() + execMu.Lock() + defer execMu.Unlock() + log.Printf("🌀 Near Intents USDC triangle $%.0f...", tier) + for i, route := range routes { + result := executor.RunBridgeOnRoute("near-intents", route, tier) + time.Sleep(2 * time.Second) + if result == nil || !result.Success { + log.Printf(" ⚠️ NI leg %d (%s) failed — skipping remaining legs (cascade prevention)", i+1, route.Name) + break + } + } +} + +// nearIntentsExecQuote is the subset of the 1Click quote response needed to +// execute: where to deposit, the optional memo, and the exact input amount. +type nearIntentsExecQuote struct { + Quote struct { + DepositAddress string `json:"depositAddress"` + DepositMemo string `json:"depositMemo"` + AmountIn string `json:"amountIn"` + AmountInUsd string `json:"amountInUsd"` + AmountOutUsd string `json:"amountOutUsd"` + TimeEstimate float64 `json:"timeEstimate"` + } `json:"quote"` +} + +// GetExecutableQuote requests a non-dry 1Click quote and returns the deposit +// target. Unlike the quote-loop GetQuote (dry:true), this commits the solver to +// a deposit address we must fund. +func (n *NearIntentsBridge) GetExecutableQuote(originAsset, destinationAsset, rawAmount, recipient, refundTo string) (*nearIntentsExecQuote, time.Duration, error) { + start := time.Now() + deadline := time.Now().UTC().Add(15 * time.Minute).Format("2006-01-02T15:04:05.000Z") + body := NearIntentsQuoteRequest{ + Dry: false, + SwapType: "EXACT_INPUT", + SlippageTolerance: 100, + OriginAsset: originAsset, + DepositType: "ORIGIN_CHAIN", + DestinationAsset: destinationAsset, + Amount: rawAmount, + Recipient: recipient, + RecipientType: "DESTINATION_CHAIN", + RefundTo: refundTo, + RefundType: "ORIGIN_CHAIN", + Deadline: deadline, + QuoteWaitingTimeMs: 3000, + } + payload, _ := json.Marshal(body) + req, err := http.NewRequest("POST", nearIntents1ClickBase+"/v0/quote", bytes.NewReader(payload)) + if err != nil { + return nil, time.Since(start), err + } + req.Header.Set("Content-Type", "application/json") + if n.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+n.apiKey) + } + resp, err := n.client.Do(req) + if err != nil { + return nil, time.Since(start), err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + lat := time.Since(start) + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, lat, fmt.Errorf("near-intents exec quote %d: %s", resp.StatusCode, truncateNI(string(raw), 300)) + } + var out nearIntentsExecQuote + if err := json.Unmarshal(raw, &out); err != nil { + return nil, lat, fmt.Errorf("near-intents exec quote decode: %w", err) + } + if out.Quote.DepositAddress == "" { + return nil, lat, fmt.Errorf("near-intents exec quote returned no deposit address: %s", truncateNI(string(raw), 200)) + } + return &out, lat, nil +} + +// SubmitDeposit notifies the 1Click coordinator that the deposit tx was sent, +// so the solver starts settling without waiting to detect the deposit itself. +func (n *NearIntentsBridge) SubmitDeposit(txHash, depositAddress, memo string) error { + body := map[string]string{"txHash": txHash, "depositAddress": depositAddress} + if memo != "" { + body["memo"] = memo + } + payload, _ := json.Marshal(body) + req, err := http.NewRequest("POST", nearIntents1ClickBase+"/v0/deposit/submit", bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if n.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+n.apiKey) + } + resp, err := n.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("deposit/submit %d: %s", resp.StatusCode, truncateNI(string(raw), 200)) + } + return nil +} + +// Status returns the current 1Click execution status for a deposit address. +func (n *NearIntentsBridge) Status(depositAddress, memo string) (string, error) { + q := url.Values{} + q.Set("depositAddress", depositAddress) + if memo != "" { + q.Set("depositMemo", memo) + } + req, err := http.NewRequest("GET", nearIntents1ClickBase+"/v0/status?"+q.Encode(), nil) + if err != nil { + return "", err + } + if n.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+n.apiKey) + } + resp, err := n.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("status %d: %s", resp.StatusCode, truncateNI(string(raw), 200)) + } + var out struct { + Status string `json:"status"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", fmt.Errorf("status decode: %w", err) + } + return out.Status, nil +} + +// executeNearIntents runs one USDC leg through the 1Click intent flow: quote -> +// send the exact input to the deposit address -> submit -> poll to SUCCESS. +func (e *Executor) executeNearIntents(route TestRoute, amountUSD float64, rawUnits string, quoteStart time.Time) (*ExecutionResult, string, error) { + result := &ExecutionResult{ + Bridge: "near-intents", Route: route, AmountUSD: amountUSD, + FromChain: route.FromChain, ToChain: route.ToChain, + FromToken: route.FromToken, ToToken: route.ToToken, + } + if e.nearIntents == nil { + return result, "", fmt.Errorf("near-intents client not configured") + } + + originAsset, ok := nearIntentsAssetID(route.FromChain, route.FromToken) + if !ok { + return result, "", fmt.Errorf("near-intents unsupported origin %s/%s", route.FromChain, route.FromToken) + } + destAsset, ok := nearIntentsAssetID(route.ToChain, route.ToToken) + if !ok { + return result, "", fmt.Errorf("near-intents unsupported destination %s/%s", route.ToChain, route.ToToken) + } + + recipient := e.walletManager.EVMAddress + if route.ToChain == "Solana" { + recipient = e.walletManager.SolanaAddress + } + refundTo := e.walletManager.EVMAddress + if route.FromChain == "Solana" { + refundTo = e.walletManager.SolanaAddress + } + + quote, quoteLat, err := e.nearIntents.GetExecutableQuote(originAsset, destAsset, rawUnits, recipient, refundTo) + if err != nil { + return result, "", err + } + result.QuoteLatencyMs = quoteLat.Milliseconds() + + // Send the exact input the solver expects to its deposit address. + sendRaw := quote.Quote.AmountIn + if sendRaw == "" { + sendRaw = rawUnits + } + broadcastStart := time.Now() + var txHash string + if route.FromChain == "Solana" { + amt, perr := strconv.ParseUint(sendRaw, 10, 64) + if perr != nil { + return result, "", fmt.Errorf("parse amountIn %q: %w", sendRaw, perr) + } + txHash, err = e.txExecutor.TransferSPL(solanaUSDCMint, quote.Quote.DepositAddress, amt, usdcDecimals) + } else { + txHash, err = e.txExecutor.TransferERC20(route.FromChain, route.FromToken, quote.Quote.DepositAddress, sendRaw) + } + if err != nil { + // A send error may follow node acceptance: keep the hash so callers + // classify this as in-flight, never retry-safe. + return result, txHash, fmt.Errorf("near-intents deposit transfer failed: %w", err) + } + result.TxHash = txHash + + // Best-effort notify; the solver also detects the deposit on-chain. + if serr := e.nearIntents.SubmitDeposit(txHash, quote.Quote.DepositAddress, quote.Quote.DepositMemo); serr != nil { + log.Printf(" ⚠️ near-intents deposit/submit failed (solver will still detect on-chain): %v", serr) + } + + // Poll to settlement. + status := e.pollNearIntentsSettle(quote.Quote.DepositAddress, quote.Quote.DepositMemo, 5*time.Minute) + now := time.Now() + result.ExecutionLatencyMs = now.Sub(broadcastStart).Milliseconds() + result.E2ELatencyMs = now.Sub(quoteStart).Milliseconds() + + switch status { + case "SUCCESS": + result.Success = true + case "REFUNDED", "FAILED": + result.Reverted = true + result.Refunded = status == "REFUNDED" + default: + // Timed out in PROCESSING/PENDING: terminal-ambiguous. Leave Success + // false with the TxHash set so the caller treats it as in-flight. + return result, txHash, fmt.Errorf("near-intents did not settle within timeout (last status %q)", status) + } + + inUsd := parseFloatOrZero(quote.Quote.AmountInUsd) + outUsd := parseFloatOrZero(quote.Quote.AmountOutUsd) + result.OutputUSD = outUsd + if inUsd > 0 && outUsd > 0 && inUsd >= outUsd { + result.ActualFeeUSD = inUsd - outUsd + } + return result, txHash, nil +} + +// pollNearIntentsSettle polls 1Click status until a terminal state or timeout, +// returning the last observed status. +func (e *Executor) pollNearIntentsSettle(depositAddress, memo string, timeout time.Duration) string { + deadline := time.Now().Add(timeout) + last := "UNKNOWN" + for time.Now().Before(deadline) { + time.Sleep(nearIntentsSettleWaitMs) + st, err := e.nearIntents.Status(depositAddress, memo) + if err != nil { + continue + } + last = st + switch st { + case "SUCCESS", "REFUNDED", "FAILED": + return st + } + } + return last +} + +// TransferERC20 sends `rawAmount` (base units, decimal string) of an ERC-20 to +// `to` on an EVM chain via a standard transfer(address,uint256) call. +func (tx *TxExecutor) TransferERC20(chain, tokenAddr, to, rawAmount string) (string, error) { + amt := new(big.Int) + if _, ok := amt.SetString(strings.TrimSpace(rawAmount), 10); !ok { + return "", fmt.Errorf("invalid raw amount %q", rawAmount) + } + // transfer(address,uint256) selector 0xa9059cbb + data := append(hexutil.MustDecode("0xa9059cbb"), + append(common.LeftPadBytes(common.HexToAddress(to).Bytes(), 32), + common.LeftPadBytes(amt.Bytes(), 32)...)...) + return tx.ExecuteEVMTransaction(chain, tokenAddr, "0x"+common.Bytes2Hex(data), "0") +} + +// TransferSPL sends `rawAmount` base units of an SPL token to the associated +// token account of `toOwner`, creating that ATA idempotently first. Used for +// the Solana-source leg of the Near Intents USDC triangle. +func (tx *TxExecutor) TransferSPL(mintB58, toOwnerB58 string, rawAmount uint64, decimals uint8) (string, error) { + if tx.dryRun { + return "dry-run-spl-transfer", nil + } + mint, err := solana.PublicKeyFromBase58(mintB58) + if err != nil { + return "", fmt.Errorf("invalid mint: %w", err) + } + toOwner, err := solana.PublicKeyFromBase58(toOwnerB58) + if err != nil { + return "", fmt.Errorf("invalid deposit owner: %w", err) + } + owner := tx.solanaPrivateKey.PublicKey() + + sourceATA, _, err := solana.FindAssociatedTokenAddress(owner, mint) + if err != nil { + return "", fmt.Errorf("source ATA: %w", err) + } + destATA, _, err := solana.FindAssociatedTokenAddress(toOwner, mint) + if err != nil { + return "", fmt.Errorf("dest ATA: %w", err) + } + + createIx := ata.NewCreateIdempotentInstruction(owner, toOwner, mint).Build() + transferIx := token.NewTransferCheckedInstruction(rawAmount, decimals, sourceATA, mint, destATA, owner, nil).Build() + + ctx := context.Background() + recent, err := tx.solanaClient.GetLatestBlockhash(ctx, rpc.CommitmentFinalized) + if err != nil { + return "", fmt.Errorf("blockhash: %w", err) + } + transaction, err := solana.NewTransaction( + []solana.Instruction{createIx, transferIx}, + recent.Value.Blockhash, + solana.TransactionPayer(owner), + ) + if err != nil { + return "", fmt.Errorf("build spl tx: %w", err) + } + if _, err := transaction.Sign(func(k solana.PublicKey) *solana.PrivateKey { + if k.Equals(owner) { + return &tx.solanaPrivateKey + } + return nil + }); err != nil { + return "", fmt.Errorf("sign spl tx: %w", err) + } + sig, err := tx.solanaClient.SendTransaction(ctx, transaction) + if err != nil { + if sig != (solana.Signature{}) { + return sig.String(), fmt.Errorf("send spl tx (may be accepted, sig %s): %w", sig.String(), err) + } + return "", fmt.Errorf("send spl tx: %w", err) + } + log.Printf("📤 Solana SPL transfer sent: %s", sig.String()) + return sig.String(), nil +} diff --git a/harnesses/bridge-monitor/cmd/monitor/nearintents_exec_test.go b/harnesses/bridge-monitor/cmd/monitor/nearintents_exec_test.go new file mode 100644 index 000000000..ca7a1a99d --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/nearintents_exec_test.go @@ -0,0 +1,56 @@ +package main + +import "testing" + +// TestNearIntentsTriangleSupported verifies every leg of the USDC-only triangle +// resolves to a Near Intents assetId on both origin and destination, so the +// intent solver can actually execute all three hops. +func TestNearIntentsTriangleSupported(t *testing.T) { + routes := GetNearIntentsTriangle() + if len(routes) != 3 { + t.Fatalf("expected 3 legs, got %d", len(routes)) + } + for _, r := range routes { + if _, ok := nearIntentsAssetID(r.FromChain, r.FromToken); !ok { + t.Errorf("%s: origin %s/%s not supported by Near Intents", r.Name, r.FromChain, r.FromToken) + } + if _, ok := nearIntentsAssetID(r.ToChain, r.ToToken); !ok { + t.Errorf("%s: destination %s/%s not supported by Near Intents", r.Name, r.ToChain, r.ToToken) + } + // Every leg is USDC-denominated, so the source classifies as USDC and + // converts 1:1 at 6 decimals (no meme/decimal edge cases). + if getSourceTokenName(r) != "USDC" { + t.Errorf("%s: source token classified as %s, want USDC", r.Name, getSourceTokenName(r)) + } + } +} + +// TestNearIntentsTriangleConserves checks the triangle closes: the set of chains +// touched as a source equals the set touched as a destination, so inventory +// returns to start over a full cycle. +func TestNearIntentsTriangleConserves(t *testing.T) { + routes := GetNearIntentsTriangle() + src := map[string]int{} + dst := map[string]int{} + for _, r := range routes { + src[r.FromChain]++ + dst[r.ToChain]++ + } + for chain, n := range src { + if dst[chain] != n { + t.Errorf("chain %s is a source %d times but a destination %d times: cycle does not conserve", chain, n, dst[chain]) + } + } + if src["Solana"] != 1 || src["Base"] != 1 || src["Arbitrum"] != 1 { + t.Errorf("expected exactly one leg sourced from each of Solana/Base/Arbitrum, got %v", src) + } +} + +// TestTransferERC20DryRun exercises the calldata path in dry-run (no broadcast), +// confirming a valid raw amount is accepted and an invalid one is rejected. +func TestTransferERC20InvalidAmount(t *testing.T) { + tx := &TxExecutor{dryRun: true} + if _, err := tx.TransferERC20("base", baseUSDCAddr, "0x0000000000000000000000000000000000000001", "not-a-number"); err == nil { + t.Fatal("expected error for non-numeric raw amount") + } +} diff --git a/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go b/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go index c6aac96d1..60a42324a 100644 --- a/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go +++ b/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go @@ -40,11 +40,25 @@ func (tx *TxExecutor) erc20BalanceOf(chain string, token, owner common.Address) } // evmClientFor maps a Route chain name (Base / Arbitrum) to the cached ethclient. -func (tx *TxExecutor) evmClientFor(chain string) interface{ CallContract(context.Context, ethereum.CallMsg, *big.Int) ([]byte, error) } { +func (tx *TxExecutor) evmClientFor(chain string) interface { + CallContract(context.Context, ethereum.CallMsg, *big.Int) ([]byte, error) +} { + // Return a genuinely-nil interface when the concrete client failed to + // initialize. Returning a typed nil *ethclient.Client here would satisfy + // `client == nil` as FALSE in erc20BalanceOf (non-nil interface wrapping a + // nil pointer), then panic on CallContract. This is what crash-looped the + // VPS container when the portfolio API went down and the on-chain fallback + // ran against un-dialled EVM clients. switch strings.ToLower(chain) { case "base": + if tx.baseClient == nil { + return nil + } return tx.baseClient case "arbitrum": + if tx.arbitrumClient == nil { + return nil + } return tx.arbitrumClient } return nil diff --git a/harnesses/bridge-monitor/cmd/monitor/onchain_balance_test.go b/harnesses/bridge-monitor/cmd/monitor/onchain_balance_test.go new file mode 100644 index 000000000..43f3d74dd --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/onchain_balance_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +// TestErc20BalanceNilClientNoPanic guards the nil-interface trap that crash- +// looped the VPS: with un-dialled EVM clients, erc20BalanceOf must return an +// error, never panic on CallContract. +func TestErc20BalanceNilClientNoPanic(t *testing.T) { + tx := &TxExecutor{} // baseClient / arbitrumClient are nil + for _, chain := range []string{"base", "arbitrum"} { + if _, err := tx.erc20BalanceOf(chain, common.Address{}, common.Address{}); err == nil { + t.Errorf("%s: expected error for nil client, got nil (would have panicked)", chain) + } + } +} diff --git a/harnesses/bridge-monitor/cmd/monitor/pricer.go b/harnesses/bridge-monitor/cmd/monitor/pricer.go index 4a6779875..8bfe2ae3a 100644 --- a/harnesses/bridge-monitor/cmd/monitor/pricer.go +++ b/harnesses/bridge-monitor/cmd/monitor/pricer.go @@ -52,7 +52,7 @@ func TokenPriceUSD(symbol string, fallback float64) float64 { func fetchPriceUSD(symbol string) float64 { apiKey := os.Getenv("MOBULA_API_KEY") - url := fmt.Sprintf("https://demo-api.mobula.io/api/1/market/data?symbol=%s", symbol) + url := fmt.Sprintf(mobulaAPIBase()+"/api/1/market/data?symbol=%s", symbol) req, err := http.NewRequest("GET", url, nil) if err != nil { return 0 diff --git a/harnesses/bridge-monitor/cmd/monitor/reaper.go b/harnesses/bridge-monitor/cmd/monitor/reaper.go index a43874511..42e5c4246 100644 --- a/harnesses/bridge-monitor/cmd/monitor/reaper.go +++ b/harnesses/bridge-monitor/cmd/monitor/reaper.go @@ -22,24 +22,27 @@ const strandedDustUSD = 1.0 // Reaper transfers share the rebalancer's spirit of "never move more than a // tier plus buffer": one capped hop per tick instead of one big blind sweep. -const reaperMaxTransferUSD = 300 * rebalanceBufferFactor +const reaperMaxTransferUSD = 30 * rebalanceBufferFactor // Tokens that are SUPPOSED to sit on each chain. Everything else above the -// dust floor counts as stranded. TRUMP and BRETT are meme-route inventory, -// native SOL and ETH are gas. +// dust floor counts as stranded. TRUMP and BRETT are meme-route inventory +// (R4), native SOL and ETH are gas. Arb USDC is now a home pool: it is R5's +// source and the destination of the HL withdraw return leg, so the reaper must +// NOT sweep it (that would drain the R5 inventory each hour). var homeTokens = map[string]map[string]bool{ "Solana": {"USDC": true, "SOL": true, "TRUMP": true}, "Base": {"USDC": true, "ETH": true, "BRETT": true}, - "Arbitrum": {"USDT0": true, "USDT": true, "ETH": true}, + "Arbitrum": {"USDC": true, "ETH": true}, } // Stranded tokens we know how to route home. Anything else is alert-only: // building a bridge TX for an unknown asset from inside a repair loop is how // funds get burned, so we only ever move assets we have addresses for. +// Arb USDC was here as an R3-refund leftover; it is now a home pool (see +// homeTokens) so it is intentionally no longer swept. var movableStranded = map[string]map[string]string{ - "Solana": {"USDT": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"}, - "Base": {"USDT": "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2"}, - "Arbitrum": {"USDC": arbUSDCAddr}, + "Solana": {"USDT": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"}, + "Base": {"USDT": "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2"}, } type strandKey struct { @@ -153,11 +156,7 @@ func reaperTick(fetch func() (map[string]map[string]float64, bool, error), rebal // Home legs always report 0 so alert expressions have a baseline series. for _, leg := range triangleLegs { - sym := leg.Token - if leg.Chain == "Arbitrum" { - sym = "USDT0" - } - bridgeStrandedHours.WithLabelValues(leg.Chain, sym).Set(0) + bridgeStrandedHours.WithLabelValues(leg.Chain, leg.Token).Set(0) } for k, h := range hours { bridgeStrandedHours.WithLabelValues(k.Chain, k.Token).Set(h) diff --git a/harnesses/bridge-monitor/cmd/monitor/rebalancer.go b/harnesses/bridge-monitor/cmd/monitor/rebalancer.go index 9ead71dc5..1d8539a8c 100644 --- a/harnesses/bridge-monitor/cmd/monitor/rebalancer.go +++ b/harnesses/bridge-monitor/cmd/monitor/rebalancer.go @@ -95,7 +95,7 @@ type legSpec struct { var triangleLegs = []legSpec{ {Chain: "Solana", ChainAPI: "solana:solana", Token: "USDC", TokenAddr: solanaUSDCMint}, {Chain: "Base", ChainAPI: "evm:8453", Token: "USDC", TokenAddr: baseUSDCAddr}, - {Chain: "Arbitrum", ChainAPI: "evm:42161", Token: "USDT", TokenAddr: arbUSDTAddr}, + {Chain: "Arbitrum", ChainAPI: "evm:42161", Token: "USDC", TokenAddr: arbUSDCAddr}, } func findLeg(chain string) (legSpec, bool) { @@ -307,6 +307,64 @@ func (r *Rebalancer) TryUnblockTier(sim CycleSimulation, balances map[string]map } } +// EqualizeStableLegs proactively tops up every triangle home leg (Sol USDC, +// Base USDC, Arb USDT) to the largest tier plus buffer, moving from the leg +// holding the most surplus. It runs BEFORE any tier viability check so the loop +// self-heals continuously rather than only when a tier is blocked. Bounded by +// the shared slot budget and the daily spend cap; best-effort and never sources +// from a leg that would itself fall short. +func (r *Rebalancer) EqualizeStableLegs(balances map[string]map[string]float64, budget *slotBudget) { + if !r.canAct() || budget == nil { + return + } + const target = 30.0 * rebalanceBufferFactor + for _, leg := range triangleLegs { + if budget.remaining <= 0 { + return + } + if r.executor.DailySpent() >= r.executor.config.MaxDailySpendUSD { + return + } + have := legBalanceUSD(balances, leg) + if have >= target { + continue + } + sim := CycleSimulation{ + Tier: 30, + RefillChain: leg.Chain, + RefillToken: leg.Token, + RefillUSD: target - have, + } + route, amountUSD, err := BuildRefillRoute(sim, balances) + if err != nil { + // No leg holds enough surplus to cover this one right now; leave it + // for the reactive path / reaper. Too routine to alert every tick. + continue + } + if budget.blockedByInFlight(leg.Chain) { + continue + } + budget.remaining-- + bridgeRebalanceAttempts.WithLabelValues("equalize").Inc() + log.Printf("⚖️ Equalize: $%.2f %s -> %s %s (leg at $%.2f, target $%.2f)", + amountUSD, route.FromChain, sourceSymbol(route), route.ToChain, have, target) + result := r.ExecuteCheapest(route, amountUSD) + switch classifyCorrectiveResult(result) { + case outcomeSuccess: + // Reflect the move locally so the next leg sees the reduced surplus + // on the source and the increased balance on the destination. + dst := balances[route.ToChain] + if dst != nil { + dst[strings.ToLower(leg.TokenAddr)] = have + amountUSD + } + case outcomeInFlight: + budget.inFlight = true + budget.inFlightChain = leg.Chain + return + } + } +} + // ExecuteCheapest quotes all three executing bridges and broadcasts through // the cheapest one that returned a live quote. Shared by the tier rebalancer // and the stuck-fund reaper so both follow the same cost discipline. diff --git a/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go b/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go index 7350d1324..89ae779ae 100644 --- a/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go @@ -32,6 +32,12 @@ func relayChainID(chain string) int64 { return 42161 case "hypercore", "hyperliquid": return 1337 + case "bnb", "bsc": + return 56 + case "ethereum", "eth": + return 1 + case "robinhood": + return 4663 } return 0 } @@ -78,8 +84,8 @@ type RelayStepData struct { Data string `json:"data"` Value string `json:"value"` // Solana fields - Instructions []RelaySolanaInstruction `json:"instructions"` - AddressLookupTableAddresses []string `json:"addressLookupTableAddresses"` + Instructions []RelaySolanaInstruction `json:"instructions"` + AddressLookupTableAddresses []string `json:"addressLookupTableAddresses"` } type RelayQuoteResponse struct { @@ -96,9 +102,15 @@ type RelayQuoteResponse struct { TimeEstimate float64 `json:"timeEstimate"` } `json:"details"` Fees struct { - Gas struct{ AmountUsd string `json:"amountUsd"` } `json:"gas"` - RelayerGas struct{ AmountUsd string `json:"amountUsd"` } `json:"relayerGas"` - RelayerService struct{ AmountUsd string `json:"amountUsd"` } `json:"relayerService"` + Gas struct { + AmountUsd string `json:"amountUsd"` + } `json:"gas"` + RelayerGas struct { + AmountUsd string `json:"amountUsd"` + } `json:"relayerGas"` + RelayerService struct { + AmountUsd string `json:"amountUsd"` + } `json:"relayerService"` } `json:"fees"` // Transaction steps for execution (EVM or Solana) Steps []struct { diff --git a/harnesses/bridge-monitor/cmd/monitor/scheduler.go b/harnesses/bridge-monitor/cmd/monitor/scheduler.go index ca7c6f3e4..1dc27512d 100644 --- a/harnesses/bridge-monitor/cmd/monitor/scheduler.go +++ b/harnesses/bridge-monitor/cmd/monitor/scheduler.go @@ -5,122 +5,107 @@ import ( "time" ) -// Schedule defines when execution tests run -// Uses fixed UTC times so redeploys don't affect the rhythm +// Schedule defines when execution tests run. +// Uses fixed UTC times so redeploys don't affect the rhythm. +// +// Two tiers only ($3 and $30 — the $300 tier was retired): the loop is now a +// small, self-balancing carousel whose point is settlement reliability, not +// large-ticket cost. $3 runs daily for a dense datapoint, $30 twice a week for +// a larger-notional read. type Schedule struct { - // $5 tests: daily at these hours UTC - Hours5USD []int // e.g., [10] = 10:00 UTC daily + // $3 tests: daily at these hours UTC + Hours3USD []int // e.g. [10] = 10:00 UTC daily - // $50 tests: specific weekdays at hour UTC + // $30 tests: specific weekdays at hour UTC // 0=Sunday, 1=Monday, ..., 6=Saturday - Weekdays50USD []time.Weekday - Hour50USD int - - // $300 tests: specific weekdays at hour UTC (Option C — weekly rhythm so - // stats converge in ~6 weeks instead of 6 months vs the monthly cadence). - Weekdays300USD []time.Weekday - Hour300USD int + Weekdays30USD []time.Weekday + Hour30USD int } -// DefaultSchedule returns the default execution schedule -// $5: daily at 10:00 UTC -// $50: Monday & Thursday at 10:00 UTC -// $300: Monday at 10:00 UTC (weekly) +// DefaultSchedule returns the default execution schedule. +// $3: daily at 10:00 UTC +// $30: Monday & Thursday at 10:00 UTC func DefaultSchedule() *Schedule { return &Schedule{ - Hours5USD: []int{10}, // 10:00 UTC daily - Weekdays50USD: []time.Weekday{time.Monday, time.Thursday}, - Hour50USD: 10, - Weekdays300USD: []time.Weekday{time.Monday}, - Hour300USD: 10, + Hours3USD: []int{10}, // 10:00 UTC daily + Weekdays30USD: []time.Weekday{time.Monday, time.Thursday}, + Hour30USD: 10, } } -// Scheduler handles fixed-time execution scheduling +// Scheduler handles fixed-time execution scheduling. type Scheduler struct { - schedule *Schedule - exec5Chan chan struct{} - exec50Chan chan struct{} - exec300Chan chan struct{} - stopChan chan struct{} + schedule *Schedule + exec3Chan chan struct{} + exec30Chan chan struct{} + stopChan chan struct{} } -// NewScheduler creates a new scheduler with fixed times +// NewScheduler creates a new scheduler with fixed times. func NewScheduler(schedule *Schedule) *Scheduler { if schedule == nil { schedule = DefaultSchedule() } return &Scheduler{ - schedule: schedule, - exec5Chan: make(chan struct{}, 1), - exec50Chan: make(chan struct{}, 1), - exec300Chan: make(chan struct{}, 1), - stopChan: make(chan struct{}), + schedule: schedule, + exec3Chan: make(chan struct{}, 1), + exec30Chan: make(chan struct{}, 1), + stopChan: make(chan struct{}), } } -// Start begins the scheduler goroutines +// Start begins the scheduler goroutines. func (s *Scheduler) Start() { log.Println("📅 Scheduler started with fixed UTC times:") - log.Printf(" $5 tests: daily at %v:00 UTC", s.schedule.Hours5USD) - log.Printf(" $50 tests: %v at %02d:00 UTC", s.schedule.Weekdays50USD, s.schedule.Hour50USD) - log.Printf(" $300 tests: %v at %02d:00 UTC", s.schedule.Weekdays300USD, s.schedule.Hour300USD) + log.Printf(" $3 tests: daily at %v:00 UTC", s.schedule.Hours3USD) + log.Printf(" $30 tests: %v at %02d:00 UTC", s.schedule.Weekdays30USD, s.schedule.Hour30USD) - // Log next scheduled times s.logNextScheduledTimes() - go s.run5USDScheduler() - go s.run50USDScheduler() - go s.run300USDScheduler() + go s.run3USDScheduler() + go s.run30USDScheduler() } -// logNextScheduledTimes logs when the next tests will run +// logNextScheduledTimes logs when the next tests will run. func (s *Scheduler) logNextScheduledTimes() { now := time.Now().UTC() - next5 := s.nextTime5USD(now) - next50 := s.nextTime50USD(now) - next300 := s.nextTime300USD(now) - - log.Printf(" Next $5 test: %s (in %v)", next5.Format("2006-01-02 15:04 UTC"), next5.Sub(now).Round(time.Minute)) - log.Printf(" Next $50 test: %s (in %v)", next50.Format("2006-01-02 15:04 UTC"), next50.Sub(now).Round(time.Minute)) - log.Printf(" Next $300 test: %s (in %v)", next300.Format("2006-01-02 15:04 UTC"), next300.Sub(now).Round(time.Minute)) -} + next3 := s.nextTime3USD(now) + next30 := s.nextTime30USD(now) -// Exec5Chan returns the channel that fires for $5 tests -func (s *Scheduler) Exec5Chan() <-chan struct{} { - return s.exec5Chan + log.Printf(" Next $3 test: %s (in %v)", next3.Format("2006-01-02 15:04 UTC"), next3.Sub(now).Round(time.Minute)) + log.Printf(" Next $30 test: %s (in %v)", next30.Format("2006-01-02 15:04 UTC"), next30.Sub(now).Round(time.Minute)) } -// Exec50Chan returns the channel that fires for $50 tests -func (s *Scheduler) Exec50Chan() <-chan struct{} { - return s.exec50Chan +// Exec3Chan returns the channel that fires for $3 tests. +func (s *Scheduler) Exec3Chan() <-chan struct{} { + return s.exec3Chan } -// Exec300Chan returns the channel that fires for $300 tests -func (s *Scheduler) Exec300Chan() <-chan struct{} { - return s.exec300Chan +// Exec30Chan returns the channel that fires for $30 tests. +func (s *Scheduler) Exec30Chan() <-chan struct{} { + return s.exec30Chan } -// Stop stops the scheduler +// Stop stops the scheduler. func (s *Scheduler) Stop() { close(s.stopChan) } -// run5USDScheduler runs $5 tests at scheduled hours daily -func (s *Scheduler) run5USDScheduler() { +// run3USDScheduler runs $3 tests at scheduled hours daily. +func (s *Scheduler) run3USDScheduler() { for { now := time.Now().UTC() - next := s.nextTime5USD(now) + next := s.nextTime3USD(now) waitDuration := next.Sub(now) - log.Printf("⏰ $5 test scheduled for %s (waiting %v)", next.Format("2006-01-02 15:04 UTC"), waitDuration.Round(time.Minute)) + log.Printf("⏰ $3 test scheduled for %s (waiting %v)", next.Format("2006-01-02 15:04 UTC"), waitDuration.Round(time.Minute)) select { case <-time.After(waitDuration): select { - case s.exec5Chan <- struct{}{}: - log.Println("🔔 $5 execution triggered") + case s.exec3Chan <- struct{}{}: + log.Println("🔔 $3 execution triggered") default: // Channel full, skip } @@ -130,20 +115,20 @@ func (s *Scheduler) run5USDScheduler() { } } -// run50USDScheduler runs $50 tests on specific weekdays -func (s *Scheduler) run50USDScheduler() { +// run30USDScheduler runs $30 tests on specific weekdays. +func (s *Scheduler) run30USDScheduler() { for { now := time.Now().UTC() - next := s.nextTime50USD(now) + next := s.nextTime30USD(now) waitDuration := next.Sub(now) - log.Printf("⏰ $50 test scheduled for %s (waiting %v)", next.Format("2006-01-02 15:04 UTC"), waitDuration.Round(time.Minute)) + log.Printf("⏰ $30 test scheduled for %s (waiting %v)", next.Format("2006-01-02 15:04 UTC"), waitDuration.Round(time.Minute)) select { case <-time.After(waitDuration): select { - case s.exec50Chan <- struct{}{}: - log.Println("🔔 $50 execution triggered") + case s.exec30Chan <- struct{}{}: + log.Println("🔔 $30 execution triggered") default: } case <-s.stopChan: @@ -152,75 +137,28 @@ func (s *Scheduler) run50USDScheduler() { } } -// run300USDScheduler runs $300 tests on specific weekdays -func (s *Scheduler) run300USDScheduler() { - for { - now := time.Now().UTC() - next := s.nextTime300USD(now) - waitDuration := next.Sub(now) - - log.Printf("⏰ $300 test scheduled for %s (waiting %v)", next.Format("2006-01-02 15:04 UTC"), waitDuration.Round(time.Minute)) - - select { - case <-time.After(waitDuration): - select { - case s.exec300Chan <- struct{}{}: - log.Println("🔔 $300 execution triggered") - default: - } - case <-s.stopChan: - return - } - } -} - -// nextTime5USD calculates the next $5 test time -func (s *Scheduler) nextTime5USD(now time.Time) time.Time { - // Find the next scheduled hour today or tomorrow - for _, hour := range s.schedule.Hours5USD { +// nextTime3USD calculates the next $3 test time. +func (s *Scheduler) nextTime3USD(now time.Time) time.Time { + for _, hour := range s.schedule.Hours3USD { candidate := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC) if candidate.After(now) { return candidate } } - // All hours passed today, try first hour tomorrow tomorrow := now.AddDate(0, 0, 1) - return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), s.schedule.Hours5USD[0], 0, 0, 0, time.UTC) -} - -// nextTime50USD calculates the next $50 test time -func (s *Scheduler) nextTime50USD(now time.Time) time.Time { - // Start from today - candidate := time.Date(now.Year(), now.Month(), now.Day(), s.schedule.Hour50USD, 0, 0, 0, time.UTC) - - // If today's time has passed, start from tomorrow - if !candidate.After(now) { - candidate = candidate.AddDate(0, 0, 1) - } - - // Find next matching weekday - for i := 0; i < 7; i++ { - for _, wd := range s.schedule.Weekdays50USD { - if candidate.Weekday() == wd { - return candidate - } - } - candidate = candidate.AddDate(0, 0, 1) - } - - return candidate + return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), s.schedule.Hours3USD[0], 0, 0, 0, time.UTC) } -// nextTime300USD calculates the next $300 test time (weekly weekday-based) -func (s *Scheduler) nextTime300USD(now time.Time) time.Time { - candidate := time.Date(now.Year(), now.Month(), now.Day(), s.schedule.Hour300USD, 0, 0, 0, time.UTC) +// nextTime30USD calculates the next $30 test time. +func (s *Scheduler) nextTime30USD(now time.Time) time.Time { + candidate := time.Date(now.Year(), now.Month(), now.Day(), s.schedule.Hour30USD, 0, 0, 0, time.UTC) if !candidate.After(now) { candidate = candidate.AddDate(0, 0, 1) } for i := 0; i < 7; i++ { - for _, wd := range s.schedule.Weekdays300USD { + for _, wd := range s.schedule.Weekdays30USD { if candidate.Weekday() == wd { return candidate } diff --git a/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go b/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go index 7eb6d0136..71458b07b 100644 --- a/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go +++ b/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go @@ -11,7 +11,7 @@ func balancesSnapshot(sol, base, arb float64) map[string]map[string]float64 { return map[string]map[string]float64{ "Solana": {"USDC": sol, "SOL": 30}, "Base": {"USDC": base, "ETH": 40}, - "Arbitrum": {"USDT0": arb, "ETH": 25}, + "Arbitrum": {"USDC": arb, "ETH": 25}, } } @@ -103,9 +103,8 @@ func TestDowngradeLadder(t *testing.T) { tier float64 want []float64 }{ - {300, []float64{300, 50, 5}}, - {50, []float64{50, 5}}, - {5, []float64{5}}, + {30, []float64{30, 3}}, + {3, []float64{3}}, } for _, c := range cases { got := downgradeLadder(c.tier) @@ -153,9 +152,10 @@ func TestSlotBudgetSharedAcrossRungs(t *testing.T) { t.Fatalf("fresh budget: got %d remaining, want 2", b.remaining) } - // Rung 1 ($300) consumes one attempt, rung 2 ($50) consumes the second: - // the SAME budget instance is threaded through the ladder, so rung 3 - // ($5) has nothing left. Per-rung counters allowed up to 6 transfers. + // Rung 1 ($30) consumes one attempt, rung 2 ($3) consumes the second: + // the SAME budget instance is threaded through the ladder plus the + // proactive equalizer, so nothing is left after two. Per-rung counters + // previously allowed far more transfers per slot. b.remaining-- b.remaining-- if b.remaining > 0 { @@ -211,12 +211,13 @@ func TestStrandedHoursComputation(t *testing.T) { tracker := NewStrandedTracker() t0 := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC) - // USDC on Arbitrum is off-home (triangle expects USDT there). + // USDT on Solana is off-home (Solana expects USDC/SOL/TRUMP). Arb USDC is no + // longer a valid stranded probe now that it is R5's home pool. balances := balancesSnapshot(100, 100, 100) - balances["Arbitrum"]["USDC"] = 42 + balances["Solana"]["USDT"] = 42 hours := tracker.Update(balances, t0) - k := strandKey{"Arbitrum", "USDC"} + k := strandKey{"Solana", "USDT"} if hours[k] != 0 { t.Errorf("first sighting should report 0 hours, got %.2f", hours[k]) } @@ -224,8 +225,8 @@ func TestStrandedHoursComputation(t *testing.T) { if _, ok := hours[strandKey{"Solana", "USDC"}]; ok { t.Error("home leg Solana/USDC reported as stranded") } - if _, ok := hours[strandKey{"Arbitrum", "USDT0"}]; ok { - t.Error("home leg Arbitrum/USDT0 reported as stranded") + if _, ok := hours[strandKey{"Arbitrum", "USDC"}]; ok { + t.Error("home leg Arbitrum/USDC reported as stranded") } // 7 hours later the same balance is still there: above the 6h default. @@ -236,13 +237,13 @@ func TestStrandedHoursComputation(t *testing.T) { // Funds moved home: the key must report an explicit 0 so the gauge resets, // and the clock must restart if it strands again later. - delete(balances["Arbitrum"], "USDC") + delete(balances["Solana"], "USDT") hours = tracker.Update(balances, t0.Add(8*time.Hour)) if hours[k] != 0 { t.Errorf("expected explicit 0 after funds came home, got %.2f", hours[k]) } - balances["Arbitrum"]["USDC"] = 42 + balances["Solana"]["USDT"] = 42 hours = tracker.Update(balances, t0.Add(20*time.Hour)) if hours[k] != 0 { t.Errorf("re-stranding must restart the clock at 0, got %.2f", hours[k]) @@ -254,11 +255,11 @@ func TestStrandedIgnoresDustAndAddressKeys(t *testing.T) { now := time.Now().UTC() balances := balancesSnapshot(100, 100, 100) - balances["Arbitrum"]["USDC"] = 0.5 + balances["Solana"]["USDT"] = 0.5 balances["Arbitrum"][arbUSDCAddr] = 5000 hours := tracker.Update(balances, now) - if _, ok := hours[strandKey{"Arbitrum", "USDC"}]; ok { + if _, ok := hours[strandKey{"Solana", "USDT"}]; ok { t.Error("dust below $1 must not count as stranded") } for k := range hours { diff --git a/harnesses/bridge-monitor/cmd/monitor/slack.go b/harnesses/bridge-monitor/cmd/monitor/slack.go index 579f514b7..c5c6ae160 100644 --- a/harnesses/bridge-monitor/cmd/monitor/slack.go +++ b/harnesses/bridge-monitor/cmd/monitor/slack.go @@ -176,7 +176,7 @@ func (s *SlackNotifier) getBalanceSummary() string { // fetchBalances calls Mobula API to get wallet balances func (s *SlackNotifier) fetchBalances(wallet, blockchain string) string { - url := fmt.Sprintf("https://demo-api.mobula.io/api/1/wallet/portfolio?wallet=%s&blockchains=%s", wallet, blockchain) + url := fmt.Sprintf(mobulaAPIBase()+"/api/1/wallet/portfolio?wallet=%s&blockchains=%s", wallet, blockchain) req, err := http.NewRequest("GET", url, nil) if err != nil { diff --git a/harnesses/bridge-monitor/cmd/monitor/socket_bridge.go b/harnesses/bridge-monitor/cmd/monitor/socket_bridge.go index 5b640ebfe..4be0ab9a6 100644 --- a/harnesses/bridge-monitor/cmd/monitor/socket_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/socket_bridge.go @@ -51,11 +51,11 @@ func socketChainID(chain string) string { } type socketRoute struct { - OutputValueInUsd float64 `json:"outputValueInUsd"` - InputValueInUsd float64 `json:"inputValueInUsd"` - TotalGasFeesInUsd float64 `json:"totalGasFeesInUsd"` - ServiceTime int `json:"serviceTime"` - BridgeRoute struct { + OutputValueInUsd float64 `json:"outputValueInUsd"` + InputValueInUsd float64 `json:"inputValueInUsd"` + TotalGasFeesInUsd float64 `json:"totalGasFeesInUsd"` + ServiceTime int `json:"serviceTime"` + BridgeRoute struct { BridgeFeeAmount string `json:"bridgeFeeAmount"` } `json:"bridgeRoute"` } diff --git a/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go b/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go index e5772f69e..6f006a26b 100644 --- a/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go @@ -74,10 +74,10 @@ type squidGasCost struct { type squidRouteResponse struct { Route struct { Estimate struct { - FromAmountUSD string `json:"fromAmountUSD"` - ToAmountUSD string `json:"toAmountUSD"` - FeeCosts []squidFeeCost `json:"feeCosts"` - GasCosts []squidGasCost `json:"gasCosts"` + FromAmountUSD string `json:"fromAmountUSD"` + ToAmountUSD string `json:"toAmountUSD"` + FeeCosts []squidFeeCost `json:"feeCosts"` + GasCosts []squidGasCost `json:"gasCosts"` EstimatedRouteDuration int `json:"estimatedRouteDuration"` } `json:"estimate"` } `json:"route"` diff --git a/harnesses/bridge-monitor/cmd/monitor/tx_executor.go b/harnesses/bridge-monitor/cmd/monitor/tx_executor.go index a9d08380c..a79ecdf58 100644 --- a/harnesses/bridge-monitor/cmd/monitor/tx_executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/tx_executor.go @@ -427,7 +427,7 @@ func (tx *TxExecutor) PollMobulaStatus(txHash string, timeout time.Duration) (*B } func (tx *TxExecutor) getMobulaStatus(txHash string) (*BridgeStatus, error) { - url := fmt.Sprintf("https://demo-api.mobula.io/api/2/bridge/status/%s", txHash) + url := fmt.Sprintf(mobulaAPIBase()+"/api/2/bridge/status/%s", txHash) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err @@ -518,9 +518,13 @@ func (tx *TxExecutor) getLiFiStatus(txHash, fromChain, toChain string) (*BridgeS } var result struct { - Status string `json:"status"` - Sending struct{ TxHash string `json:"txHash"` } `json:"sending"` - Received struct{ TxHash string `json:"txHash"` } `json:"received"` + Status string `json:"status"` + Sending struct { + TxHash string `json:"txHash"` + } `json:"sending"` + Received struct { + TxHash string `json:"txHash"` + } `json:"received"` } if err := json.Unmarshal(body, &result); err != nil { return nil, err @@ -595,6 +599,29 @@ func (tx *TxExecutor) getRelayStatus(requestID string) (*BridgeStatus, error) { } // Close closes all connections + +// nativeBalanceUSD returns the native-token (ETH/SOL) balance of ownerAddr on +// chain, valued in USD. Used to measure the real gas we pay for an execution +// as the source-chain native balance delta (pre - post). +func (tx *TxExecutor) nativeBalanceUSD(chain, ownerAddr string) (float64, error) { + if strings.EqualFold(chain, "Solana") { + k, err := solana.PublicKeyFromBase58(ownerAddr) + if err != nil { + return 0, err + } + lamports, err := tx.solanaNativeBalance(k) + if err != nil { + return 0, err + } + return rawToFloat(lamports, 9) * TokenPriceUSD("SOL", 150), nil + } + wei, err := tx.evmNativeBalance(chain, common.HexToAddress(ownerAddr)) + if err != nil { + return 0, err + } + return rawToFloat(wei, 18) * TokenPriceUSD("ETH", 3600), nil +} + func (tx *TxExecutor) Close() { if tx.baseClient != nil { tx.baseClient.Close() diff --git a/harnesses/bridge-monitor/cmd/monitor/wallet_snapshot.go b/harnesses/bridge-monitor/cmd/monitor/wallet_snapshot.go index 986889295..20c2a1c97 100644 --- a/harnesses/bridge-monitor/cmd/monitor/wallet_snapshot.go +++ b/harnesses/bridge-monitor/cmd/monitor/wallet_snapshot.go @@ -218,9 +218,9 @@ func runDailyPnL(bc *BalanceChecker, slack *SlackNotifier) { // starts AFTER R1 has delivered ~3×amount × 99% to Base, and R3 starts AFTER R2 has // delivered ~3×amount × 99% to Arb. The per-leg requirement is therefore: // -// R1 source (Sol USDC): initial ≥ 3 × amount (no preceding inflow) -// R2 source (Base USDC): initial + R1_inflow ≥ 3×amount -// R3 source (Arb USDT): initial + R2_inflow ≥ 3×amount +// R1 source (Sol USDC): initial ≥ 3 × amount (no preceding inflow) +// R2 source (Base USDC): initial + R1_inflow ≥ 3×amount +// R3 source (Arb USDC): initial + R2_inflow ≥ 3×amount // // where inflow ≈ 3×amount × (1 - avg_fee). Using a conservative 2% cumulative loss. func formatTierHealth(balances map[string]map[string]float64) string { @@ -228,8 +228,8 @@ func formatTierHealth(balances map[string]map[string]float64) string { solUSDC := balances["Solana"]["USDC"] baseUSDC := balances["Base"]["USDC"] - arbUSDT := balances["Arbitrum"]["USDT0"] - tiers := []float64{5, 50, 300} + arbUSDC := balances["Arbitrum"]["USDC"] + tiers := []float64{3, 30} // Simulate the cycle for a given tier using the sequential per-bridge model // (1× tier per leg, matches cycle_sim.SimulateTriangleCycle). Shared logic @@ -254,10 +254,10 @@ func formatTierHealth(balances map[string]map[string]float64) string { return result{r1OK: true, blockLeg: "R2 Base USDC", blockNeed: need, blockHave: baseEffective} } r.r2OK = true - arbEffective := arbUSDT + need*netFactor // R2 inflow + arbEffective := arbUSDC + need*netFactor // R2 inflow if arbEffective < need { - return result{r1OK: true, r2OK: true, blockLeg: "R3 Arb USDT", blockNeed: need, blockHave: arbEffective} + return result{r1OK: true, r2OK: true, blockLeg: "R3 Arb USDC", blockNeed: need, blockHave: arbEffective} } r.r3OK = true return r @@ -273,7 +273,7 @@ func formatTierHealth(balances map[string]map[string]float64) string { }{ {"R1 Sol USDC ", func(r result) bool { return r.r1OK }}, {"R2 Base USDC", func(r result) bool { return r.r2OK }}, - {"R3 Arb USDT ", func(r result) bool { return r.r3OK }}, + {"R3 Arb USDC ", func(r result) bool { return r.r3OK }}, } results := make(map[float64]result) for _, t := range tiers { @@ -308,12 +308,12 @@ func formatTierHealth(balances map[string]map[string]float64) string { // Can we cover it from other legs? (for R1 Sol blocker only — R2/R3 fed by upstream) if strings.HasPrefix(r.blockLeg, "R1") { - totalStable := solUSDC + baseUSDC + arbUSDT + totalStable := solUSDC + baseUSDC + arbUSDC if totalStable < r.blockNeed { grid.WriteString(fmt.Sprintf(" ⚠️ total stable pool $%.2f < $%.2f required — tier not viable without external top-up of $%.0f\n", totalStable, r.blockNeed, r.blockNeed-totalStable)) } else { - grid.WriteString(fmt.Sprintf(" → pull from Base USDC ($%.0f) or Arb USDT ($%.0f) to Sol USDC\n", baseUSDC, arbUSDT)) + grid.WriteString(fmt.Sprintf(" → pull from Base USDC ($%.0f) or Arb USDC ($%.0f) to Sol USDC\n", baseUSDC, arbUSDC)) } } } diff --git a/harnesses/fiat-onramp-cost/.env.example b/harnesses/fiat-onramp-cost/.env.example new file mode 100644 index 000000000..efca4756a --- /dev/null +++ b/harnesses/fiat-onramp-cost/.env.example @@ -0,0 +1,24 @@ +# Fiat on-ramp all-in cost harness. Every provider is optional: an unset +# key skips the provider (it is never reported as failed). Nothing here is +# committed; Railway holds the real values. + +# Direct on-ramp cohort +MOONPAY_PK= # publishable key (pk_live_… / pk_test_…), dev.moonpay.com +TRANSAK_API_KEY= # partner API key, dashboard.transak.com +TRANSAK_STAGING=false # true = api-stg.transak.com +RAMP_HOST_API_KEY= # host API key, dashboard.ramp.network +MERCURYO_WIDGET_ID= # widget id issued by Mercuryo for the OCB integration + +# Aggregator cohort (scored separately, never ranked against direct) +ONRAMPER_API_KEY= # pk_prod_… from dashboard.onramper.com +MELD_API_KEY= # dashboard.meld.io; BASIC auth +MELD_SANDBOX=false # true = api-sb.meld.io + +# Spot reference (Kraken is keyless; Pyth Hermes requires a key since 2026-08-26) +PYTH_API_KEY= + +# Runtime +CYCLE_SECONDS=300 +LISTEN_ADDR=:2113 +PROVIDER_CONCURRENCY=2 +ENABLE_ATM_COHORT=false diff --git a/harnesses/fiat-onramp-cost/CHECKLIST.md b/harnesses/fiat-onramp-cost/CHECKLIST.md new file mode 100644 index 000000000..a50bc2b29 --- /dev/null +++ b/harnesses/fiat-onramp-cost/CHECKLIST.md @@ -0,0 +1,86 @@ +# fiat-onramp-cost · verification checklist + +Rule from the spec: never invent an endpoint or parameter. Everything below is either VERIFIED (read from the provider's live documentation or a live keyless call on the date shown) or UNVERIFIED (written from documentation that could not be exercised without a key, or from a search summary). An UNVERIFIED item is not a guess about behaviour, it is a documented shape we could not call. + +## Spot references + +| Item | Status | Source | +|---|---|---| +| Kraken `GET /0/public/Ticker?pair=XBTEUR,ETHEUR,USDCEUR`, result keys `XXBTZEUR`, `XETHZEUR`, `USDCEUR`, `a`/`b` arrays | VERIFIED live 2026-09-10 (66,437 EUR BTC, 0.02 bps spread) | api.kraken.com | +| Pyth Hermes `GET /v2/updates/price/latest?ids[]=…&parsed=true` | VERIFIED shape; live returns 401 without `PYTH_API_KEY` since 2026-08-26 | hermes.pyth.network | +| Pyth feed ids btc `e62df6c8…`, eth `ff61491a…`, usdc `eaa020c6…`, eurusd `a995d00b…` | VERIFIED 2026-09-10 on `/v2/price_feeds` | hermes.pyth.network | + +## MoonPay (direct) + +| Item | Status | +|---|---| +| `GET /v3/currencies/{code}/buy_quote?apiKey=pk_…&baseCurrencyCode=eur&baseCurrencyAmount=&paymentMethod=&areFeesIncluded=true` | VERIFIED in docs 2026-09-10 (dev.moonpay.com/reference/getbuyquote) | +| Response fields `quoteCurrencyAmount`, `quoteCurrencyPrice`, `feeAmount`, `extraFeeAmount`, `networkFeeAmount`, `totalAmount`, `expiresIn` | VERIFIED in docs | +| No country parameter; pricing by request IP (`country_source=ip`) | VERIFIED in docs | +| Payment method values `credit_debit_card`, `sepa_bank_transfer` | VERIFIED in widget design guide | +| Currency codes `usdc_base`, `usdc_arbitrum` | UNVERIFIED on live: with a `pk_test_` key the API answers `Currency not supported in test mode` (test-mode restriction, not a code error). `btc`, `eth`, `usdc` VERIFIED live 2026-09-14 | +| Live call | VERIFIED 2026-09-14 with a `pk_test_` key: `quoteCurrencyAmount`, `quoteCurrencyPrice`, `feeAmount` (3.99 minimum), `extraFeeAmount`, `networkFeeAmount`, `totalAmount` all present for card and sepa; test-mode prices track Kraken mid within ~4 % (MoonPay spread) | + +## Transak (direct) + +| Item | Status | +|---|---| +| `GET /api/v1/pricing/public/quotes` with `x-api-key` header and `partnerApiKey` param | VERIFIED in docs 2026-09-10 | +| Params `fiatCurrency`, `cryptoCurrency`, `network`, `isBuyOrSell=BUY`, `fiatAmount`, `paymentMethod`, `quoteCountryCode` | VERIFIED in docs | +| Response `response.{fiatAmount,cryptoAmount,marketConversionPrice,totalFee,feeBreakdown[]}` | VERIFIED in docs | +| Network value for Bitcoin (`mainnet`) | VERIFIED live 2026-09-14 (also `ethereum`, `base`, `arbitrum`) | +| `feeBreakdown[].id` values | VERIFIED live 2026-09-14: `transak_fee`, `network_fee` (substring match holds). `marketConversionPrice` is crypto per EUR (inverted in the adapter) | +| Live call | VERIFIED 2026-09-14 on `api.transak.com` (production key). EUR payment methods on this account: card, Apple Pay, Google Pay; `sepa_bank_transfer` answers 400 "Invalid payment method" and is recorded as no_quote until bank transfers are enabled (KYB) | + +## Ramp (direct) + +| Item | Status | +|---|---| +| `POST /api/host-api/v3/onramp/quote/all?hostApiKey=` with body `cryptoAssetSymbol`, `fiatCurrency`, `fiatValue`, `userCountryCode` | VERIFIED in docs 2026-09-10 | +| Response: `asset{symbol,decimals,price{EUR}}` plus one key per payment method (`CARD_PAYMENT`, `MANUAL_BANK_TRANSFER`, …) each `{cryptoAmount (base units string), fiatValue, appliedFee, baseRampFee}` | VERIFIED in docs | +| Rate limit 100/min, 1000/15 min per IP | VERIFIED in docs | +| Asset symbols `BTC_BTC`, `ETH_ETH`, `BASE_USDC`, `ARBITRUM_USDC` | UNVERIFIED against live (CHAIN_TOKEN convention; confirm on `/api/host-api/v3/assets`) | +| Live call | UNVERIFIED, no host key | + +## Mercuryo (direct) + +| Item | Status | +|---|---| +| `GET /v1.6/widget/buy/rate?from=EUR&to=&amount=&network=&widget_id=` | VERIFIED in docs 2026-09-10 (path and params) | +| Response shape | VERIFIED live 2026-09-14: `{status, data:{amount, rate, fee:{BTC,EUR}, mercuryo_fee:{..}, network_fee:{..}, partner_fee:null, fiat_amount, subtotal:{..}, total:{..}, kyc_limits, ...}}`. Money fields are `{crypto, fiat}` pairs, not scalars; parser rewritten, `testdata/mercuryo_rate.json` is a real response | +| No payment-method parameter: only the `card` cell is recorded, `sepa` is `no_quote` | VERIFIED in docs (absence of parameter) | +| Network values `BITCOIN`, `ETHEREUM`, `BASE`, `ARBITRUM` | VERIFIED live 2026-09-14, all four quote | +| Live call | VERIFIED 2026-09-14 using the public `widget_id` Mercuryo embeds on its own homepage; an OCB-issued id from dashboard.mercuryo.io is still to be requested | + +## Onramper (aggregator) + +| Item | Status | +|---|---| +| `GET /quotes/{fiat}/{crypto}?amount=&paymentMethod=&country=&type=buy`, `Authorization: pk_prod_…` | VERIFIED in docs 2026-09-10 (page dated 2026-07-28) | +| Response array of `{ramp, paymentMethod, rate, payout, networkFee, transactionFee, quoteId, errors[]}` | VERIFIED in docs | +| Crypto ids `usdc_base`, `usdc_arbitrum` | UNVERIFIED against live (confirm on `/supported/crypto`) | +| Payment method id `sepabanktransfer` | UNVERIFIED against live (`creditcard` is the documented example) | +| Live call | UNVERIFIED, no key | + +## Meld (aggregator) + +| Item | Status | +|---|---| +| Base hosts `api.meld.io` / `api-sb.meld.io` | VERIFIED in docs | +| Auth `Authorization: BASIC ` | UNVERIFIED (docs describe BASIC auth with the API key; exact encoding not exercised) | +| Quote path `/payments/crypto/quote` | UNVERIFIED (the reference page returned 404 on 2026-09-10; a search summary names `/payments/virtual-account/quote` for the virtual-account flavour) | +| Params `countryCode`, `sourceCurrencyCode`, `destinationCurrencyCode`, `paymentMethodType`, `sourceAmount` | UNVERIFIED | +| Response `quotes[]{serviceProvider, sourceAmount, destinationAmount, exchangeRate, totalFee, networkFee, transactionFee, partnerFee}` | UNVERIFIED | +| Currency codes `USDC_BASE`, `USDC_ARBITRUM` | UNVERIFIED | +| Live call | UNVERIFIED, no key | + +## Excluded from v1 and why + +- Coinbase Onramp: the CDP terms reportedly restrict benchmarking (UNVERIFIED verbatim, the terms page returned 403). Not wired until read. +- Apple Pay / Google Pay cells: not quotable server-side without a real wallet session. +- ATM cohort: no public quote API; `ENABLE_ATM_COHORT` is reserved. +- HMAC-signed providers (Banxa, Alchemy Pay direct): reachable only through the aggregators in v1. + +## How to flip an item to VERIFIED + +Get the key, run `ONCE=1 go run ./cmd/script`, keep the raw response (the harness logs `RawJSONHash`, not the body; capture the body once with `curl`), update the fixture in `cmd/script/testdata/` to the live shape, run `go test ./...`, then edit this file. diff --git a/harnesses/fiat-onramp-cost/Dockerfile b/harnesses/fiat-onramp-cost/Dockerfile new file mode 100644 index 000000000..5b4b82e0f --- /dev/null +++ b/harnesses/fiat-onramp-cost/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.24-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/onramp ./cmd/script + +FROM debian:bookworm-slim +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/onramp /app/onramp +EXPOSE 2113 +CMD ["/app/onramp"] diff --git a/harnesses/fiat-onramp-cost/README.md b/harnesses/fiat-onramp-cost/README.md new file mode 100644 index 000000000..cc70d74ae --- /dev/null +++ b/harnesses/fiat-onramp-cost/README.md @@ -0,0 +1,78 @@ +# Harness · fiat-onramp-cost + +> Produces the `onramp_quote_*` and `spot_reference_*` metrics consumed by [`benchmarks/fiat-onramp-cost.yml`](../../benchmarks/fiat-onramp-cost.yml). + +**Bench**: [№ 262 · Fiat on-ramp all-in cost](../../benchmarks/fiat-onramp-cost.yml) + +## What it measures + +One persona, one region: a retail buyer in France paying in EUR from the eu-west vantage. Every 5 minutes the harness asks each provider for a buy quote on a fixed grid and converts the answer into one number a reader can compare across providers: + +``` +all_in_premium_bps = (fiat_in / crypto_out − spot) / spot × 10 000 +``` + +`fiat_in` is what the buyer pays, `crypto_out` is what lands in the wallet, `spot` is an exchange mid sampled in the same cycle. The number folds the declared fee, the network fee and whatever spread is hidden in the provider's rate into one figure. The declared fees are recorded separately so the hidden part is visible: + +``` +declared_fee_bps = (fee_provider + fee_network + fee_partner) / fiat_in × 10 000 +hidden_spread_bps = all_in_premium_bps − declared_fee_bps +``` + +Grid: assets `btc/bitcoin`, `usdc/base` (fallback `usdc/arbitrum` when a provider does not list Base), `eth/ethereum`; notionals 100 and 500 EUR; payment methods card and SEPA. 12 cells per provider per cycle. + +Two cohorts, never ranked against each other: + +- `onramp`: MoonPay, Transak, Ramp, Mercuryo, quoted directly through their own API. +- `aggregator`: Onramper and Meld. One request returns one quote per member ramp; each becomes a sample labelled `provider= via=`. + +## Spot references + +- `kraken_eur` (headline): Kraken public Ticker, EUR pairs, mid of best bid and ask. Keyless. +- `pyth`: Pyth Hermes `BTC/USD`, `ETH/USD`, `USDC/USD` divided by `EUR/USD`. Needs `PYTH_API_KEY` since 2026-08-26. Cross-check only. + +`spot_reference_divergence_bps{asset}` records the gap between the two so a reader can judge how much the reference choice moves the result. + +## Metrics + +``` +onramp_quote_all_in_premium_bps{provider,cohort,via,asset,network,payment_method,notional,fiat,country,region,country_source,spot_ref} gauge +onramp_quote_declared_fee_bps{…same minus spot_ref} gauge +onramp_quote_hidden_spread_bps{…with spot_ref} gauge +onramp_quote_crypto_out{…} gauge +onramp_quote_ttl_seconds{…} gauge (0 when the provider returns none) +onramp_quote_success{…} gauge 1/0 per cell +onramp_quote_latency_ms{provider} histogram +onramp_quote_errors_total{provider,reason} counter (no_quote, http_4xx, http_5xx, rate_limited, timeout, network, parse) +onramp_quote_samples_total{…} counter, one per usable quote (use increase() for sample counts) +onramp_limits_min_fiat / onramp_limits_max_fiat{provider,payment_method,asset} gauge +spot_reference_price{asset,fiat,source} gauge +spot_reference_divergence_bps{asset,fiat} gauge +onramp_last_cycle_timestamp_seconds gauge +``` + +`country_source` is `param` when the provider's API takes a country code (Transak, Ramp, Onramper, Meld) and `ip` when it geolocates the request (MoonPay, Mercuryo). The harness runs from an EU vantage so both resolve to France, but the reader should know which. + +A cell that stops answering is purged, not frozen: the gauges are deleted and `onramp_quote_success` goes to 0. Same rule as bench 001. + +## Reproduce in five steps + +1. `cp .env.example .env` and fill the keys you have. Any provider without a key is skipped. +2. `go test ./...` runs the maths, the spot parsers and every adapter against the fixtures in `cmd/script/testdata/`. +3. `set -a && source .env && set +a && go run ./cmd/script` starts the loop. `ONCE=1` runs one cycle and exits. +4. `curl localhost:2113/metrics` shows the gauges; `curl localhost:2113/config` shows the resolved grid with keys redacted. +5. `OCB_LIVE=1 go test -run TestLiveSpotReferencesAgree ./cmd/script` checks the two spot references against each other over the network. + +Port: `:2113`. Endpoints: `/metrics`, `/healthz`, `/config`. + +## Rate limits and budget + +Ramp documents 100 requests/min and 1000/15 min per source IP. One cycle is at most 12 requests per provider, spaced by a per-provider semaphore of 2 in flight, so a 5 minute cycle stays two orders of magnitude under that. Each cycle's start is jittered by ±10 % so no provider sees a fixed pattern from one IP. A 4xx is never retried; a 5xx or timeout is retried once. + +## What is not verified against live + +See [CHECKLIST.md](./CHECKLIST.md). The harness never invents a response: each adapter is written against the provider's documented schema and the fixtures mirror that documentation, but until a key is issued the exact currency codes and a few response fields carry an UNVERIFIED flag. + +## Hosting + +Container `ocb-fiat-onramp-cost` on the Paris VPS (eu-west), same Docker network as the shared Prometheus, scraped as job `fiat-onramp-cost` (see [`infrastructure/prometheus/prometheus.yml`](../../infrastructure/prometheus/prometheus.yml)). Provider keys live in the VPS env file for the container and nowhere in git. `railway.toml` is kept so the same image runs on Railway if a second region is ever added. diff --git a/harnesses/fiat-onramp-cost/cmd/script/adapter.go b/harnesses/fiat-onramp-cost/cmd/script/adapter.go new file mode 100644 index 000000000..644df7c7a --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/adapter.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "errors" + "time" +) + +// QuoteRequest is one cell of the persona grid. +type QuoteRequest struct { + Asset AssetSpec + Network string // the network actually requested (primary or fallback) + PaymentMethod string // card, sepa + Notional float64 +} + +// Adapter is the one interface every provider implements. Quote returns +// one or more normalized quotes: exactly one for a direct provider, one +// per member ramp for an aggregator. ErrNoQuote means the provider +// answered without a usable quote for this cell. +type Adapter interface { + Slug() string + Cohort() string // "onramp" or "aggregator" + Enabled() bool // false when its key is unset: skipped, never failed + // CountrySource says how the persona country reaches the provider: + // "param" when the API takes a country, "ip" when it geolocates. + CountrySource() string + Quote(ctx context.Context, req QuoteRequest) ([]NormalizedQuote, time.Duration, error) +} + +// buildAdapters wires every provider that has a key. Order is stable so +// logs are readable. +func buildAdapters(cfg *Config) []Adapter { + return []Adapter{ + &moonpayAdapter{pk: cfg.MoonPayPK}, + &transakAdapter{key: cfg.TransakAPIKey, staging: cfg.TransakStaging}, + &rampAdapter{hostKey: cfg.RampHostAPIKey}, + &mercuryoAdapter{widgetID: cfg.MercuryoWidgetID}, + &onramperAdapter{key: cfg.OnramperAPIKey}, + &meldAdapter{key: cfg.MeldAPIKey, sandbox: cfg.MeldSandbox}, + } +} + +// classifyErr maps an adapter error to the errors_total reason label. +func classifyErr(err error) string { + var se *httpStatusError + switch { + case err == nil: + return "" + case errors.Is(err, ErrNoQuote): + return "no_quote" + case errors.As(err, &se) && se.Status >= 500: + return "http_5xx" + case errors.As(err, &se) && se.Status == 429: + return "rate_limited" + case errors.As(err, &se): + return "http_4xx" + case isTimeout(err): + return "timeout" + case errors.Is(err, ErrRetryable): + return "network" + default: + return "parse" + } +} diff --git a/harnesses/fiat-onramp-cost/cmd/script/adapters_test.go b/harnesses/fiat-onramp-cost/cmd/script/adapters_test.go new file mode 100644 index 000000000..24b59bb4b --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/adapters_test.go @@ -0,0 +1,264 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +// Every adapter is exercised against a fixture that mirrors its documented +// response shape, through the real HTTP path (headers, params, retry). +// The fixtures are documented shapes, not live captures: see CHECKLIST.md +// for which fields are verified against live. + +func fixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile("testdata/" + name) + if err != nil { + t.Fatal(err) + } + return b +} + +func serve(t *testing.T, slug string, check func(r *http.Request), body []byte, status int) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if check != nil { + check(r) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(body) + })) + t.Cleanup(srv.Close) + endpointOverride[slug] = srv.URL + t.Cleanup(func() { delete(endpointOverride, slug) }) +} + +var btcSepa500 = QuoteRequest{Asset: AssetSpec{Asset: "btc", Network: "bitcoin"}, Network: "bitcoin", PaymentMethod: "sepa", Notional: 500} + +func TestMoonPayAdapter(t *testing.T) { + serve(t, "moonpay", func(r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/v3/currencies/btc/buy_quote") { + t.Errorf("path %s", r.URL.Path) + } + q := r.URL.Query() + if q.Get("apiKey") != "pk_test_x" || q.Get("baseCurrencyAmount") != "500" || q.Get("paymentMethod") != "sepa_bank_transfer" || q.Get("areFeesIncluded") != "true" { + t.Errorf("params %v", q) + } + }, fixture(t, "moonpay_buy_quote.json"), 200) + a := &moonpayAdapter{pk: "pk_test_x"} + qs, _, err := a.Quote(context.Background(), btcSepa500) + if err != nil { + t.Fatal(err) + } + q := qs[0] + if q.Provider != "moonpay" || q.Via != "direct" || q.Cohort != "onramp" || q.CountrySource != "ip" { + t.Errorf("identity %+v", q) + } + if q.FiatIn != 500 || q.CryptoOut != 0.00744 || q.TTLSeconds != 60 { + t.Errorf("amounts %+v", q) + } + d, _ := DeclaredFeeBps(q) + if !near(d, (19.62+0.38)/500*1e4, 0.01) { + t.Errorf("declared %v", d) + } +} + +func TestMoonPay4xxIsNotRetriedAndClassified(t *testing.T) { + hits := 0 + serve(t, "moonpay", func(*http.Request) { hits++ }, []byte(`{"message":"bad","type":"BadRequestError"}`), 400) + a := &moonpayAdapter{pk: "pk"} + _, _, err := a.Quote(context.Background(), btcSepa500) + if err == nil || hits != 1 { + t.Fatalf("err=%v hits=%d", err, hits) + } + if classifyErr(err) != "http_4xx" { + t.Errorf("reason %s", classifyErr(err)) + } +} + +func TestMoonPay5xxRetriedOnce(t *testing.T) { + hits := 0 + serve(t, "moonpay", func(*http.Request) { hits++ }, []byte(`{}`), 503) + a := &moonpayAdapter{pk: "pk"} + _, _, err := a.Quote(context.Background(), btcSepa500) + if err == nil || hits != 2 { + t.Fatalf("err=%v hits=%d", err, hits) + } + if classifyErr(err) != "http_5xx" { + t.Errorf("reason %s", classifyErr(err)) + } +} + +func TestTransakAdapter(t *testing.T) { + serve(t, "transak", func(r *http.Request) { + if r.URL.Path != "/api/v1/pricing/public/quotes" { + t.Errorf("path %s", r.URL.Path) + } + if r.Header.Get("x-api-key") != "k1" { + t.Errorf("header missing") + } + q := r.URL.Query() + if q.Get("partnerApiKey") != "k1" || q.Get("quoteCountryCode") != "FR" || q.Get("fiatAmount") != "500" || q.Get("cryptoCurrency") != "BTC" || q.Get("isBuyOrSell") != "BUY" { + t.Errorf("params %v", q) + } + }, fixture(t, "transak_quote.json"), 200) + a := &transakAdapter{key: "k1"} + qs, _, err := a.Quote(context.Background(), btcSepa500) + if err != nil { + t.Fatal(err) + } + q := qs[0] + // Fixture is a real 2026-09-14 production response (card, 500 EUR). + if q.CryptoOut != 0.00700952 || q.FiatIn != 500 || q.CountrySource != "param" { + t.Errorf("%+v", q) + } + if q.FeeProvider != 18.5 || q.FeeNetwork != 0.36 { + t.Errorf("fee split provider=%v network=%v", q.FeeProvider, q.FeeNetwork) + } +} + +func TestRampAdapter(t *testing.T) { + serve(t, "ramp", func(r *http.Request) { + if r.Method != "POST" || !strings.HasPrefix(r.URL.Path, "/api/host-api/v3/onramp/quote/all") || r.URL.Query().Get("hostApiKey") != "hk" { + t.Errorf("%s %s", r.Method, r.URL) + } + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if body["cryptoAssetSymbol"] != "BTC_BTC" || body["fiatCurrency"] != "EUR" || body["fiatValue"] != float64(500) || body["userCountryCode"] != "FR" { + t.Errorf("body %v", body) + } + }, fixture(t, "ramp_quote_all.json"), 200) + a := &rampAdapter{hostKey: "hk"} + qs, _, err := a.Quote(context.Background(), btcSepa500) + if err != nil { + t.Fatal(err) + } + q := qs[0] + if !near(q.CryptoOut, 0.0076, 1e-12) { + t.Errorf("base units not converted: %v", q.CryptoOut) + } + if q.FeeProvider != 9.9 || q.ProviderMarketRate != 64480.5 { + t.Errorf("%+v", q) + } + card := btcSepa500 + card.PaymentMethod = "card" + qs, _, _ = a.Quote(context.Background(), card) + if !near(qs[0].CryptoOut, 0.00744, 1e-12) { + t.Errorf("card cell %v", qs[0].CryptoOut) + } +} + +func TestMercuryoAdapter(t *testing.T) { + serve(t, "mercuryo", func(r *http.Request) { + q := r.URL.Query() + if r.URL.Path != "/v1.6/widget/buy/rate" || q.Get("from") != "EUR" || q.Get("to") != "BTC" || q.Get("amount") != "500" || q.Get("widget_id") != "w" || q.Get("network") != "BITCOIN" { + t.Errorf("%s", r.URL) + } + }, fixture(t, "mercuryo_rate.json"), 200) + a := &mercuryoAdapter{widgetID: "w"} + card := btcSepa500 + card.PaymentMethod = "card" + qs, _, err := a.Quote(context.Background(), card) + if err != nil { + t.Fatal(err) + } + // Fixture is a real 2026-09-14 response: fee {BTC, EUR} objects with a + // mercuryo_fee + network_fee breakdown and a null partner_fee. + if qs[0].CryptoOut != 0.00711045 || qs[0].FeeProvider != 9.32 || qs[0].FeeNetwork != 0.27 || qs[0].FeePartner != 0 || qs[0].FiatIn != 500 || qs[0].ProviderMarketRate != 68970.31 { + t.Errorf("%+v", qs[0]) + } + // sepa is not a documented parameter: honest no_quote, no HTTP call. + if _, _, err := a.Quote(context.Background(), btcSepa500); err != ErrNoQuote { + t.Errorf("sepa should be no_quote, got %v", err) + } +} + +func TestOnramperAdapterFansOutPerRamp(t *testing.T) { + serve(t, "onramper", func(r *http.Request) { + if r.URL.Path != "/quotes/eur/btc" || r.Header.Get("Authorization") != "pk_prod_x" || r.URL.Query().Get("country") != "fr" { + t.Errorf("%s auth=%q", r.URL, r.Header.Get("Authorization")) + } + }, fixture(t, "onramper_quotes.json"), 200) + a := &onramperAdapter{key: "pk_prod_x"} + qs, _, err := a.Quote(context.Background(), btcSepa500) + if err != nil { + t.Fatal(err) + } + if len(qs) != 2 { + t.Fatalf("want 2 usable member quotes (banxa errored), got %d", len(qs)) + } + for _, q := range qs { + if q.Via != "onramper" || q.Cohort != "aggregator" || q.CountrySource != "param" { + t.Errorf("%+v", q) + } + } + if qs[0].Provider != "moonpay" || qs[0].CryptoOut != 0.00741 || qs[1].Provider != "transak" { + t.Errorf("%+v", qs) + } +} + +func TestMeldAdapter(t *testing.T) { + serve(t, "meld", func(r *http.Request) { + q := r.URL.Query() + if !strings.HasPrefix(r.Header.Get("Authorization"), "BASIC ") || q.Get("countryCode") != "FR" || q.Get("sourceCurrencyCode") != "EUR" || q.Get("destinationCurrencyCode") != "BTC" || q.Get("sourceAmount") != "500" { + t.Errorf("%s", r.URL) + } + }, fixture(t, "meld_quote.json"), 200) + a := &meldAdapter{key: "mk"} + qs, _, err := a.Quote(context.Background(), btcSepa500) + if err != nil { + t.Fatal(err) + } + if len(qs) != 2 || qs[0].Provider != "moonpay" || qs[1].Provider != "paybis" || qs[0].Via != "meld" { + t.Errorf("%+v", qs) + } +} + +func TestDisabledAdaptersAreSkippedNotFailed(t *testing.T) { + cfg := &Config{ProviderSemaphore: 1, Assets: DefaultAssets, Notionals: DefaultNotionals, PaymentMethods: DefaultPaymentMethods} + for _, a := range buildAdapters(cfg) { + if a.Enabled() { + t.Errorf("%s enabled with empty config", a.Slug()) + } + } +} + +// One full cycle end to end: spot from a stub, one direct provider and one +// aggregator from fixtures, check the gauges and the purge on the next +// cycle when a member ramp disappears. +func TestRunCycleEmitsAndPurges(t *testing.T) { + serve(t, "moonpay", nil, fixture(t, "moonpay_buy_quote.json"), 200) + serve(t, "onramper", nil, fixture(t, "onramper_quotes.json"), 200) + serve(t, "kraken", nil, []byte(krakenFixture), 200) + serve(t, "pyth", nil, []byte(`{"parsed":[]}`), 200) + cfg := &Config{ProviderSemaphore: 2, Assets: []AssetSpec{{Asset: "btc", Network: "bitcoin"}}, Notionals: []float64{500}, PaymentMethods: []string{"sepa"}} + adapters := []Adapter{&moonpayAdapter{pk: "pk"}, &onramperAdapter{key: "k"}} + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + seen := runCycle(ctx, cfg, adapters, nil) + // 1 direct + 2 members + the aggregator's own success cell + if len(seen) != 4 { + t.Fatalf("seen %d cells: %v", len(seen), seen) + } + l := labelsFor(NormalizedQuote{Provider: "transak", Cohort: "aggregator", Via: "onramper", Asset: "btc", Network: "bitcoin", PaymentMethod: "sepa", Notional: 500, CountrySource: "param"}) + if v := gaugeValue(t, quoteSuccess.With(l)); v != 1 { + t.Errorf("transak via onramper success %v", v) + } + // Next cycle: onramper drops transak. + serve(t, "onramper", nil, []byte(`[{"ramp":"moonpay","rate":64750.2,"payout":0.00741,"networkFee":0.4,"transactionFee":19.5}]`), 200) + seen = runCycle(ctx, cfg, adapters, seen) + if v := gaugeValue(t, quoteSuccess.With(l)); v != 0 { + t.Errorf("dropped member should be success=0, got %v", v) + } + if v := gaugeValue(t, cryptoOut.With(l)); v != 0 { + t.Errorf("dropped member crypto_out should be purged, got %v", v) + } +} diff --git a/harnesses/fiat-onramp-cost/cmd/script/config.go b/harnesses/fiat-onramp-cost/cmd/script/config.go new file mode 100644 index 000000000..acea11b9a --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/config.go @@ -0,0 +1,172 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// Persona. Fixed on purpose: every provider is asked the same question. +// Changing any of these is a spec change, not a config change, so they are +// constants with env overrides only for the notional/asset lists that the +// spec dimensions already enumerate. +const ( + PersonaCountry = "FR" + PersonaFiat = "EUR" + PersonaRegion = "eu-west" +) + +// AssetSpec ties a persona asset to the network it is bought on and to +// each provider's naming for both. Fallback network is used only when the +// primary is not offered by the provider for this asset. +type AssetSpec struct { + Asset string // btc, usdc, eth + Network string // bitcoin, base, ethereum + Fallback string // arbitrum for usdc, "" otherwise +} + +var DefaultAssets = []AssetSpec{ + {Asset: "btc", Network: "bitcoin"}, + {Asset: "usdc", Network: "base", Fallback: "arbitrum"}, + {Asset: "eth", Network: "ethereum"}, +} + +var DefaultNotionals = []float64{100, 500} +var DefaultPaymentMethods = []string{"card", "sepa"} + +type Config struct { + CycleSeconds int + Notionals []float64 + Assets []AssetSpec + PaymentMethods []string + EnableATM bool + ListenAddr string + + // Provider credentials. Empty means the provider is skipped, not failed. + MoonPayPK string + TransakAPIKey string + TransakStaging bool + RampHostAPIKey string + MercuryoWidgetID string + OnramperAPIKey string + MeldAPIKey string + MeldSandbox bool + + // Per-provider request budget per cycle, to stay inside documented + // rate limits. Ramp documents 100 req/min per source IP; one cycle asks + // at most assets × notionals × methods = 12 quotes per provider. + ProviderSemaphore int +} + +func loadConfig() *Config { + c := &Config{ + CycleSeconds: envInt("CYCLE_SECONDS", 300), + Notionals: DefaultNotionals, + Assets: DefaultAssets, + PaymentMethods: DefaultPaymentMethods, + EnableATM: envBool("ENABLE_ATM_COHORT", false), + ListenAddr: env("LISTEN_ADDR", ":2113"), + MoonPayPK: env("MOONPAY_PK", ""), + TransakAPIKey: env("TRANSAK_API_KEY", ""), + TransakStaging: envBool("TRANSAK_STAGING", false), + RampHostAPIKey: env("RAMP_HOST_API_KEY", ""), + MercuryoWidgetID: env("MERCURYO_WIDGET_ID", ""), + OnramperAPIKey: env("ONRAMPER_API_KEY", ""), + MeldAPIKey: env("MELD_API_KEY", ""), + MeldSandbox: envBool("MELD_SANDBOX", false), + ProviderSemaphore: envInt("PROVIDER_CONCURRENCY", 2), + } + if v := env("NOTIONALS", ""); v != "" { + c.Notionals = nil + for _, s := range strings.Split(v, ",") { + if f, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil && f > 0 { + c.Notionals = append(c.Notionals, f) + } + } + } + if v := env("PAYMENT_METHODS", ""); v != "" { + c.PaymentMethods = nil + for _, s := range strings.Split(v, ",") { + if m := strings.TrimSpace(strings.ToLower(s)); m != "" { + c.PaymentMethods = append(c.PaymentMethods, m) + } + } + } + if v := env("ASSETS", ""); v != "" { + keep := map[string]bool{} + for _, s := range strings.Split(v, ",") { + keep[strings.TrimSpace(strings.ToLower(s))] = true + } + var out []AssetSpec + for _, a := range DefaultAssets { + if keep[a.Asset] { + out = append(out, a) + } + } + if len(out) > 0 { + c.Assets = out + } + } + return c +} + +func (c *Config) Cycle() time.Duration { return time.Duration(c.CycleSeconds) * time.Second } + +// Redacted returns the config as shown on /config: every secret replaced +// by whether it is set, so a reader can reproduce the run without the keys. +func (c *Config) Redacted() map[string]any { + set := func(s string) string { + if s == "" { + return "unset" + } + return "set" + } + return map[string]any{ + "persona": map[string]string{"country": PersonaCountry, "fiat": PersonaFiat, "region": PersonaRegion}, + "cycle_seconds": c.CycleSeconds, + "notionals": c.Notionals, + "assets": c.Assets, + "payment_methods": c.PaymentMethods, + "enable_atm_cohort": c.EnableATM, + "provider_concurrency": c.ProviderSemaphore, + "keys": map[string]string{ + "MOONPAY_PK": set(c.MoonPayPK), "TRANSAK_API_KEY": set(c.TransakAPIKey), + "RAMP_HOST_API_KEY": set(c.RampHostAPIKey), "MERCURYO_WIDGET_ID": set(c.MercuryoWidgetID), + "ONRAMPER_API_KEY": set(c.OnramperAPIKey), "MELD_API_KEY": set(c.MeldAPIKey), + }, + "transak_staging": c.TransakStaging, + "meld_sandbox": c.MeldSandbox, + } +} + +func env(k, d string) string { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + return v + } + return d +} +func envInt(k string, d int) int { + if v, err := strconv.Atoi(env(k, "")); err == nil && v > 0 { + return v + } + return d +} +func envBool(k string, d bool) bool { + switch strings.ToLower(env(k, "")) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + } + return d +} + +func (c *Config) Print() { + fmt.Printf("Config: cycle=%ds notionals=%v methods=%v assets=%d atm=%v\n", + c.CycleSeconds, c.Notionals, c.PaymentMethods, len(c.Assets), c.EnableATM) + fmt.Printf("Keys: moonpay=%v transak=%v(staging=%v) ramp=%v mercuryo=%v onramper=%v meld=%v(sandbox=%v)\n", + c.MoonPayPK != "", c.TransakAPIKey != "", c.TransakStaging, c.RampHostAPIKey != "", + c.MercuryoWidgetID != "", c.OnramperAPIKey != "", c.MeldAPIKey != "", c.MeldSandbox) +} diff --git a/harnesses/fiat-onramp-cost/cmd/script/httpclient.go b/harnesses/fiat-onramp-cost/cmd/script/httpclient.go new file mode 100644 index 000000000..9d906cfe6 --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/httpclient.go @@ -0,0 +1,119 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +// One shared client with keep-alive. A client constructed per request pays +// a TCP+TLS handshake on every quote and that cost lands in the latency +// histogram, which is exactly the trap bench 069 hit. +var httpClient = &http.Client{ + Timeout: 8 * time.Second, + Transport: &http.Transport{MaxIdleConnsPerHost: 8, IdleConnTimeout: 90 * time.Second}, +} + +// ErrRetryable marks 5xx and timeouts. Adapters retry these once; a 4xx is +// the provider's final answer and is never retried. +var ErrRetryable = errors.New("retryable") + +type httpResult struct { + Status int + Body []byte + Latency time.Duration +} + +// doJSON performs a request with one retry on 5xx/timeout. The caller +// decides what a non-2xx body means; this only classifies retryability. +func doJSON(ctx context.Context, method, url string, headers map[string]string, body []byte) (httpResult, error) { + var last httpResult + var lastErr error + for attempt := 0; attempt < 2; attempt++ { + var rdr io.Reader + if body != nil { + rdr = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, url, rdr) + if err != nil { + return httpResult{}, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com/benchmarks/fiat-onramp-cost)") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + for k, v := range headers { + req.Header.Set(k, v) + } + start := time.Now() + resp, err := httpClient.Do(req) + lat := time.Since(start) + if err != nil { + lastErr = fmt.Errorf("%w: %v", ErrRetryable, err) + last = httpResult{Latency: lat} + continue + } + b, rerr := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + resp.Body.Close() + last = httpResult{Status: resp.StatusCode, Body: b, Latency: lat} + if rerr != nil { + lastErr = fmt.Errorf("%w: read: %v", ErrRetryable, rerr) + continue + } + if resp.StatusCode >= 500 { + lastErr = fmt.Errorf("%w: status %d", ErrRetryable, resp.StatusCode) + continue + } + return last, nil + } + // A 5xx that survived the retry is the provider's answer: hand it back + // with a nil error so the adapter labels it http_5xx, not timeout. + if last.Status >= 500 { + return last, nil + } + return last, lastErr +} + +func isTimeout(err error) bool { + if err == nil { + return false + } + type timeout interface{ Timeout() bool } + if t, ok := err.(timeout); ok && t.Timeout() { + return true + } + return errors.Is(err, context.DeadlineExceeded) +} + +// httpStatusError carries the provider's non-2xx answer so the cycle loop +// can label errors_total with http_4xx / http_5xx without re-parsing. +type httpStatusError struct { + Provider string + Status int + Body string +} + +func (e *httpStatusError) Error() string { + return fmt.Sprintf("%s status %d: %s", e.Provider, e.Status, e.Body) +} + +func statusErr(provider string, res httpResult) error { + return &httpStatusError{Provider: provider, Status: res.Status, Body: truncate(res.Body)} +} + +// endpointOverride lets tests point an adapter at an httptest server. It +// is never set from the environment: production always talks to the +// documented hosts. +var endpointOverride = map[string]string{} + +func baseFor(slug, def string) string { + if v := endpointOverride[slug]; v != "" { + return v + } + return def +} diff --git a/harnesses/fiat-onramp-cost/cmd/script/main.go b/harnesses/fiat-onramp-cost/cmd/script/main.go new file mode 100644 index 000000000..90c9eea43 --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/main.go @@ -0,0 +1,3 @@ +package main + +func main() { runMain() } diff --git a/harnesses/fiat-onramp-cost/cmd/script/meld.go b/harnesses/fiat-onramp-cost/cmd/script/meld.go new file mode 100644 index 000000000..ea84ec677 --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/meld.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/url" + "strings" + "time" +) + +// Meld, aggregator. GET {base}/payments/crypto/quote +// Doc: docs.meld.io (fetched 2026-09-10; the reference page for this +// endpoint returned 404 on the path I tried, and the search summary names +// /payments/virtual-account/quote for the virtual-account flavour). Path +// and auth scheme (BASIC with the API key) are UNVERIFIED against live, +// see CHECKLIST.md. countryCode=FR is a documented parameter: +// country_source="param". One quote per member onramp; each becomes a +// sample with via="meld", cohort="aggregator". + +type meldAdapter struct { + key string + sandbox bool +} + +func (m *meldAdapter) Slug() string { return "meld" } +func (m *meldAdapter) Cohort() string { return "aggregator" } +func (m *meldAdapter) Enabled() bool { return m.key != "" } + +func (m *meldAdapter) base() string { + if m.sandbox { + return baseFor("meld", "https://api-sb.meld.io") + } + return baseFor("meld", "https://api.meld.io") +} + +// Destination currency codes: Meld qualifies by network in the code +// (UNVERIFIED exact strings). +var meldCrypto = map[string]string{ + "btc/bitcoin": "BTC", "eth/ethereum": "ETH", + "usdc/base": "USDC_BASE", "usdc/arbitrum": "USDC_ARBITRUM", +} +var meldMethod = map[string]string{"card": "CREDIT_DEBIT_CARD", "sepa": "SEPA"} + +type meldQuote struct { + ServiceProvider string `json:"serviceProvider"` + SourceAmount float64 `json:"sourceAmount"` + DestinationAmount float64 `json:"destinationAmount"` + ExchangeRate float64 `json:"exchangeRate"` + TotalFee float64 `json:"totalFee"` + NetworkFee float64 `json:"networkFee"` + TransactionFee float64 `json:"transactionFee"` + PartnerFee float64 `json:"partnerFee"` + PaymentMethodType string `json:"paymentMethodType"` +} + +type meldResp struct { + Quotes []meldQuote `json:"quotes"` +} + +var meldProviderSlug = map[string]string{ + "MOONPAY": "moonpay", "TRANSAK": "transak", "RAMP": "ramp", "MERCURYO": "mercuryo", + "BANXA": "banxa", "PAYBIS": "paybis", "UNLIMIT": "unlimit", "TOPPER": "topper", + "COINBASE": "coinbase", "MESO": "meso", "STRIPE": "stripe", "BLOCKCHAINDOTCOM": "blockchain-com", + "ROBINHOOD": "robinhood-connect", "ALCHEMYPAY": "alchemypay", "GUARDARIAN": "guardarian", +} + +func (m *meldAdapter) Quote(ctx context.Context, req QuoteRequest) ([]NormalizedQuote, time.Duration, error) { + dest, ok := meldCrypto[req.Asset.Asset+"/"+req.Network] + if !ok { + return nil, 0, ErrNoQuote + } + method, ok := meldMethod[req.PaymentMethod] + if !ok { + return nil, 0, ErrNoQuote + } + q := url.Values{} + q.Set("countryCode", PersonaCountry) + q.Set("sourceCurrencyCode", "EUR") + q.Set("destinationCurrencyCode", dest) + q.Set("paymentMethodType", method) + q.Set("sourceAmount", trimFloat(req.Notional)) + u := m.base() + "/payments/crypto/quote?" + q.Encode() + auth := "BASIC " + base64.StdEncoding.EncodeToString([]byte(m.key+":")) + res, err := doJSON(ctx, "GET", u, map[string]string{"Authorization": auth}, nil) + if err != nil { + return nil, res.Latency, err + } + if res.Status != 200 { + return nil, res.Latency, statusErr("meld", res) + } + var r meldResp + if err := json.Unmarshal(res.Body, &r); err != nil { + // Some Meld endpoints return the array at top level. + var arr []meldQuote + if err2 := json.Unmarshal(res.Body, &arr); err2 != nil { + return nil, res.Latency, err + } + r.Quotes = arr + } + h := hashBody(res.Body) + var out []NormalizedQuote + for _, mq := range r.Quotes { + if mq.DestinationAmount <= 0 { + continue + } + slug := meldProviderSlug[strings.ToUpper(mq.ServiceProvider)] + if slug == "" { + slug = strings.ToLower(mq.ServiceProvider) + } + fiatIn := mq.SourceAmount + if fiatIn <= 0 { + fiatIn = req.Notional + } + out = append(out, NormalizedQuote{ + Provider: slug, Via: "meld", Cohort: "aggregator", + Asset: req.Asset.Asset, Network: req.Network, PaymentMethod: req.PaymentMethod, + Notional: req.Notional, CountrySource: "param", + FiatIn: fiatIn, CryptoOut: mq.DestinationAmount, + FeeProvider: mq.TransactionFee, FeeNetwork: mq.NetworkFee, FeePartner: mq.PartnerFee, + ProviderMarketRate: mq.ExchangeRate, RawJSONHash: h, + }) + } + if len(out) == 0 { + return nil, res.Latency, ErrNoQuote + } + return out, res.Latency, nil +} + +func (m *meldAdapter) CountrySource() string { return "param" } diff --git a/harnesses/fiat-onramp-cost/cmd/script/mercuryo.go b/harnesses/fiat-onramp-cost/cmd/script/mercuryo.go new file mode 100644 index 000000000..0c0671b7c --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/mercuryo.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "encoding/json" + "net/url" + "strconv" + "strings" + "time" +) + +// Mercuryo. GET https://api.mercuryo.io/v1.6/widget/buy/rate +// Doc: github.com/mercuryoio/api-migration-docs, Widget_API_Mercuryo_v1.6.md +// (fetched 2026-09-10, no date on page). widget_id identifies the partner. +// v1.6 "responses now contain information about commission". Shape +// VERIFIED live 2026-09-14 (testdata/mercuryo_rate.json is a real +// response): every money field is a {crypto, fiat} pair keyed by +// currency code, e.g. "fee": {"BTC": "0.00013906", "EUR": "9.59"}, and +// fee = mercuryo_fee + network_fee. "partner_fee" is null for a widget +// without a partner markup. No country parameter: country_source="ip". + +type mercuryoAdapter struct{ widgetID string } + +func (m *mercuryoAdapter) Slug() string { return "mercuryo" } +func (m *mercuryoAdapter) Cohort() string { return "onramp" } +func (m *mercuryoAdapter) Enabled() bool { return m.widgetID != "" } + +var mercuryoNetwork = map[string]string{"bitcoin": "BITCOIN", "ethereum": "ETHEREUM", "base": "BASE", "arbitrum": "ARBITRUM"} + +// mercuryoMoney is a {"BTC": "0.001", "EUR": "9.59"} pair; only the +// fiat leg is used. Null (partner_fee) decodes to an empty map. +type mercuryoMoney map[string]json.Number + +func (m mercuryoMoney) eur() float64 { + v, _ := strconv.ParseFloat(string(m["EUR"]), 64) + return v +} + +type mercuryoResp struct { + Status int `json:"status"` + Data struct { + Amount json.Number `json:"amount"` // crypto out + Rate json.Number `json:"rate"` // fiat per unit + Fee mercuryoMoney `json:"fee"` // total fee = mercuryo_fee + network_fee + MercuryoFee mercuryoMoney `json:"mercuryo_fee"` // provider share + NetworkFee mercuryoMoney `json:"network_fee"` // chain fee + PartnerFee mercuryoMoney `json:"partner_fee"` // widget owner markup, null here + FiatAmount json.Number `json:"fiat_amount"` // total fiat + } `json:"data"` + Message string `json:"message"` +} + +func (m *mercuryoAdapter) Quote(ctx context.Context, req QuoteRequest) ([]NormalizedQuote, time.Duration, error) { + net, ok := mercuryoNetwork[req.Network] + if !ok { + return nil, 0, ErrNoQuote + } + // The widget rate endpoint has no payment-method parameter in the + // documented sample; the same quote is recorded for card only, and + // sepa is reported as no_quote so the cell stays honest. + if req.PaymentMethod != "card" { + return nil, 0, ErrNoQuote + } + q := url.Values{} + q.Set("from", "EUR") + q.Set("to", strings.ToUpper(req.Asset.Asset)) + q.Set("amount", trimFloat(req.Notional)) + q.Set("network", net) + q.Set("widget_id", m.widgetID) + u := baseFor("mercuryo", "https://api.mercuryo.io") + "/v1.6/widget/buy/rate?" + q.Encode() + res, err := doJSON(ctx, "GET", u, nil, nil) + if err != nil { + return nil, res.Latency, err + } + if res.Status != 200 { + return nil, res.Latency, statusErr("mercuryo", res) + } + var r mercuryoResp + if err := json.Unmarshal(res.Body, &r); err != nil { + return nil, res.Latency, err + } + out, _ := strconv.ParseFloat(string(r.Data.Amount), 64) + if out <= 0 { + return nil, res.Latency, ErrNoQuote + } + rate, _ := strconv.ParseFloat(string(r.Data.Rate), 64) + fiatIn, _ := strconv.ParseFloat(string(r.Data.FiatAmount), 64) + if fiatIn <= 0 { + fiatIn = req.Notional + } + // Split the fee the way the other adapters do. When the breakdown is + // absent, the total goes to the provider column. + feeProvider := r.Data.MercuryoFee.eur() + feeNetwork := r.Data.NetworkFee.eur() + if feeProvider == 0 && feeNetwork == 0 { + feeProvider = r.Data.Fee.eur() + } + return []NormalizedQuote{{ + Provider: "mercuryo", Cohort: "onramp", Via: "direct", Asset: req.Asset.Asset, Network: req.Network, + PaymentMethod: req.PaymentMethod, Notional: req.Notional, CountrySource: "ip", + FiatIn: fiatIn, CryptoOut: out, FeeProvider: feeProvider, FeeNetwork: feeNetwork, + FeePartner: r.Data.PartnerFee.eur(), ProviderMarketRate: rate, + RawJSONHash: hashBody(res.Body), + }}, res.Latency, nil +} + +func (m *mercuryoAdapter) CountrySource() string { return "ip" } diff --git a/harnesses/fiat-onramp-cost/cmd/script/metrics.go b/harnesses/fiat-onramp-cost/cmd/script/metrics.go new file mode 100644 index 000000000..08df14912 --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/metrics.go @@ -0,0 +1,141 @@ +package main + +import ( + "encoding/json" + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Label sets. Kept small and enumerable: every value comes from the persona +// or a fixed adapter table, never from a provider's free text, so +// cardinality is bounded by design (6 providers × 3 assets × 2 methods × +// 2 notionals × 2 references). +var quoteLabels = []string{"provider", "cohort", "via", "asset", "network", "payment_method", "notional", "fiat", "country", "region", "country_source"} +var premiumLabels = append(append([]string{}, quoteLabels...), "spot_ref") + +var ( + allInPremium *prometheus.GaugeVec + declaredFee *prometheus.GaugeVec + hiddenSpread *prometheus.GaugeVec + cryptoOut *prometheus.GaugeVec + quoteTTL *prometheus.GaugeVec + quoteLatency *prometheus.HistogramVec + quoteSuccess *prometheus.GaugeVec + quoteErrors *prometheus.CounterVec + quoteSamples *prometheus.CounterVec + limitsMinFiat *prometheus.GaugeVec + limitsMaxFiat *prometheus.GaugeVec + spotPrice *prometheus.GaugeVec + spotDivergence *prometheus.GaugeVec + lastCycleTS prometheus.Gauge +) + +func init() { + allInPremium = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "onramp_quote_all_in_premium_bps", + Help: "(fiat_in / crypto_out - spot) / spot * 1e4. Effective price paid per unit vs the labelled spot reference, in basis points. Spot is sampled in the same cycle as the quote.", + }, premiumLabels) + declaredFee = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "onramp_quote_declared_fee_bps", + Help: "(fee_provider + fee_partner + fee_network) / fiat_in * 1e4, every fee field the provider returns, in basis points.", + }, quoteLabels) + hiddenSpread = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "onramp_quote_hidden_spread_bps", + Help: "all_in_premium_bps - declared_fee_bps. The markup embedded in the provider's exchange rate that it does not call a fee.", + }, premiumLabels) + cryptoOut = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "onramp_quote_crypto_out", + Help: "Whole units of the asset the provider quotes for the persona's fiat_in.", + }, quoteLabels) + quoteTTL = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "onramp_quote_ttl_seconds", + Help: "Quote validity as returned by the provider. Absent when the provider returns none.", + }, quoteLabels) + quoteLatency = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "onramp_quote_latency_ms", + Help: "Round-trip time of the quote request, milliseconds.", + Buckets: []float64{50, 100, 200, 400, 800, 1500, 3000, 6000, 8000}, + }, []string{"provider"}) + quoteSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "onramp_quote_success", + Help: "1 when the cycle produced a usable quote (crypto_out > 0, TTL not expired at receipt) inside the 8 s timeout, else 0.", + }, quoteLabels) + quoteErrors = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "onramp_quote_errors_total", + Help: "Failed quote attempts by reason: timeout, http_4xx, http_5xx, parse, no_quote, skipped_no_key.", + }, []string{"provider", "reason"}) + quoteSamples = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "onramp_quote_samples_total", + Help: "Usable quotes recorded per cell. A counter, so increase() over the window counts quote events rather than Prometheus scrapes of a gauge.", + }, quoteLabels) + limitsMinFiat = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "onramp_limits_min_fiat", + Help: "Minimum fiat amount the provider accepts for this payment method, EUR. Refreshed hourly.", + }, []string{"provider", "payment_method", "asset"}) + limitsMaxFiat = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "onramp_limits_max_fiat", + Help: "Maximum fiat amount the provider accepts for this payment method, EUR. Refreshed hourly.", + }, []string{"provider", "payment_method", "asset"}) + spotPrice = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "spot_reference_price", + Help: "Reference mid used for the premium, fiat per whole unit, by source (kraken_eur or pyth). Published for auditability.", + }, []string{"asset", "fiat", "source"}) + spotDivergence = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "spot_reference_divergence_bps", + Help: "(kraken_eur - pyth) / pyth * 1e4 per asset. A reference outage or a stale feed shows here before it shows in the premium.", + }, []string{"asset", "fiat"}) + lastCycleTS = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "onramp_last_cycle_timestamp_seconds", + Help: "Unix time the last quote cycle finished.", + }) + for _, c := range []prometheus.Collector{allInPremium, declaredFee, hiddenSpread, cryptoOut, quoteTTL, quoteLatency, quoteSuccess, quoteErrors, quoteSamples, limitsMinFiat, limitsMaxFiat, spotPrice, spotDivergence, lastCycleTS} { + prometheus.MustRegister(c) + } +} + +func labelsFor(q NormalizedQuote) prometheus.Labels { + return prometheus.Labels{ + "provider": q.Provider, "cohort": q.Cohort, "via": q.Via, + "asset": q.Asset, "network": q.Network, "payment_method": q.PaymentMethod, + "notional": trimFloat(q.Notional), "fiat": PersonaFiat, "country": PersonaCountry, + "region": PersonaRegion, "country_source": q.CountrySource, + } +} + +func withRef(l prometheus.Labels, ref string) prometheus.Labels { + out := prometheus.Labels{} + for k, v := range l { + out[k] = v + } + out["spot_ref"] = ref + return out +} + +// DeleteQuoteSeries clears a provider × dims cell so a provider that stops +// answering does not keep serving its last value to the scraper. Same +// rule as bench 001's gauge purge. +func DeleteQuoteSeries(l prometheus.Labels) { + declaredFee.Delete(l) + cryptoOut.Delete(l) + quoteTTL.Delete(l) + for _, ref := range []string{"kraken_eur", "pyth"} { + allInPremium.Delete(withRef(l, ref)) + hiddenSpread.Delete(withRef(l, ref)) + } +} + +func StartMetricsServer(addr string, cfg *Config) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte("ok")) + }) + mux.HandleFunc("/config", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(cfg.Redacted()) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/fiat-onramp-cost/cmd/script/metrics_test.go b/harnesses/fiat-onramp-cost/cmd/script/metrics_test.go new file mode 100644 index 000000000..ee2d297a2 --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/metrics_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" +) + +func gaugeValue(t *testing.T, g prometheus.Gauge) float64 { + t.Helper() + var m dto.Metric + if err := g.Write(&m); err != nil { + t.Fatal(err) + } + return m.GetGauge().GetValue() +} + +func TestLabelsForAndDelete(t *testing.T) { + q := NormalizedQuote{Provider: "ramp", Cohort: "onramp", Via: "direct", Asset: "usdc", Network: "base", PaymentMethod: "sepa", Notional: 500, CountrySource: "param"} + l := labelsFor(q) + if l["notional"] != "500" || l["fiat"] != "EUR" || l["country"] != "FR" || l["region"] != "eu-west" { + t.Errorf("%v", l) + } + spot := SpotSnapshot{Kraken: map[string]float64{"usdc": 0.92}, Pyth: map[string]float64{"usdc": 0.921}} + q.FiatIn, q.CryptoOut, q.FeeProvider = 500, 530, 5 + emitQuote(q, spot) + if v := gaugeValue(t, allInPremium.With(withRef(l, "kraken_eur"))); v == 0 { + t.Error("premium not emitted") + } + DeleteQuoteSeries(l) + if v := gaugeValue(t, allInPremium.With(withRef(l, "kraken_eur"))); v != 0 { + t.Errorf("not purged: %v", v) + } +} diff --git a/harnesses/fiat-onramp-cost/cmd/script/moonpay.go b/harnesses/fiat-onramp-cost/cmd/script/moonpay.go new file mode 100644 index 000000000..b527979bb --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/moonpay.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "encoding/json" + "net/url" + "time" +) + +// MoonPay. GET https://api.moonpay.com/v3/currencies/{code}/buy_quote +// Doc: https://dev.moonpay.com/reference/getbuyquote (fetched 2026-09-10, +// no date on page). Publishable key as `apiKey` query param. There is no +// country parameter on this endpoint; the response carries +// notAllowedCountries per currency. Pricing is therefore whatever the +// vantage receives, and the sample is labelled country_source="ip". +// +// areFeesIncluded=true makes baseCurrencyAmount the total the user pays, +// which is the persona's notional; totalAmount is used as fiat_in. + +type moonpayAdapter struct{ pk string } + +func (m *moonpayAdapter) Slug() string { return "moonpay" } +func (m *moonpayAdapter) Cohort() string { return "onramp" } +func (m *moonpayAdapter) Enabled() bool { return m.pk != "" } + +// Currency codes per persona asset and network. usdc_base and usdc_arbitrum +// are MoonPay's documented pattern for network-qualified stablecoins +// (UNVERIFIED for this exact pair, see CHECKLIST.md). +var moonpayCurrency = map[string]string{ + "btc/bitcoin": "btc", "eth/ethereum": "eth", + "usdc/base": "usdc_base", "usdc/arbitrum": "usdc_arbitrum", +} + +// Payment method enum values as listed in the widget design guide +// (dev.moonpay.com/widget/on-ramp/design-guide). sepa_bank_transfer is +// documented; credit_debit_card is the documented card value. +var moonpayMethod = map[string]string{"card": "credit_debit_card", "sepa": "sepa_bank_transfer"} + +type moonpayResp struct { + QuoteCurrencyAmount float64 `json:"quoteCurrencyAmount"` + QuoteCurrencyPrice float64 `json:"quoteCurrencyPrice"` + BaseCurrencyAmount float64 `json:"baseCurrencyAmount"` + FeeAmount float64 `json:"feeAmount"` + ExtraFeeAmount float64 `json:"extraFeeAmount"` + NetworkFeeAmount float64 `json:"networkFeeAmount"` + TotalAmount float64 `json:"totalAmount"` + ExpiresIn float64 `json:"expiresIn"` + Message string `json:"message"` + Type string `json:"type"` +} + +func (m *moonpayAdapter) Quote(ctx context.Context, req QuoteRequest) ([]NormalizedQuote, time.Duration, error) { + code, ok := moonpayCurrency[req.Asset.Asset+"/"+req.Network] + if !ok { + return nil, 0, ErrNoQuote + } + method, ok := moonpayMethod[req.PaymentMethod] + if !ok { + return nil, 0, ErrNoQuote + } + q := url.Values{} + q.Set("apiKey", m.pk) + q.Set("baseCurrencyCode", "eur") + q.Set("baseCurrencyAmount", trimFloat(req.Notional)) + q.Set("paymentMethod", method) + q.Set("areFeesIncluded", "true") + u := baseFor("moonpay", "https://api.moonpay.com") + "/v3/currencies/" + code + "/buy_quote?" + q.Encode() + res, err := doJSON(ctx, "GET", u, nil, nil) + if err != nil { + return nil, res.Latency, err + } + if res.Status != 200 { + return nil, res.Latency, statusErr("moonpay", res) + } + var r moonpayResp + if err := json.Unmarshal(res.Body, &r); err != nil { + return nil, res.Latency, err + } + if r.QuoteCurrencyAmount <= 0 { + return nil, res.Latency, ErrNoQuote + } + fiatIn := r.TotalAmount + if fiatIn <= 0 { + fiatIn = r.BaseCurrencyAmount + } + return []NormalizedQuote{{ + Provider: "moonpay", Cohort: "onramp", Via: "direct", Asset: req.Asset.Asset, Network: req.Network, + PaymentMethod: req.PaymentMethod, Notional: req.Notional, CountrySource: "ip", + FiatIn: fiatIn, CryptoOut: r.QuoteCurrencyAmount, + FeeProvider: r.FeeAmount, FeeNetwork: r.NetworkFeeAmount, FeePartner: r.ExtraFeeAmount, + ProviderMarketRate: r.QuoteCurrencyPrice, TTLSeconds: r.ExpiresIn, + RawJSONHash: hashBody(res.Body), + }}, res.Latency, nil +} + +func truncate(b []byte) string { + if len(b) > 160 { + return string(b[:160]) + "…" + } + return string(b) +} + +func (m *moonpayAdapter) CountrySource() string { return "ip" } diff --git a/harnesses/fiat-onramp-cost/cmd/script/normalize.go b/harnesses/fiat-onramp-cost/cmd/script/normalize.go new file mode 100644 index 000000000..c0777fc4d --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/normalize.go @@ -0,0 +1,106 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "math" +) + +// NormalizedQuote is the one shape every adapter produces. Amounts are in +// the persona's units: fiat in EUR, crypto in whole units of the asset +// (BTC, not satoshi; ETH, not wei; USDC, not micro-USDC). Adapters that +// receive base units convert here, never downstream. +type NormalizedQuote struct { + Provider string // product slug, matches /products/ + Via string // "" for a direct quote, "onramper" or "meld" for an aggregator sub-quote + Cohort string // "onramp" or "aggregator" + Asset string // btc, usdc, eth + Network string // bitcoin, base, arbitrum, ethereum + PaymentMethod string // card, sepa + Notional float64 // requested fiat amount, EUR + CountrySource string // "param" when the API took country=FR, "ip" when it geolocated the vantage + + FiatIn float64 // total fiat the user pays, fees included + CryptoOut float64 // whole units delivered + FeeProvider float64 // EUR + FeeNetwork float64 // EUR + FeePartner float64 // EUR + ProviderMarketRate float64 // EUR per unit as the provider reports it, 0 when absent + TTLSeconds float64 // 0 when the provider returns none + MinFiat float64 // 0 when absent + MaxFiat float64 // 0 when absent + RawJSONHash string // sha256 of the response body, for reproduction +} + +// ErrNoQuote is what an adapter returns when the provider answered but the +// answer carries no usable quote for the persona (unsupported method, +// notional below minimum, asset not listed). Counted as a miss, never as +// a zero. +var ErrNoQuote = errors.New("no usable quote") + +// Basis-point maths. Kept in one file so a reviewer can check the three +// formulas against the spec in one screen. + +// AllInPremiumBps is the effective price the user paid per unit, relative +// to a spot reference, in basis points. Worked example from the issue: +// 500 EUR buys 0.0048 BTC at spot 100 000 → effective price 104 166.67 → +// (104 166.67 − 100 000) / 100 000 × 1e4 = 416.67 bps. +func AllInPremiumBps(fiatIn, cryptoOut, spot float64) (float64, error) { + if fiatIn <= 0 || cryptoOut <= 0 || spot <= 0 { + return 0, errors.New("all-in premium needs positive fiat, crypto and spot") + } + effective := fiatIn / cryptoOut + return (effective - spot) / spot * 1e4, nil +} + +// DeclaredFeeBps is what the provider says it charges, all fee fields +// summed, as a share of the fiat the user pays. +func DeclaredFeeBps(q NormalizedQuote) (float64, error) { + if q.FiatIn <= 0 { + return 0, errors.New("declared fee needs positive fiat_in") + } + return (q.FeeProvider + q.FeeNetwork + q.FeePartner) / q.FiatIn * 1e4, nil +} + +// HiddenSpreadBps is the part of the premium the provider does not call a +// fee: the markup embedded in its exchange rate. Can be negative when the +// provider's rate beats the reference mid, which happens on illiquid +// moments and is reported as is. +func HiddenSpreadBps(allIn, declared float64) float64 { + return allIn - declared +} + +// FromBaseUnits converts an integer-string amount in the asset's smallest +// unit into whole units. Ramp returns wei for ETH and satoshi for BTC. +func FromBaseUnits(raw float64, decimals int) float64 { + return raw / math.Pow10(decimals) +} + +// AssetDecimals is the on-chain precision per persona asset. +func AssetDecimals(asset string) int { + switch asset { + case "btc": + return 8 + case "eth": + return 18 + case "usdc": + return 6 + } + return 0 +} + +func hashBody(b []byte) string { + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +// trimFloat renders a notional as a label: 100 → "100", 500 → "500", +// matching the string amount labels the bridge-fee bench uses. +func trimFloat(f float64) string { + if f == math.Trunc(f) { + return fmt.Sprintf("%d", int64(f)) + } + return fmt.Sprintf("%g", f) +} diff --git a/harnesses/fiat-onramp-cost/cmd/script/normalize_test.go b/harnesses/fiat-onramp-cost/cmd/script/normalize_test.go new file mode 100644 index 000000000..798839d08 --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/normalize_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "math" + "testing" +) + +func near(a, b, tol float64) bool { return math.Abs(a-b) <= tol } + +// The worked example from the issue text, to the cent. +func TestAllInPremiumWorkedExample(t *testing.T) { + got, err := AllInPremiumBps(500, 0.0048, 100000) + if err != nil { + t.Fatal(err) + } + if !near(got, 416.67, 0.01) { + t.Fatalf("got %.4f bps, want 416.67", got) + } +} + +func TestAllInPremiumRejectsZeroes(t *testing.T) { + for _, c := range [][3]float64{{0, 1, 1}, {1, 0, 1}, {1, 1, 0}, {-1, 1, 1}} { + if _, err := AllInPremiumBps(c[0], c[1], c[2]); err == nil { + t.Errorf("expected error for %v", c) + } + } +} + +// A provider quoting exactly at spot has 0 bps premium; one quoting +// below spot goes negative and is reported as such, not clamped. +func TestAllInPremiumSignAndZero(t *testing.T) { + z, _ := AllInPremiumBps(100, 0.001, 100000) + if !near(z, 0, 1e-9) { + t.Errorf("at spot should be 0, got %v", z) + } + n, _ := AllInPremiumBps(100, 0.00101, 100000) + if n >= 0 { + t.Errorf("below spot should be negative, got %v", n) + } +} + +func TestDeclaredAndHidden(t *testing.T) { + q := NormalizedQuote{FiatIn: 500, FeeProvider: 5, FeeNetwork: 1.5, FeePartner: 0} + d, err := DeclaredFeeBps(q) + if err != nil { + t.Fatal(err) + } + // 6.5 / 500 = 1.3 % = 130 bps + if !near(d, 130, 1e-9) { + t.Fatalf("declared = %v, want 130", d) + } + // all-in 416.67 with 130 declared leaves 286.67 of spread the provider + // never named a fee. + if h := HiddenSpreadBps(416.67, d); !near(h, 286.67, 1e-6) { + t.Fatalf("hidden = %v, want 286.67", h) + } +} + +// Ramp returns crypto in base units. 0.0048 BTC is 480000 satoshi; +// 1.5 ETH is 1.5e18 wei; 250 USDC is 250e6. +func TestFromBaseUnits(t *testing.T) { + cases := []struct { + asset string + raw float64 + want float64 + }{ + {"btc", 480000, 0.0048}, + {"eth", 1.5e18, 1.5}, + {"usdc", 250e6, 250}, + } + for _, c := range cases { + got := FromBaseUnits(c.raw, AssetDecimals(c.asset)) + if !near(got, c.want, 1e-12) { + t.Errorf("%s: %v → %v, want %v", c.asset, c.raw, got, c.want) + } + } +} + +// End to end on the Ramp shape: satoshi in, bps out, must land on the +// worked example. +func TestRampShapeEndToEnd(t *testing.T) { + cryptoOut := FromBaseUnits(480000, AssetDecimals("btc")) + got, _ := AllInPremiumBps(500, cryptoOut, 100000) + if !near(got, 416.67, 0.01) { + t.Fatalf("got %.4f", got) + } +} diff --git a/harnesses/fiat-onramp-cost/cmd/script/onramper.go b/harnesses/fiat-onramp-cost/cmd/script/onramper.go new file mode 100644 index 000000000..b4b3e51fc --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/onramper.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "encoding/json" + "net/url" + "strings" + "time" +) + +// Onramper, aggregator. GET https://api.onramper.com/quotes/{fiat}/{crypto} +// Doc: https://docs.onramper.com/reference/get_quotes-fiat-crypto, page +// last updated 2026-07-28. `Authorization: ` header. country=fr is +// passed explicitly ("auto-determined by IP if absent"): country_source= +// "param". One call returns one object per member ramp; each becomes a +// sample with provider=, via="onramper", cohort="aggregator". +// The spec never ranks these against the direct cohort. + +type onramperAdapter struct{ key string } + +func (o *onramperAdapter) Slug() string { return "onramper" } +func (o *onramperAdapter) Cohort() string { return "aggregator" } +func (o *onramperAdapter) Enabled() bool { return o.key != "" } + +// Crypto ids: Onramper qualifies stablecoins by network with an underscore +// (UNVERIFIED exact ids, see CHECKLIST.md). btc and eth are unqualified. +var onramperCrypto = map[string]string{ + "btc/bitcoin": "btc", "eth/ethereum": "eth", + "usdc/base": "usdc_base", "usdc/arbitrum": "usdc_arbitrum", +} + +// paymentMethod values: "creditcard" is the documented example; the SEPA +// id is UNVERIFIED and taken from Onramper's payment-types listing +// convention. +var onramperMethod = map[string]string{"card": "creditcard", "sepa": "sepabanktransfer"} + +type onramperQuote struct { + Ramp string `json:"ramp"` + PaymentMethod string `json:"paymentMethod"` + Rate float64 `json:"rate"` + Payout float64 `json:"payout"` + NetworkFee float64 `json:"networkFee"` + TransactionFee float64 `json:"transactionFee"` + QuoteID string `json:"quoteId"` + Errors []struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"errors"` +} + +// Member ramp names as Onramper reports them, mapped to OCB product slugs +// so the same provider is one product across cohorts. +var onramperRampSlug = map[string]string{ + "moonpay": "moonpay", "transak": "transak", "ramp": "ramp", "mercuryo": "mercuryo", + "banxa": "banxa", "alchemypay": "alchemypay", "unlimit": "unlimit", "guardarian": "guardarian", + "topper": "topper", "coinbase": "coinbase", "paybis": "paybis", "sardine": "sardine", + "simplex": "simplex", "wert": "wert", "meld": "meld", +} + +func (o *onramperAdapter) Quote(ctx context.Context, req QuoteRequest) ([]NormalizedQuote, time.Duration, error) { + crypto, ok := onramperCrypto[req.Asset.Asset+"/"+req.Network] + if !ok { + return nil, 0, ErrNoQuote + } + method, ok := onramperMethod[req.PaymentMethod] + if !ok { + return nil, 0, ErrNoQuote + } + q := url.Values{} + q.Set("amount", trimFloat(req.Notional)) + q.Set("paymentMethod", method) + q.Set("country", strings.ToLower(PersonaCountry)) + q.Set("type", "buy") + u := baseFor("onramper", "https://api.onramper.com") + "/quotes/eur/" + crypto + "?" + q.Encode() + res, err := doJSON(ctx, "GET", u, map[string]string{"Authorization": o.key}, nil) + if err != nil { + return nil, res.Latency, err + } + if res.Status != 200 { + return nil, res.Latency, statusErr("onramper", res) + } + var arr []onramperQuote + if err := json.Unmarshal(res.Body, &arr); err != nil { + return nil, res.Latency, err + } + h := hashBody(res.Body) + var out []NormalizedQuote + for _, m := range arr { + if m.Payout <= 0 || len(m.Errors) > 0 { + continue + } + slug := onramperRampSlug[strings.ToLower(m.Ramp)] + if slug == "" { + slug = strings.ToLower(m.Ramp) + } + out = append(out, NormalizedQuote{ + Provider: slug, Via: "onramper", Cohort: "aggregator", + Asset: req.Asset.Asset, Network: req.Network, PaymentMethod: req.PaymentMethod, + Notional: req.Notional, CountrySource: "param", + FiatIn: req.Notional, CryptoOut: m.Payout, + FeeProvider: m.TransactionFee, FeeNetwork: m.NetworkFee, + ProviderMarketRate: m.Rate, RawJSONHash: h, + }) + } + if len(out) == 0 { + return nil, res.Latency, ErrNoQuote + } + return out, res.Latency, nil +} + +func (o *onramperAdapter) CountrySource() string { return "param" } diff --git a/harnesses/fiat-onramp-cost/cmd/script/ramp.go b/harnesses/fiat-onramp-cost/cmd/script/ramp.go new file mode 100644 index 000000000..50ab9ad51 --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/ramp.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "encoding/json" + "strconv" + "time" +) + +// Ramp Network. POST https://api.ramp.network/api/host-api/v3/onramp/quote/all +// Doc: https://docs.rampnetwork.com/rest-api-v3-reference (fetched +// 2026-09-10, no date on page). hostApiKey as query param, obtained from +// partner@ramp.network. One request returns a quote per payment method, so +// a cell maps to one key of the response; crypto amounts come back in +// base units (wei, satoshi, micro-USDC) and are converted with the asset's +// decimals from the same response. userCountryCode=FR is passed: +// country_source="param". +// +// Rate limits are documented at docs.rampnetwork.com/rate-limiting: 100 +// requests / minute and 1000 / 15 minutes per source IP, a one-hour block +// on exceeding the latter. One cycle issues at most 6 Ramp calls (one per +// asset × notional; both methods come from one call). + +type rampAdapter struct{ hostKey string } + +func (r *rampAdapter) Slug() string { return "ramp" } +func (r *rampAdapter) Cohort() string { return "onramp" } +func (r *rampAdapter) Enabled() bool { return r.hostKey != "" } + +// Asset symbols follow Ramp's CHAIN_TOKEN convention (UNVERIFIED for the +// exact strings, see CHECKLIST.md). +var rampSymbol = map[string]string{ + "btc/bitcoin": "BTC_BTC", "eth/ethereum": "ETH_ETH", + "usdc/base": "BASE_USDC", "usdc/arbitrum": "ARBITRUM_USDC", +} + +// Payment method keys as documented in the PaymentMethodName enum. +var rampMethod = map[string]string{"card": "CARD_PAYMENT", "sepa": "MANUAL_BANK_TRANSFER"} + +type rampMethodQuote struct { + CryptoAmount string `json:"cryptoAmount"` // base units, decimal string + FiatValue float64 `json:"fiatValue"` + AppliedFee float64 `json:"appliedFee"` + BaseRampFee float64 `json:"baseRampFee"` +} + +type rampResp struct { + Asset struct { + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + Price map[string]float64 `json:"price"` + } `json:"asset"` + Quotes map[string]rampMethodQuote `json:"-"` +} + +// parseRamp reads the per-method keys off the top level of the object, +// alongside the `asset` block, without hardcoding every method name. +func parseRamp(body []byte) (rampResp, error) { + var top map[string]json.RawMessage + if err := json.Unmarshal(body, &top); err != nil { + return rampResp{}, err + } + var r rampResp + r.Quotes = map[string]rampMethodQuote{} + if a, ok := top["asset"]; ok { + if err := json.Unmarshal(a, &r.Asset); err != nil { + return r, err + } + } + for k, v := range top { + if k == "asset" { + continue + } + var q rampMethodQuote + if json.Unmarshal(v, &q) == nil && q.CryptoAmount != "" { + r.Quotes[k] = q + } + } + return r, nil +} + +func (r *rampAdapter) Quote(ctx context.Context, req QuoteRequest) ([]NormalizedQuote, time.Duration, error) { + sym, ok := rampSymbol[req.Asset.Asset+"/"+req.Network] + if !ok { + return nil, 0, ErrNoQuote + } + mkey, ok := rampMethod[req.PaymentMethod] + if !ok { + return nil, 0, ErrNoQuote + } + body, _ := json.Marshal(map[string]any{ + "cryptoAssetSymbol": sym, "fiatCurrency": "EUR", "fiatValue": req.Notional, + "userCountryCode": PersonaCountry, + }) + u := baseFor("ramp", "https://api.ramp.network") + "/api/host-api/v3/onramp/quote/all?hostApiKey=" + r.hostKey + res, err := doJSON(ctx, "POST", u, nil, body) + if err != nil { + return nil, res.Latency, err + } + if res.Status != 200 && res.Status != 201 { + return nil, res.Latency, statusErr("ramp", res) + } + parsed, err := parseRamp(res.Body) + if err != nil { + return nil, res.Latency, err + } + mq, ok := parsed.Quotes[mkey] + if !ok { + return nil, res.Latency, ErrNoQuote + } + raw, err := strconv.ParseFloat(mq.CryptoAmount, 64) + if err != nil || raw <= 0 { + return nil, res.Latency, ErrNoQuote + } + dec := parsed.Asset.Decimals + if dec == 0 { + dec = AssetDecimals(req.Asset.Asset) + } + return []NormalizedQuote{{ + Provider: "ramp", Cohort: "onramp", Via: "direct", Asset: req.Asset.Asset, Network: req.Network, + PaymentMethod: req.PaymentMethod, Notional: req.Notional, CountrySource: "param", + FiatIn: mq.FiatValue, CryptoOut: FromBaseUnits(raw, dec), + FeeProvider: mq.AppliedFee, ProviderMarketRate: parsed.Asset.Price["EUR"], + RawJSONHash: hashBody(res.Body), + }}, res.Latency, nil +} + +func (r *rampAdapter) CountrySource() string { return "param" } diff --git a/harnesses/fiat-onramp-cost/cmd/script/run.go b/harnesses/fiat-onramp-cost/cmd/script/run.go new file mode 100644 index 000000000..23c375ed0 --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/run.go @@ -0,0 +1,247 @@ +package main + +import ( + "context" + "fmt" + "math/rand" + "os" + "sort" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +// cellKey identifies one provider × dims series so a cell that stops +// answering is purged instead of freezing at its last value. +type cellKey string + +func keyOf(l prometheus.Labels) cellKey { + keys := make([]string, 0, len(l)) + for k := range l { + keys = append(keys, k) + } + sort.Strings(keys) + s := "" + for _, k := range keys { + s += k + "=" + l[k] + ";" + } + return cellKey(s) +} + +type cycleResult struct { + adapter Adapter + req QuoteRequest + quotes []NormalizedQuote + latency time.Duration + err error +} + +// grid expands the persona into one QuoteRequest per asset × notional × +// payment method, on each asset's primary network. +func grid(cfg *Config) []QuoteRequest { + var out []QuoteRequest + for _, a := range cfg.Assets { + for _, n := range cfg.Notionals { + for _, m := range cfg.PaymentMethods { + out = append(out, QuoteRequest{Asset: a, Network: a.Network, PaymentMethod: m, Notional: n}) + } + } + } + return out +} + +// quoteCell asks one adapter for one cell, falling back to the asset's +// secondary network when the primary is not listed. A fallback sample keeps +// its real network label so the reader sees what was actually quoted. +func quoteCell(ctx context.Context, a Adapter, req QuoteRequest) cycleResult { + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + qs, lat, err := a.Quote(cctx, req) + if err == ErrNoQuote && req.Asset.Fallback != "" && req.Asset.Fallback != req.Network { + fb := req + fb.Network = req.Asset.Fallback + qs2, lat2, err2 := a.Quote(cctx, fb) + if err2 == nil { + return cycleResult{adapter: a, req: fb, quotes: qs2, latency: lat + lat2} + } + lat += lat2 + } + return cycleResult{adapter: a, req: req, quotes: qs, latency: lat, err: err} +} + +// runCycle fetches spot, quotes every enabled adapter in parallel (one +// semaphore per provider so no provider sees more than cfg.ProviderSemaphore +// in flight), emits metrics and returns the set of cells seen. +func runCycle(ctx context.Context, cfg *Config, adapters []Adapter, prev map[cellKey]prometheus.Labels) map[cellKey]prometheus.Labels { + start := time.Now() + spot := fetchSpot(ctx) + cells := grid(cfg) + + results := make(chan cycleResult, len(cells)*len(adapters)) + var wg sync.WaitGroup + for _, a := range adapters { + if !a.Enabled() { + continue + } + a := a + sem := make(chan struct{}, cfg.ProviderSemaphore) + for _, req := range cells { + req := req + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + results <- quoteCell(ctx, a, req) + }() + } + } + wg.Wait() + close(results) + + seen := map[cellKey]prometheus.Labels{} + ok, failed := 0, 0 + for r := range results { + slug := r.adapter.Slug() + if r.latency > 0 { + quoteLatency.WithLabelValues(slug).Observe(float64(r.latency.Milliseconds())) + } + if r.err != nil { + failed++ + reason := classifyErr(r.err) + quoteErrors.WithLabelValues(slug, reason).Inc() + // A direct provider's failed cell is a success=0 sample on its + // own series. An aggregator's failure cannot be attributed to a + // member, so it is recorded on the aggregator slug. + l := labelsFor(NormalizedQuote{ + Provider: slug, Cohort: r.adapter.Cohort(), Via: viaOf(r.adapter), + Asset: r.req.Asset.Asset, Network: r.req.Network, PaymentMethod: r.req.PaymentMethod, + Notional: r.req.Notional, CountrySource: r.adapter.CountrySource(), + }) + quoteSuccess.With(l).Set(0) + seen[keyOf(l)] = l + if reason != "no_quote" { + fmt.Printf("[ERR] %s %s/%s %s %.0f: %v\n", slug, r.req.Asset.Asset, r.req.Network, r.req.PaymentMethod, r.req.Notional, r.err) + } + continue + } + for _, q := range r.quotes { + ok++ + emitQuote(q, spot) + l := labelsFor(q) + seen[keyOf(l)] = l + } + // An aggregator that returned at least one member quote is itself a + // success on its own slug, so the spec can read success for direct + // and aggregator rows with the same selector shape. + if r.adapter.Cohort() == "aggregator" && len(r.quotes) > 0 { + l := labelsFor(NormalizedQuote{ + Provider: slug, Cohort: "aggregator", Via: slug, + Asset: r.req.Asset.Asset, Network: r.req.Network, PaymentMethod: r.req.PaymentMethod, + Notional: r.req.Notional, CountrySource: r.adapter.CountrySource(), + }) + quoteSuccess.With(l).Set(1) + seen[keyOf(l)] = l + } + } + // Purge cells that answered last cycle and are absent now (a member ramp + // that dropped out of an aggregator's list, or a direct provider that + // vanished without an error path). + for k, l := range prev { + if _, still := seen[k]; !still { + DeleteQuoteSeries(l) + quoteSuccess.With(l).Set(0) + seen[k] = l + } + } + lastCycleTS.Set(float64(time.Now().Unix())) + fmt.Printf("[CYCLE] %s: %d quotes, %d failed cells, spot kraken=%d pyth=%d, %.1fs\n", + start.UTC().Format(time.RFC3339), ok, failed, len(spot.Kraken), len(spot.Pyth), time.Since(start).Seconds()) + return seen +} + +func viaOf(a Adapter) string { + if a.Cohort() == "aggregator" { + return a.Slug() + } + return "direct" +} + +// emitQuote writes every gauge for one normalized quote. The premium is +// emitted once per spot reference that answered this cycle, so the two +// series are never mixed and a missing reference simply leaves a gap. +func emitQuote(q NormalizedQuote, spot SpotSnapshot) { + l := labelsFor(q) + declared, derr := DeclaredFeeBps(q) + if derr != nil { + quoteErrors.WithLabelValues(q.Provider, "parse").Inc() + return + } + declaredFee.With(l).Set(declared) + cryptoOut.With(l).Set(q.CryptoOut) + quoteTTL.With(l).Set(q.TTLSeconds) + quoteSuccess.With(l).Set(1) + quoteSamples.With(l).Inc() + if q.MinFiat > 0 { + limitsMinFiat.WithLabelValues(q.Provider, q.PaymentMethod, q.Asset).Set(q.MinFiat) + } + if q.MaxFiat > 0 { + limitsMaxFiat.WithLabelValues(q.Provider, q.PaymentMethod, q.Asset).Set(q.MaxFiat) + } + for ref, m := range map[string]map[string]float64{"kraken_eur": spot.Kraken, "pyth": spot.Pyth} { + s, ok := m[q.Asset] + if !ok || s <= 0 { + allInPremium.Delete(withRef(l, ref)) + hiddenSpread.Delete(withRef(l, ref)) + continue + } + allIn, aerr := AllInPremiumBps(q.FiatIn, q.CryptoOut, s) + if aerr != nil { + allInPremium.Delete(withRef(l, ref)) + hiddenSpread.Delete(withRef(l, ref)) + continue + } + allInPremium.With(withRef(l, ref)).Set(allIn) + hiddenSpread.With(withRef(l, ref)).Set(HiddenSpreadBps(allIn, declared)) + } +} + +func runMain() { + cfg := loadConfig() + cfg.Print() + adapters := buildAdapters(cfg) + enabled := 0 + for _, a := range adapters { + if a.Enabled() { + enabled++ + fmt.Printf("[INIT] %s enabled (%s)\n", a.Slug(), a.Cohort()) + } else { + fmt.Printf("[INIT] %s skipped: no credential\n", a.Slug()) + } + } + if enabled == 0 { + fmt.Println("[INIT] no provider enabled; serving spot references only") + } + + go func() { + if err := StartMetricsServer(cfg.ListenAddr, cfg); err != nil { + fmt.Printf("[FATAL] metrics server: %v\n", err) + os.Exit(1) + } + }() + + ctx := context.Background() + prev := map[cellKey]prometheus.Labels{} + once := os.Getenv("ONCE") == "1" + for { + prev = runCycle(ctx, cfg, adapters, prev) + if once { + return + } + // ±10 % jitter so six providers never see a fixed-period pattern + // from one IP. + jitter := time.Duration(rand.Int63n(int64(cfg.Cycle())/5)) - cfg.Cycle()/10 + time.Sleep(cfg.Cycle() + jitter) + } +} diff --git a/harnesses/fiat-onramp-cost/cmd/script/spot.go b/harnesses/fiat-onramp-cost/cmd/script/spot.go new file mode 100644 index 000000000..a165ff0ff --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/spot.go @@ -0,0 +1,206 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "sync" + "time" +) + +// Spot references. Two, both keyless, both labelled on every sample. +// +// kraken_eur is the headline for EUR-denominated quotes: the persona pays +// in EUR, Kraken is the deepest EUR spot venue, and its XBT/EUR mid carries +// a 0.02 bps spread, so the reference is not itself a source of error at +// the 10 to 300 bps scale the bench measures. pyth is the cross-check, +// reusing what the USDY NAV-basis bench (078) already consumes. Pyth has no +// BTC/EUR feed, so its EUR price is BTC/USD divided by EUR/USD, one FX leg +// the Kraken mid does not have. That is why it is not the headline. +// +// Both are fetched in the same cycle as the quotes; the sample timestamp +// is recorded so a reader can check the ±2 s co-timing claim. + +type SpotSnapshot struct { + At time.Time + Kraken map[string]float64 // asset → EUR mid + Pyth map[string]float64 // asset → EUR (USD price / EURUSD) +} + +// Kraken pair names as returned in the Ticker result keys. +var krakenPairs = map[string]string{"btc": "XXBTZEUR", "eth": "XETHZEUR", "usdc": "USDCEUR"} + +// Pyth Hermes feed ids, verified on hermes.pyth.network/v2/price_feeds on +// 2026-09-10. Crypto.BTC/USD, Crypto.ETH/USD, Crypto.USDC/USD, FX.EUR/USD. +var pythFeeds = map[string]string{ + "btc": "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43", + "eth": "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", + "usdc": "eaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a", + "eurusd": "a995d00bb36a63cef7fd2c287dc105fc8f3d93779f062f09551b0af3e81ec30b", +} + +func krakenTickerURL() string { + return baseFor("kraken", "https://api.kraken.com") + "/0/public/Ticker?pair=XBTEUR,ETHEUR,USDCEUR" +} +func pythHermesURL() string { + return baseFor("pyth", "https://hermes.pyth.network") + "/v2/updates/price/latest" +} + +func fetchKraken(ctx context.Context) (map[string]float64, error) { + res, err := doJSON(ctx, "GET", krakenTickerURL(), nil, nil) + if err != nil { + return nil, err + } + if res.Status != 200 { + return nil, fmt.Errorf("kraken status %d", res.Status) + } + return parseKraken(res.Body) +} + +func parseKraken(body []byte) (map[string]float64, error) { + var env struct { + Error []string `json:"error"` + Result map[string]struct { + A []string `json:"a"` + B []string `json:"b"` + } `json:"result"` + } + if err := json.Unmarshal(body, &env); err != nil { + return nil, err + } + if len(env.Error) > 0 { + return nil, fmt.Errorf("kraken: %v", env.Error) + } + out := map[string]float64{} + for asset, pair := range krakenPairs { + t, ok := env.Result[pair] + if !ok || len(t.A) == 0 || len(t.B) == 0 { + continue + } + ask, e1 := strconv.ParseFloat(t.A[0], 64) + bid, e2 := strconv.ParseFloat(t.B[0], 64) + if e1 != nil || e2 != nil || ask <= 0 || bid <= 0 { + continue + } + out[asset] = (ask + bid) / 2 + } + if len(out) == 0 { + return nil, errors.New("kraken: no usable pair") + } + return out, nil +} + +func fetchPyth(ctx context.Context) (map[string]float64, error) { + url := pythHermesURL() + "?parsed=true" + for _, id := range pythFeeds { + url += "&ids[]=" + id + } + // Hermes requires a key since the Pyth Core upgrade of 2026-08-26 + // (docs.pyth.network, "Hermes now requires an API Key"). Without one the + // call returns 401 and the pyth reference is simply absent this cycle; + // kraken_eur, the headline, does not depend on it. + var hdr map[string]string + if k := env("PYTH_API_KEY", ""); k != "" { + hdr = map[string]string{"Authorization": "Bearer " + k} + } + res, err := doJSON(ctx, "GET", url, hdr, nil) + if err != nil { + return nil, err + } + if res.Status == 401 { + return nil, errors.New("pyth: 401, set PYTH_API_KEY (required since 2026-08-26)") + } + if res.Status != 200 { + return nil, fmt.Errorf("pyth status %d", res.Status) + } + return parsePyth(res.Body) +} + +func parsePyth(body []byte) (map[string]float64, error) { + var env struct { + Parsed []struct { + ID string `json:"id"` + Price struct { + Price string `json:"price"` + Expo int `json:"expo"` + } `json:"price"` + } `json:"parsed"` + } + if err := json.Unmarshal(body, &env); err != nil { + return nil, err + } + byID := map[string]float64{} + for _, p := range env.Parsed { + v, err := strconv.ParseFloat(p.Price.Price, 64) + if err != nil { + continue + } + byID[p.ID] = v * pow10(p.Price.Expo) + } + eurusd := byID[pythFeeds["eurusd"]] + if eurusd <= 0 { + return nil, errors.New("pyth: EUR/USD missing") + } + out := map[string]float64{} + for _, a := range []string{"btc", "eth", "usdc"} { + if usd := byID[pythFeeds[a]]; usd > 0 { + out[a] = usd / eurusd + } + } + if len(out) == 0 { + return nil, errors.New("pyth: no usable feed") + } + return out, nil +} + +func pow10(e int) float64 { + r := 1.0 + if e >= 0 { + for i := 0; i < e; i++ { + r *= 10 + } + return r + } + for i := 0; i < -e; i++ { + r /= 10 + } + return r +} + +// fetchSpot gathers both references in parallel. A missing reference is not +// fatal: the premium is emitted only for the references that answered, and +// the label says which. +func fetchSpot(ctx context.Context) SpotSnapshot { + snap := SpotSnapshot{At: time.Now().UTC()} + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + if k, err := fetchKraken(ctx); err == nil { + snap.Kraken = k + } else { + fmt.Printf("[SPOT] kraken: %v\n", err) + } + }() + go func() { + defer wg.Done() + if p, err := fetchPyth(ctx); err == nil { + snap.Pyth = p + } else { + fmt.Printf("[SPOT] pyth: %v\n", err) + } + }() + wg.Wait() + for a, v := range snap.Kraken { + spotPrice.WithLabelValues(a, PersonaFiat, "kraken_eur").Set(v) + } + for a, v := range snap.Pyth { + spotPrice.WithLabelValues(a, PersonaFiat, "pyth").Set(v) + if k := snap.Kraken[a]; k > 0 { + spotDivergence.WithLabelValues(a, PersonaFiat).Set((k - v) / v * 1e4) + } + } + return snap +} diff --git a/harnesses/fiat-onramp-cost/cmd/script/spot_test.go b/harnesses/fiat-onramp-cost/cmd/script/spot_test.go new file mode 100644 index 000000000..70da733e1 --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/spot_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "os" + "testing" + "time" +) + +const krakenFixture = `{"error":[],"result":{"XXBTZEUR":{"a":["66437.40000","1","1.000"],"b":["66437.30000","2","2.000"]},"XETHZEUR":{"a":["2120.57000","5","5.000"],"b":["2120.56000","1","1.000"]},"USDCEUR":{"a":["0.86150","1000","1000.000"],"b":["0.86140","500","500.000"]}}}` + +func TestParseKrakenMid(t *testing.T) { + m, err := parseKraken([]byte(krakenFixture)) + if err != nil { + t.Fatal(err) + } + if !near(m["btc"], 66437.35, 1e-6) || !near(m["eth"], 2120.565, 1e-6) || !near(m["usdc"], 0.86145, 1e-9) { + t.Fatalf("mids = %v", m) + } +} + +func TestParseKrakenError(t *testing.T) { + if _, err := parseKraken([]byte(`{"error":["EQuery:Unknown asset pair"],"result":{}}`)); err == nil { + t.Fatal("expected error") + } +} + +// Pyth prices arrive as integer strings with an exponent. 6643700000000 × +// 10^-8 = 66437. EUR/USD 116000000 × 10^-8 = 1.16 → BTC/EUR = 57273.28. +func TestParsePythCompositeEUR(t *testing.T) { + body := `{"parsed":[ + {"id":"` + pythFeeds["btc"] + `","price":{"price":"6643700000000","expo":-8}}, + {"id":"` + pythFeeds["eth"] + `","price":{"price":"212056000000","expo":-8}}, + {"id":"` + pythFeeds["usdc"] + `","price":{"price":"99990000","expo":-8}}, + {"id":"` + pythFeeds["eurusd"] + `","price":{"price":"116000000","expo":-8}}]}` + m, err := parsePyth([]byte(body)) + if err != nil { + t.Fatal(err) + } + if !near(m["btc"], 66437.0/1.16, 1e-6) { + t.Fatalf("btc eur = %v", m["btc"]) + } + if !near(m["usdc"], 0.9999/1.16, 1e-9) { + t.Fatalf("usdc eur = %v", m["usdc"]) + } +} + +func TestParsePythMissingFX(t *testing.T) { + body := `{"parsed":[{"id":"` + pythFeeds["btc"] + `","price":{"price":"1","expo":0}}]}` + if _, err := parsePyth([]byte(body)); err == nil { + t.Fatal("expected error without EUR/USD") + } +} + +// Live integration: both references must answer and agree within 1 %. +// Skipped unless OCB_LIVE=1 so CI stays hermetic. +func TestLiveSpotReferencesAgree(t *testing.T) { + if os.Getenv("OCB_LIVE") != "1" { + t.Skip("set OCB_LIVE=1") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + k, err := fetchKraken(ctx) + if err != nil { + t.Fatalf("kraken: %v", err) + } + p, err := fetchPyth(ctx) + if err != nil { + t.Fatalf("pyth: %v", err) + } + for _, a := range []string{"btc", "eth", "usdc"} { + if k[a] <= 0 || p[a] <= 0 { + t.Fatalf("%s: kraken=%v pyth=%v", a, k[a], p[a]) + } + div := (k[a] - p[a]) / p[a] * 1e4 + t.Logf("%s kraken=%.4f pyth=%.4f divergence=%.1f bps", a, k[a], p[a], div) + if div > 100 || div < -100 { + t.Errorf("%s: references diverge by %.1f bps", a, div) + } + } +} diff --git a/harnesses/fiat-onramp-cost/cmd/script/testdata/meld_quote.json b/harnesses/fiat-onramp-cost/cmd/script/testdata/meld_quote.json new file mode 100644 index 000000000..ba69416ce --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/testdata/meld_quote.json @@ -0,0 +1 @@ +{"quotes":[{"serviceProvider":"MOONPAY","sourceAmount":500,"sourceCurrencyCode":"EUR","destinationAmount":0.00741,"destinationCurrencyCode":"BTC","exchangeRate":64750.2,"totalFee":19.9,"networkFee":0.4,"transactionFee":19.5,"partnerFee":0,"paymentMethodType":"SEPA"},{"serviceProvider":"PAYBIS","sourceAmount":500,"destinationAmount":0.00729,"exchangeRate":65000,"totalFee":25,"networkFee":1,"transactionFee":24,"partnerFee":0,"paymentMethodType":"SEPA"}]} diff --git a/harnesses/fiat-onramp-cost/cmd/script/testdata/mercuryo_rate.json b/harnesses/fiat-onramp-cost/cmd/script/testdata/mercuryo_rate.json new file mode 100644 index 000000000..065ff0e04 --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/testdata/mercuryo_rate.json @@ -0,0 +1 @@ +{"status":200,"data":{"currency":"BTC","amount":"0.00711045","receivable_amount":"0.00711045","fiat_amount":"500.00","fiat_currency":"EUR","rate":"68970.31","reverse_rate":"0.00001449","fee":{"BTC":"0.00013906","EUR":"9.59"},"subtotal":{"BTC":"0.00711045","EUR":"490.41"},"total":{"BTC":"0.00724951","EUR":"500.00"},"kyc_limit_exceeded":false,"kyc_limits":false,"dca":false,"total_denominated":"50000","is_native_available":false,"network_fee":{"BTC":"0.00000392","EUR":"0.27"},"partner_fee":null,"mercuryo_fee":{"BTC":"0.00013514","EUR":"9.32"}}} \ No newline at end of file diff --git a/harnesses/fiat-onramp-cost/cmd/script/testdata/moonpay_buy_quote.json b/harnesses/fiat-onramp-cost/cmd/script/testdata/moonpay_buy_quote.json new file mode 100644 index 000000000..9d191af5a --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/testdata/moonpay_buy_quote.json @@ -0,0 +1 @@ +{"baseCurrencyAmount":500,"baseCurrencyCode":"eur","quoteCurrencyAmount":0.00744,"quoteCurrencyCode":"btc","quoteCurrencyPrice":64500.12,"feeAmount":19.62,"extraFeeAmount":0,"networkFeeAmount":0.38,"totalAmount":500,"expiresIn":60,"paymentMethod":"sepa_bank_transfer"} diff --git a/harnesses/fiat-onramp-cost/cmd/script/testdata/onramper_quotes.json b/harnesses/fiat-onramp-cost/cmd/script/testdata/onramper_quotes.json new file mode 100644 index 000000000..20c5a48b0 --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/testdata/onramper_quotes.json @@ -0,0 +1 @@ +[{"ramp":"moonpay","paymentMethod":"sepabanktransfer","rate":64750.2,"payout":0.00741,"networkFee":0.4,"transactionFee":19.5,"quoteId":"a"},{"ramp":"transak","paymentMethod":"sepabanktransfer","rate":64800,"payout":0.00738,"networkFee":1,"transactionFee":4.9,"quoteId":"b"},{"ramp":"banxa","paymentMethod":"sepabanktransfer","rate":0,"payout":0,"errors":[{"type":"AmountTooLow","message":"min 50"}]}] diff --git a/harnesses/fiat-onramp-cost/cmd/script/testdata/ramp_quote_all.json b/harnesses/fiat-onramp-cost/cmd/script/testdata/ramp_quote_all.json new file mode 100644 index 000000000..05c1e57a1 --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/testdata/ramp_quote_all.json @@ -0,0 +1 @@ +{"asset":{"symbol":"BTC_BTC","name":"Bitcoin","decimals":8,"type":"NATIVE","price":{"EUR":64480.5,"USD":70500}},"CARD_PAYMENT":{"cryptoAmount":"744000","fiatCurrency":"EUR","fiatValue":500,"appliedFee":14.5,"baseRampFee":14.5},"MANUAL_BANK_TRANSFER":{"cryptoAmount":"760000","fiatCurrency":"EUR","fiatValue":500,"appliedFee":9.9,"baseRampFee":9.9}} diff --git a/harnesses/fiat-onramp-cost/cmd/script/testdata/transak_quote.json b/harnesses/fiat-onramp-cost/cmd/script/testdata/transak_quote.json new file mode 100644 index 000000000..462d376b1 --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/testdata/transak_quote.json @@ -0,0 +1 @@ +{"response":{"quoteId":"REDACTED","conversionPrice":1.4568572565579742e-05,"marketConversionPrice":1.4597768101783308e-05,"slippage":0.2,"fiatCurrency":"EUR","cryptoCurrency":"BTC","paymentMethod":"credit_debit_card","fiatAmount":500,"cryptoAmount":0.00700952,"isBuyOrSell":"BUY","network":"mainnet","feeDecimal":0.03772,"totalFee":18.86,"feeBreakdown":[{"name":"Transak fee","value":18.5,"id":"transak_fee","ids":["transak_fee"]},{"name":"Third Party fee","value":0.36,"id":"network_fee","ids":["network_fee"]}],"nonce":1789415378,"cryptoLiquidityProvider":"transak","notes":[]}} \ No newline at end of file diff --git a/harnesses/fiat-onramp-cost/cmd/script/transak.go b/harnesses/fiat-onramp-cost/cmd/script/transak.go new file mode 100644 index 000000000..3b515e888 --- /dev/null +++ b/harnesses/fiat-onramp-cost/cmd/script/transak.go @@ -0,0 +1,139 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/url" + "strings" + "time" +) + +// Transak. GET {base}/api/v1/pricing/public/quotes +// Doc: https://docs.transak.com/reference/get-price (fetched 2026-09-10, no +// date on page). Partner key both as `x-api-key` header and `partnerApiKey` +// query param, as documented. quoteCountryCode=FR is passed explicitly: +// the docs say it exists "to ensure consistency in pricing that user sees +// on your platform as well as ours", which is exactly the identical-inputs +// property this bench needs. country_source="param". + +type transakAdapter struct { + key string + staging bool +} + +func (t *transakAdapter) Slug() string { return "transak" } +func (t *transakAdapter) Cohort() string { return "onramp" } +func (t *transakAdapter) Enabled() bool { return t.key != "" } + +// Network values as documented in the parameter table (arbitrum, ethereum, +// mainnet). "mainnet" for BTC follows the doc example; UNVERIFIED that base +// is spelled "base" (see CHECKLIST.md). +var transakNetwork = map[string]string{"bitcoin": "mainnet", "ethereum": "ethereum", "base": "base", "arbitrum": "arbitrum"} +var transakMethod = map[string]string{"card": "credit_debit_card", "sepa": "sepa_bank_transfer"} + +type transakResp struct { + Response struct { + QuoteID string `json:"quoteId"` + ConversionPrice float64 `json:"conversionPrice"` + MarketConversionPrice float64 `json:"marketConversionPrice"` + FiatAmount float64 `json:"fiatAmount"` + CryptoAmount float64 `json:"cryptoAmount"` + TotalFee float64 `json:"totalFee"` + FeeBreakdown []struct { + Name string `json:"name"` + Value float64 `json:"value"` + ID string `json:"id"` + } `json:"feeBreakdown"` + } `json:"response"` + Error struct { + Message string `json:"message"` + } `json:"error"` +} + +func (t *transakAdapter) base() string { + if t.staging { + return baseFor("transak", "https://api-stg.transak.com") + } + return baseFor("transak", "https://api.transak.com") +} + +func (t *transakAdapter) Quote(ctx context.Context, req QuoteRequest) ([]NormalizedQuote, time.Duration, error) { + net, ok := transakNetwork[req.Network] + if !ok { + return nil, 0, ErrNoQuote + } + method, ok := transakMethod[req.PaymentMethod] + if !ok { + return nil, 0, ErrNoQuote + } + q := url.Values{} + q.Set("partnerApiKey", t.key) + q.Set("fiatCurrency", "EUR") + q.Set("cryptoCurrency", strings.ToUpper(req.Asset.Asset)) + q.Set("network", net) + q.Set("isBuyOrSell", "BUY") + q.Set("fiatAmount", trimFloat(req.Notional)) + q.Set("paymentMethod", method) + q.Set("quoteCountryCode", PersonaCountry) + u := t.base() + "/api/v1/pricing/public/quotes?" + q.Encode() + res, err := doJSON(ctx, "GET", u, map[string]string{"x-api-key": t.key}, nil) + if err != nil { + return nil, res.Latency, err + } + if res.Status == 400 && bytes.Contains(res.Body, []byte("Invalid payment method")) { + // VERIFIED live 2026-09-14: EUR on this partner account lists + // card, Apple Pay and Google Pay only; SEPA comes with bank-transfer + // enablement after KYB. Until then the cell is "no offer", not + // "provider failed". + return nil, res.Latency, ErrNoQuote + } + if res.Status != 200 { + return nil, res.Latency, statusErr("transak", res) + } + var r transakResp + if err := json.Unmarshal(res.Body, &r); err != nil { + return nil, res.Latency, err + } + rp := r.Response + if rp.CryptoAmount <= 0 { + return nil, res.Latency, ErrNoQuote + } + nq := NormalizedQuote{ + Provider: "transak", Cohort: "onramp", Via: "direct", Asset: req.Asset.Asset, Network: req.Network, + PaymentMethod: req.PaymentMethod, Notional: req.Notional, CountrySource: "param", + FiatIn: rp.FiatAmount, CryptoOut: rp.CryptoAmount, + // marketConversionPrice is crypto per fiat unit (VERIFIED live + // 2026-09-14: 1.46e-5 BTC per EUR); invert to EUR per unit. + ProviderMarketRate: invert(rp.MarketConversionPrice), RawJSONHash: hashBody(res.Body), + } + // feeBreakdown ids are matched by substring so a rename on their side + // ("transak_fee" vs "transakFee") degrades to "unclassified", which is + // summed into fee_provider and flagged in the log rather than dropped. + var classified float64 + for _, f := range rp.FeeBreakdown { + id := strings.ToLower(f.ID + " " + f.Name) + switch { + case strings.Contains(id, "network"): + nq.FeeNetwork += f.Value + case strings.Contains(id, "partner"): + nq.FeePartner += f.Value + default: + nq.FeeProvider += f.Value + } + classified += f.Value + } + if len(rp.FeeBreakdown) == 0 && rp.TotalFee > 0 { + nq.FeeProvider = rp.TotalFee + } + return []NormalizedQuote{nq}, res.Latency, nil +} + +func (t *transakAdapter) CountrySource() string { return "param" } + +func invert(x float64) float64 { + if x <= 0 { + return 0 + } + return 1 / x +} diff --git a/harnesses/fiat-onramp-cost/go.mod b/harnesses/fiat-onramp-cost/go.mod new file mode 100644 index 000000000..19710c4df --- /dev/null +++ b/harnesses/fiat-onramp-cost/go.mod @@ -0,0 +1,20 @@ +module fiat-onramp-cost + +go 1.24.0 + +require ( + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/fiat-onramp-cost/go.sum b/harnesses/fiat-onramp-cost/go.sum new file mode 100644 index 000000000..d6b8ca98b --- /dev/null +++ b/harnesses/fiat-onramp-cost/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/fiat-onramp-cost/railway.toml b/harnesses/fiat-onramp-cost/railway.toml new file mode 100644 index 000000000..e717828ce --- /dev/null +++ b/harnesses/fiat-onramp-cost/railway.toml @@ -0,0 +1,9 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/healthz" +healthcheckTimeout = 60 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 5 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/permissions-scan/cmd/script/metrics.go b/harnesses/permissions-scan/cmd/script/metrics.go index e2e484f5a..0103f1935 100644 --- a/harnesses/permissions-scan/cmd/script/metrics.go +++ b/harnesses/permissions-scan/cmd/script/metrics.go @@ -50,13 +50,13 @@ func init() { epochNumberGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "perp_exit_epoch_number", - Help: "Current gToken epoch number from gains.trade gDAI vault (live RPC).", + Help: "Current gToken epoch number from the Gains gDAI vault (live RPC).", }, []string{"venue"}) prometheus.MustRegister(epochNumberGauge) epochAgeHrsGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "perp_exit_epoch_age_hours", - Help: "Hours since the current gains.trade epoch started (live RPC).", + Help: "Hours since the current Gains epoch started (live RPC).", }, []string{"venue"}) prometheus.MustRegister(epochAgeHrsGauge) } diff --git a/harnesses/perp-cohort-stats/cmd/script/registry.go b/harnesses/perp-cohort-stats/cmd/script/registry.go index 5cec711ac..3aef704be 100644 --- a/harnesses/perp-cohort-stats/cmd/script/registry.go +++ b/harnesses/perp-cohort-stats/cmd/script/registry.go @@ -22,7 +22,7 @@ var Registry = []Venue{ {Slug: "hyperliquid", Name: "Hyperliquid", Type: "perp", Chain: "hyperliquid"}, {Slug: "lighter", Name: "Lighter", Type: "perp", Chain: "zksync"}, {Slug: "gmx-v2", Name: "GMX V2", Type: "perp", Chain: "arbitrum"}, - {Slug: "gains", Name: "Gains Network", Type: "perp", Chain: "arbitrum"}, + {Slug: "gains", Name: "Gains", Type: "perp", Chain: "arbitrum"}, {Slug: "dydx", Name: "dYdX v4", Type: "perp", Chain: "dydx"}, {Slug: "paradex", Name: "Paradex", Type: "perp", Chain: "paradex"}, {Slug: "edgex", Name: "edgeX", Type: "perp", Chain: "edgex"}, diff --git a/harnesses/perp-cohort-stats/cmd/script/source_gains.go b/harnesses/perp-cohort-stats/cmd/script/source_gains.go index 1665aab6a..2802ec11b 100644 --- a/harnesses/perp-cohort-stats/cmd/script/source_gains.go +++ b/harnesses/perp-cohort-stats/cmd/script/source_gains.go @@ -8,73 +8,120 @@ import ( "time" ) -// GainsNativeSource queries the Gains Network stats backend for rolling 24h -// trading volume across all active chains. +// GainsNativeSource derives Gains' trailing 24h trading volume +// from the stats backend's per-day volume mix, all chains summed. // -// Endpoint: +// Endpoint (one call per UTC day): // -// GET https://stats.gains.trade/volume +// GET https://backend-global.gains.trade/api/volume-mix?from=D&to=D // -// The response includes a `totalVolume` field (rolling 24h USD notional) and -// a `sources` array with per-chain breakdowns (arbitrum, base, polygon, etc.). -// We use the `totalVolume` field as the canonical single-number for the venue. +// The day's volume is autoVolumeUsd + directVolumeUsd: opens and closes +// at full notional, resizes at their traded delta. That is the trade +// perimeter of the Dune view DeFiLlama's adapter reads and the figure +// bench 266 (perp-daily-volume) stores per closed day, so the 24h gauge, +// the daily chart and defillama.com agree on what "Gains volume" means. +// Gains' own leveraged_volume counter is larger (resizes at full +// notional plus an "other" bucket, up to 3x on busy days) and is not +// used. +// +// Rolling 24h from day buckets: today so far plus the share of +// yesterday that is still inside the window, (24 - hours elapsed +// today) / 24. Exact at midnight, a linear approximation in between. +// +// History: v1 read https://stats.gains.trade/volume (rolling 24h). That +// host started answering Cloudflare 1016 on 2026-09-10 and the gauge +// froze at $44M for days. v2 used leveraged_volume deltas from +// /api/stats, which is the wrong perimeter (see above). // // Derived metrics: // -// volume_24h_usd = totalVolume (USD, 24h rolling, all chains combined) +// volume_24h_usd = today(auto+direct) + yesterday(auto+direct) * (24 - h) / 24 type GainsNativeSource struct { client *http.Client + base string } func NewGainsNativeSource() *GainsNativeSource { return &GainsNativeSource{ client: &http.Client{Timeout: 15 * time.Second}, + base: "https://backend-global.gains.trade", } } func (s *GainsNativeSource) Name() string { return srcGainsNative } -type gainsVolumeResp struct { - TotalVolume float64 `json:"totalVolume"` - LastRefreshed string `json:"lastRefreshed"` - Sources []struct { - Chain string `json:"chain"` - Volume float64 `json:"volume"` - } `json:"sources"` +type gainsVolumeMix struct { + AutoVolumeUsd float64 `json:"autoVolumeUsd"` + DirectVolumeUsd float64 `json:"directVolumeUsd"` + TotalVolumeUsd float64 `json:"totalVolumeUsd"` + LastTradeTS int64 `json:"lastTradeTimestamp"` +} + +func (m gainsVolumeMix) perp() float64 { return m.AutoVolumeUsd + m.DirectVolumeUsd } + +// gainsVolume24hFromDays blends today's partial day and yesterday's full +// day into a trailing 24h figure. yesterdayOK is false when yesterday +// could not be read; today alone is then returned only in the last +// hours of the day, otherwise 0 (unknown), so the gauge keeps its last +// value instead of publishing a fraction of a day. +func gainsVolume24hFromDays(today, yesterday float64, yesterdayOK bool, now time.Time) float64 { + h := now.Sub(now.Truncate(24 * time.Hour)).Hours() + if h < 0 || h > 24 { + return 0 + } + if !yesterdayOK { + if h >= 20 { + return today + } + return 0 + } + v := today + yesterday*(24-h)/24 + if v < 0 { + return 0 + } + return v } func (s *GainsNativeSource) Fetch() (*SourceResult, error) { res := newSourceResult() venue := "gains" + now := time.Now().UTC() + today := now.Format("2006-01-02") + yesterday := now.AddDate(0, 0, -1).Format("2006-01-02") - body, err := s.get("https://stats.gains.trade/volume") + mixToday, err := s.volumeMix(today) if err != nil { perpCohortFetchErrors.WithLabelValues(venue, srcGainsNative, classifyError(err.Error())).Inc() - fmt.Printf("[perp-cohort][%s][%s] err: %v\n", venue, srcGainsNative, err) + fmt.Printf("[perp-cohort][%s][%s] day=%s err: %v\n", venue, srcGainsNative, today, err) return res, nil } - - var resp gainsVolumeResp - if err := json.Unmarshal(body, &resp); err != nil { - perpCohortFetchErrors.WithLabelValues(venue, srcGainsNative, "parse").Inc() - fmt.Printf("[perp-cohort][%s][%s] err parse: %v\n", venue, srcGainsNative, err) - return res, nil + mixYesterday, errY := s.volumeMix(yesterday) + if errY != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcGainsNative, classifyError(errY.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] day=%s err: %v\n", venue, srcGainsNative, yesterday, errY) } - if resp.TotalVolume > 0 { - res.SetIfPositive(venue, mVolume24h, resp.TotalVolume) - fmt.Printf("[perp-cohort][%s][%s] ok: vol24h=%.0f (refreshed=%s)\n", - venue, srcGainsNative, resp.TotalVolume, resp.LastRefreshed) - for _, src := range resp.Sources { - if src.Volume > 0 { - fmt.Printf("[perp-cohort][%s][%s] chain=%s vol=%.0f\n", - venue, srcGainsNative, src.Chain, src.Volume) - } - } + v := gainsVolume24hFromDays(mixToday.perp(), mixYesterday.perp(), errY == nil, now) + if v > 0 { + res.SetIfPositive(venue, mVolume24h, v) + fmt.Printf("[perp-cohort][%s][%s] ok: vol24h=%.0f (today=%.0f yesterday=%.0f, total-perimeter today=%.0f)\n", + venue, srcGainsNative, v, mixToday.perp(), mixYesterday.perp(), mixToday.TotalVolumeUsd) } return res, nil } +func (s *GainsNativeSource) volumeMix(day string) (gainsVolumeMix, error) { + var mix gainsVolumeMix + body, err := s.get(fmt.Sprintf("%s/api/volume-mix?from=%s&to=%s", s.base, day, day)) + if err != nil { + return mix, err + } + if err := json.Unmarshal(body, &mix); err != nil { + return mix, fmt.Errorf("parse: %w", err) + } + return mix, nil +} + func (s *GainsNativeSource) get(url string) ([]byte, error) { req, _ := http.NewRequest("GET", url, nil) req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@openchainbench.com") diff --git a/harnesses/perp-cohort-stats/cmd/script/source_gains_test.go b/harnesses/perp-cohort-stats/cmd/script/source_gains_test.go new file mode 100644 index 000000000..01e09de9c --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_gains_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "math" + "testing" + "time" +) + +func TestGainsVolume24hFromDays_Blend(t *testing.T) { + // 2026-09-14 on the Gains backend: auto+direct = 151.9M for the full + // day, 09-13 = 37.4M (the figure bench 266 stores for that day). + yesterday, today := 37.35e6, 151.9e6 + + // At 18:00 UTC: today + 6/24 of yesterday. + now := time.Date(2026, 9, 14, 18, 0, 0, 0, time.UTC) + got := gainsVolume24hFromDays(today, yesterday, true, now) + want := today + yesterday*6/24 + if math.Abs(got-want) > 1 { + t.Fatalf("got %.0f want %.0f", got, want) + } + + // Just after midnight: yesterday's full day dominates. + now = time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC) + if got := gainsVolume24hFromDays(0, today, true, now); math.Abs(got-today) > 1 { + t.Fatalf("midnight: got %.0f want %.0f", got, today) + } +} + +func TestGainsVolume24hFromDays_YesterdayMissing(t *testing.T) { + early := time.Date(2026, 9, 14, 6, 0, 0, 0, time.UTC) + if v := gainsVolume24hFromDays(10e6, 0, false, early); v != 0 { + t.Fatalf("a quarter day must not be published as 24h: %v", v) + } + late := time.Date(2026, 9, 14, 22, 0, 0, 0, time.UTC) + if v := gainsVolume24hFromDays(10e6, 0, false, late); v != 10e6 { + t.Fatalf("late in the day today alone is close enough: %v", v) + } +} + +func TestGainsVolume24hFromDays_Degenerate(t *testing.T) { + now := time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC) + if v := gainsVolume24hFromDays(0, 0, true, now); v != 0 { + t.Fatalf("no volume: %v", v) + } + if v := gainsVolume24hFromDays(-5, -5, true, now); v != 0 { + t.Fatalf("negative: %v", v) + } +} diff --git a/harnesses/perp-fees/README.md b/harnesses/perp-fees/README.md index f10992fe0..f1054fc35 100644 --- a/harnesses/perp-fees/README.md +++ b/harnesses/perp-fees/README.md @@ -12,7 +12,7 @@ Exposes Prometheus metrics on `:2112/metrics`. | dYdX v4 | indexer orderbook + perpetualMarkets funding + Cosmos REST fee tier | | GMX v2 (Arbitrum) | Subsquid GraphQL `positionFeeFactorForNegativeImpact` + gmxinfra REST funding | | Lighter | `/orderBookDetails` taker fee + `/orderBookOrders` orderbook walk | -| gains.trade (v8 on Base) | `eth_call` on the on-chain fee oracle | +| Gains (v8 on Base) | `eth_call` on the on-chain fee oracle | Cadence: every 5 minutes, in parallel across all venues. Failed scrapes leave the previous gauge in place and increment `perp_fees_fetch_errors_total`. diff --git a/harnesses/perp-fees/cmd/script/config.go b/harnesses/perp-fees/cmd/script/config.go index 89f01d78e..3381091b1 100644 --- a/harnesses/perp-fees/cmd/script/config.go +++ b/harnesses/perp-fees/cmd/script/config.go @@ -41,7 +41,7 @@ func loadConfig() *Config { // for each asset is discovered by scanning the diamond's // `pairs(N)` slot for the asset/USD name match (findGainsPair in // gains.go). - {slug: "gains", display: "Gains Network", assets: []string{"ETH", "BTC", "SOL"}}, + {slug: "gains", display: "Gains", assets: []string{"ETH", "BTC", "SOL"}}, // Polymarket perps (2026-07-08 launch): public info API, base fee // tier taker 4 bps. ETH/BTC/SOL all listed at launch. {slug: "polymarket", display: "Polymarket", assets: []string{"ETH", "BTC", "SOL"}}, diff --git a/harnesses/perp-fees/cmd/script/gains.go b/harnesses/perp-fees/cmd/script/gains.go index 141b2476b..7e50ab09f 100644 --- a/harnesses/perp-fees/cmd/script/gains.go +++ b/harnesses/perp-fees/cmd/script/gains.go @@ -14,7 +14,7 @@ import ( "time" ) -// Gains Network v8 on Base. We read fees and spread directly from the +// Gains (gTrade) v8 on Base. We read fees and spread directly from the // PairsStorage facet on the Diamond contract via Base public RPC. No // hardcoded fee schedule — everything comes from on-chain. // @@ -136,7 +136,7 @@ func gainsReadPair(client *http.Client, pairIndex int) (*gainsPair, error) { // to the start of the struct content. from := extractAsciiString(res, fromOff+32) to := extractAsciiString(res, toOff+32) - // Pair struct slot layout (re-verified against the gains.trade backend + // Pair struct slot layout (re-verified against the Gains backend // /trading-variables on 2026-07-08): // slot 7 → spreadP (full bid-ask spread, 1e10 precision) // slot 8 → groupIndex @@ -313,7 +313,7 @@ func fetchGains(v VenueConfig, _ string) PerpSample { // 350_000_000 means 0.035% = 3.5 bps. bps = raw / 1e8. openFeeF, _ := new(big.Float).Quo(new(big.Float).SetInt(openFeeP), big.NewFloat(1e8)).Float64() // spreadP is the FULL bid-ask spread; the taker only crosses half of - // it, so half-spread = spreadP / (2 × 1e8). gains.trade UI displays + // it, so half-spread = spreadP / (2 × 1e8). the Gains UI displays // this same half-spread (e.g. spreadP=1e8 → 0.005% = 0.5 bps). spreadF, _ := new(big.Float).Quo(new(big.Float).SetInt(pair.SpreadP), big.NewFloat(2e8)).Float64() diff --git a/harnesses/perp-fees/cmd/script/walk.go b/harnesses/perp-fees/cmd/script/walk.go index 6f7076610..2d18709ac 100644 --- a/harnesses/perp-fees/cmd/script/walk.go +++ b/harnesses/perp-fees/cmd/script/walk.go @@ -125,7 +125,7 @@ func applyBookTiersCapped(s *PerpSample, levels []bookLevel, mid, maxFillRatio f } // applyFlatTiers publishes the same all-in figure at every tier. Used by the -// oracle-priced venues (GMX v2, gains.trade) where the harness-read cost is +// oracle-priced venues (GMX v2, Gains) where the harness-read cost is // a fixed fraction of position size, so the bps figure does not change with // notional. Callers document per venue why that is correct. func applyFlatTiers(s *PerpSample) { diff --git a/harnesses/perp-liq-rate/cmd/script/source_gains.go b/harnesses/perp-liq-rate/cmd/script/source_gains.go index b44af2f67..b4453a99e 100644 --- a/harnesses/perp-liq-rate/cmd/script/source_gains.go +++ b/harnesses/perp-liq-rate/cmd/script/source_gains.go @@ -1,6 +1,6 @@ package main -// source_gains.go — Gains Network (gTrade) on Base mainnet. +// source_gains.go — Gains (gTrade) on Base mainnet. // // Liquidations: eth_getLogs on the diamond for TradeClosed events, keeping // only those whose cancelReason (last uint8 word of the event data) == 1. diff --git a/harnesses/perp-protocol-longevity/cmd/script/registry.go b/harnesses/perp-protocol-longevity/cmd/script/registry.go index 7136464c8..4e20992ea 100644 --- a/harnesses/perp-protocol-longevity/cmd/script/registry.go +++ b/harnesses/perp-protocol-longevity/cmd/script/registry.go @@ -29,7 +29,7 @@ type venueRecord struct { var registry = []venueRecord{ { Slug: "gains", - Name: "gains.trade", + Name: "Gains", Launched: time.Date(2021, 12, 1, 0, 0, 0, 0, time.UTC), // No incidents recorded in DeFiLlama or public post-mortems as of 2026-08-03. Incidents: []incidentRecord{}, diff --git a/harnesses/perp-token-metrics/README.md b/harnesses/perp-token-metrics/README.md new file mode 100644 index 000000000..697d9d240 --- /dev/null +++ b/harnesses/perp-token-metrics/README.md @@ -0,0 +1,79 @@ +# perp-token-metrics + +Hourly valuation snapshot for perp DEX tokens. Feeds two benches: + +- **234 `perp-pe-ratio`**: FDV / annualized protocol revenue. +- **265 `perp-pf-ratio`**: market cap / annualized fees, plus FDV/F, P/S, float, open interest. + +## Inputs + +| Source | Endpoint | What we take | +|---|---|---| +| DeFiLlama | `GET /summary/fees/{slug}?dataType=dailyFees` | `total24h`, `totalDataChart` (we cut 30d / days 31-60 / 365d ourselves) | +| DeFiLlama | `GET /summary/fees/{slug}?dataType=dailyRevenue` | same, revenue. Flat-zero or missing series = "no revenue data", not zero | +| DeFiLlama | `GET /overview/open-interest` | `protocols[].total24h` keyed by perps child slug | +| CoinGecko | `GET /coins/markets?ids=…` | `market_cap`, `fully_diluted_valuation` (falls back to mcap), `circulating_supply`, `total_supply` | + +`/summary/fees/{parent}` aggregates every child product (verified: `hyperliquid` = perps + spot + HLP, `jupiter` = perps + aggregator + lend). The token is measured against its parent so market cap and fees share one scope; the perps-only child is published as `perp_protocol_perp_fees_30d_usd`. + +DeFiLlama's derivatives *volume* summary is paid-tier; volume is not read here (the perp-cohort-stats harness publishes `perp_venue_volume_30d_usd`). + +## Formulas + +``` +annual(x) = sum(last 30 daily points) * 365 / 30 +P/F = mcap / annual(fees) +FDV/F = fdv / annual(fees) +P/S = mcap / annual(revenue) +P/E = fdv / annual(revenue) (bench 234) +rev share = 100 * rev30d / fees30d +float % = 100 * circulating / total supply +fees / OI = annual(fees) / open interest +``` + +A ratio whose denominator is zero, or whose token has no listing, is **deleted** from the gauge vector rather than set to 0, so a missing value never reads as a zero valuation. + +## Coverage (verified 2026-09-12) + +| slug | DeFiLlama (token scope) | perps child | CoinGecko | notes | +|---|---|---|---|---| +| hyperliquid | hyperliquid | hyperliquid-perps | hyperliquid | | +| gmx | gmx | gmx-v2-perps | gmx | | +| gains | gains-network | = | gains-network | 100 % float | +| dydx | dydx | dydx-v4 | dydx-chain | | +| drift | drift-trade | = | drift-protocol | adapter reports 0 fees; ratios undefined | +| jupiter | jupiter | jupiter-perpetual-exchange | jupiter-exchange-solana | perps ≈ half of parent fees | +| aster | aster | aster-perps | aster-2 | no revenue series | +| lighter | lighter | lighter-perps | lighter | parent adds Robinhood perps | +| avantis | avantis | = | avantis | no revenue series | +| apex | apex-protocol | apex-omni | apex-token-2 | | +| orderly | orderly-perps | = | orderly-network | parent slug returns null; no revenue | +| synfutures | synfutures | synfutures-v3 | synfutures | | +| derive | derive | derive-v2 | derive | parent adds options | +| ostium, pacifica, extended, edgex, paradex | see main.go | | none | fees + OI only | + +Dropped: vertex (no data since acquisition), aevo / grvt / backpack (no DeFiLlama fees series), mux / perpetual-protocol (fees ≈ 0). + +## Metrics + +All gauges carry `protocol=""`. + +``` +perp_protocol_pf_ratio perp_protocol_fees_24h_usd perp_protocol_mcap_usd +perp_protocol_pf_fdv_ratio perp_protocol_fees_30d_usd perp_protocol_fdv_usd +perp_protocol_ps_ratio perp_protocol_fees_prev_30d_usd perp_protocol_float_pct +perp_protocol_pe_ratio perp_protocol_fees_1y_usd perp_protocol_oi_usd +perp_protocol_rev_share_pct perp_protocol_annual_fees_usd perp_protocol_fees_to_oi_ratio +perp_protocol_rev_24h_usd perp_protocol_perp_fees_30d_usd perp_protocol_health +perp_protocol_rev_30d_usd perp_protocol_annual_rev_usd +``` + +## Run + +``` +go test ./... # unit tests, fake upstream +ONESHOT=1 METRICS_ADDR=:2112 go run ./cmd/script # one live poll, then exit +curl -s localhost:2112/metrics | grep perp_protocol_pf_ratio +``` + +~40 DeFiLlama calls and one CoinGecko call per hour; both public tiers allow this comfortably. diff --git a/harnesses/perp-token-metrics/cmd/script/fetcher.go b/harnesses/perp-token-metrics/cmd/script/fetcher.go index 2ec5a9d26..4a572ad58 100644 --- a/harnesses/perp-token-metrics/cmd/script/fetcher.go +++ b/harnesses/perp-token-metrics/cmd/script/fetcher.go @@ -4,12 +4,19 @@ import ( "encoding/json" "fmt" "net/http" + "sort" "strings" "time" ) var httpClient = &http.Client{Timeout: 30 * time.Second} +// Base URLs are variables so tests can point them at an httptest server. +var ( + llamaBase = "https://api.llama.fi" + cgBase = "https://api.coingecko.com/api/v3" +) + func get(url string) (*http.Response, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -19,12 +26,28 @@ func get(url string) (*http.Response, error) { return httpClient.Do(req) } -// fetchLlamaRevenue returns (rev24h, avg30dDaily, ok). -func fetchLlamaRevenue(slug string) (float64, float64, bool) { - url := fmt.Sprintf("https://api.llama.fi/summary/fees/%s?dataType=dailyRevenue", slug) +// llamaSeries is what we keep from one /summary/fees/?dataType=… +// response: the headline 24h number and the trailing windows we derive +// from totalDataChart ourselves, so every protocol is cut the same way +// regardless of which convenience fields the adapter happens to fill. +type llamaSeries struct { + Total24h float64 + Sum30d float64 // last 30 daily points + SumPrev30 float64 // the 30 points before those (days 31-60) + Sum1y float64 // last 365 daily points + Avg30d float64 // Sum30d / non-zero days in the window + Days30 int // non-zero days counted in Avg30d +} + +// fetchLlamaSeries returns the trailing windows for one DeFiLlama slug and +// data type ("dailyFees" or "dailyRevenue"). ok is false on transport or +// decode failure, or when the chart is empty; a chart of zeros is ok=true +// with zero sums, which is a real observation (adapter live, nothing earned). +func fetchLlamaSeries(slug, dataType string) (llamaSeries, bool) { + url := fmt.Sprintf("%s/summary/fees/%s?dataType=%s", llamaBase, slug, dataType) resp, err := get(url) if err != nil || resp.StatusCode != 200 { - return 0, 0, false + return llamaSeries{}, false } defer resp.Body.Close() @@ -33,40 +56,74 @@ func fetchLlamaRevenue(slug string) (float64, float64, bool) { TotalDataChart [][2]json.Number `json:"totalDataChart"` } if err := json.NewDecoder(resp.Body).Decode(&d); err != nil { - return 0, 0, false + return llamaSeries{}, false + } + if len(d.TotalDataChart) == 0 { + return llamaSeries{}, false } + return windowsFromChart(d.Total24h, d.TotalDataChart), true +} - chart := d.TotalDataChart - start := len(chart) - 30 - if start < 0 { - start = 0 - } - var sum float64 - var count int - for _, pt := range chart[start:] { - v, err := pt[1].Float64() - if err != nil || v <= 0 { +// windowsFromChart cuts the trailing windows out of a DeFiLlama +// totalDataChart ([[unixDay, value], …]). Points are sorted by timestamp +// first because the API does not promise order. The current partial day +// is not special-cased: DeFiLlama's own 30d figure includes it too. +func windowsFromChart(total24h float64, chart [][2]json.Number) llamaSeries { + type pt struct { + t int64 + v float64 + } + pts := make([]pt, 0, len(chart)) + for _, c := range chart { + t, err1 := c[0].Int64() + v, err2 := c[1].Float64() + if err1 != nil || err2 != nil { continue } - sum += v - count++ + pts = append(pts, pt{t, v}) + } + sort.Slice(pts, func(i, j int) bool { return pts[i].t < pts[j].t }) + + s := llamaSeries{Total24h: total24h} + n := len(pts) + for i := n - 1; i >= 0 && i >= n-365; i-- { + v := pts[i].v + s.Sum1y += v + back := n - 1 - i // 0 = latest day + switch { + case back < 30: + s.Sum30d += v + if v > 0 { + s.Days30++ + } + case back < 60: + s.SumPrev30 += v + } } - if count == 0 { - return d.Total24h, 0, false + if s.Days30 > 0 { + s.Avg30d = s.Sum30d / float64(s.Days30) } - return d.Total24h, sum / float64(count), true + return s } -// fetchCGFdvBatch fetches FDV for multiple CoinGecko IDs in one request. -func fetchCGFdvBatch(ids []string) map[string]float64 { - result := map[string]float64{} +// cgMarket is the slice of CoinGecko /coins/markets we use. +type cgMarket struct { + Mcap float64 + FDV float64 + Circ float64 + TotalSupply float64 +} + +// fetchCGMarkets fetches market cap, FDV and supply for many CoinGecko IDs +// in one request. Missing IDs are simply absent from the map. +func fetchCGMarkets(ids []string) map[string]cgMarket { + result := map[string]cgMarket{} if len(ids) == 0 { return result } - url := fmt.Sprintf( - "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=%s&per_page=50&sparkline=false", - strings.Join(ids, ","), + "%s/coins/markets?vs_currency=usd&ids=%s&per_page=100&sparkline=false", + cgBase, strings.Join(ids, ","), ) resp, err := get(url) if err != nil || resp.StatusCode != 200 { @@ -75,21 +132,64 @@ func fetchCGFdvBatch(ids []string) map[string]float64 { defer resp.Body.Close() var coins []struct { - ID string `json:"id"` - FullyDilutedVal *float64 `json:"fully_diluted_valuation"` - MarketCap *float64 `json:"market_cap"` + ID string `json:"id"` + MarketCap *float64 `json:"market_cap"` + FullyDilutedVal *float64 `json:"fully_diluted_valuation"` + Circulating *float64 `json:"circulating_supply"` + TotalSupply *float64 `json:"total_supply"` } if err := json.NewDecoder(resp.Body).Decode(&coins); err != nil { return result } - + deref := func(p *float64) float64 { + if p == nil { + return 0 + } + return *p + } for _, c := range coins { - fdv := c.FullyDilutedVal - if fdv == nil { - fdv = c.MarketCap + m := cgMarket{ + Mcap: deref(c.MarketCap), + FDV: deref(c.FullyDilutedVal), + Circ: deref(c.Circulating), + TotalSupply: deref(c.TotalSupply), + } + // CoinGecko leaves FDV null when total supply is unknown; the + // existing P/E bench fell back to mcap and we keep that. + if m.FDV == 0 { + m.FDV = m.Mcap } - if fdv != nil && *fdv > 0 { - result[c.ID] = *fdv + if m.Mcap > 0 || m.FDV > 0 { + result[c.ID] = m + } + } + return result +} + +// fetchLlamaOpenInterest returns current open interest in USD keyed by +// DeFiLlama perps slug (the overview lists child protocols only). An +// empty map on failure just leaves the OI-derived gauges unset. +func fetchLlamaOpenInterest() map[string]float64 { + result := map[string]float64{} + url := llamaBase + "/overview/open-interest?excludeTotalDataChart=true&excludeTotalDataChartBreakdown=true" + resp, err := get(url) + if err != nil || resp.StatusCode != 200 { + return result + } + defer resp.Body.Close() + + var d struct { + Protocols []struct { + Slug string `json:"slug"` + Total24h *float64 `json:"total24h"` + } `json:"protocols"` + } + if err := json.NewDecoder(resp.Body).Decode(&d); err != nil { + return result + } + for _, p := range d.Protocols { + if p.Total24h != nil && *p.Total24h > 0 { + result[p.Slug] = *p.Total24h } } return result diff --git a/harnesses/perp-token-metrics/cmd/script/main.go b/harnesses/perp-token-metrics/cmd/script/main.go index 1eb747f1c..6d32cbd23 100644 --- a/harnesses/perp-token-metrics/cmd/script/main.go +++ b/harnesses/perp-token-metrics/cmd/script/main.go @@ -8,18 +8,58 @@ import ( "time" ) +// protocol is one row of the valuation table. llamaSlug is the DeFiLlama +// entity the token accrues value from (the parent when the protocol has +// several products, so mcap and fees are measured on the same scope, the +// way defillama.com/pf does it). llamaPerpSlug is the perps-only child, +// used for the perp-share column and the open-interest join; empty means +// the parent is perps-only. cgID empty means the protocol has no listed +// token: fees are still published, every valuation ratio is skipped. type protocol struct { - slug string // bench slug / prometheus label - llamaSlug string // DeFiLlama fees slug - cgID string // CoinGecko coin ID (empty = no FDV) + slug string // bench slug / prometheus label + llamaSlug string // DeFiLlama fees slug, token scope (parent) + llamaPerpSlug string // DeFiLlama fees slug, perps-only child ("" = same as llamaSlug) + cgID string // CoinGecko coin ID ("" = no token) } +// Verified against api.llama.fi/summary/fees/ and CoinGecko +// /coins/markets on 2026-09-12 (see harness README for the coverage +// table). Slugs that DeFiLlama no longer serves (vertex, aevo, grvt, +// backpack) are deliberately absent rather than left to fail every hour. var protocols = []protocol{ - {slug: "hyperliquid", llamaSlug: "hyperliquid", cgID: "hyperliquid"}, - {slug: "gmx", llamaSlug: "gmx", cgID: "gmx"}, + {slug: "hyperliquid", llamaSlug: "hyperliquid", llamaPerpSlug: "hyperliquid-perps", cgID: "hyperliquid"}, + {slug: "gmx", llamaSlug: "gmx", llamaPerpSlug: "gmx-v2-perps", cgID: "gmx"}, {slug: "gains", llamaSlug: "gains-network", cgID: "gains-network"}, - {slug: "dydx", llamaSlug: "dydx", cgID: "dydx"}, - {slug: "drift", llamaSlug: "drift", cgID: "drift-protocol"}, + {slug: "dydx", llamaSlug: "dydx", llamaPerpSlug: "dydx-v4", cgID: "dydx-chain"}, + // Drift's DeFiLlama parent aggregates the staked-SOL product; the perps + // adapter (drift-trade) is what the token is priced on, even while it + // reports zero and leaves every ratio undefined. + {slug: "drift", llamaSlug: "drift-trade", cgID: "drift-protocol"}, + {slug: "jupiter", llamaSlug: "jupiter", llamaPerpSlug: "jupiter-perpetual-exchange", cgID: "jupiter-exchange-solana"}, + {slug: "aster", llamaSlug: "aster", llamaPerpSlug: "aster-perps", cgID: "aster-2"}, + {slug: "lighter", llamaSlug: "lighter", llamaPerpSlug: "lighter-perps", cgID: "lighter"}, + {slug: "avantis", llamaSlug: "avantis", cgID: "avantis"}, + {slug: "apex", llamaSlug: "apex-protocol", llamaPerpSlug: "apex-omni", cgID: "apex-token-2"}, + {slug: "orderly", llamaSlug: "orderly-perps", cgID: "orderly-network"}, + {slug: "synfutures", llamaSlug: "synfutures", llamaPerpSlug: "synfutures-v3", cgID: "synfutures"}, + {slug: "derive", llamaSlug: "derive", llamaPerpSlug: "derive-v2", cgID: "derive"}, + // edgeX (EDGE) and Paradex (DIME) listed on CoinGecko; verified 2026-09-14. + {slug: "edgex", llamaSlug: "edgex", llamaPerpSlug: "edgex-v2", cgID: "edgex"}, + {slug: "paradex", llamaSlug: "paradex-perps", cgID: "paradex"}, + // Pre-TGE venues: fees and open interest only, shown unranked on the + // bench so the fee base of a future token is visible before it lists. + {slug: "ostium", llamaSlug: "ostium"}, + {slug: "pacifica", llamaSlug: "pacifica", llamaPerpSlug: "pacifica-perps"}, + {slug: "extended", llamaSlug: "extended", llamaPerpSlug: "extended-perps"}, + {slug: "nado", llamaSlug: "nado", llamaPerpSlug: "nado-perps"}, + {slug: "decibel", llamaSlug: "decibel"}, +} + +func (p protocol) perpSlug() string { + if p.llamaPerpSlug != "" { + return p.llamaPerpSlug + } + return p.llamaSlug } func addr() string { @@ -31,7 +71,7 @@ func addr() string { func main() { fmt.Println("=== perp-token-metrics harness ===") - fmt.Println("OpenChainBench #234 — perp DEX token P/E ratios") + fmt.Println("OpenChainBench #234 (P/E) + perp DEX valuation (P/F, P/S)") fmt.Printf("Protocols: %d | poll interval: 1h\n", len(protocols)) go func() { @@ -43,6 +83,10 @@ func main() { poll() + if os.Getenv("ONESHOT") == "1" { + return + } + ticker := time.NewTicker(60 * time.Minute) defer ticker.Stop() @@ -63,42 +107,74 @@ func main() { func poll() { fmt.Printf("[%s] polling DeFiLlama + CoinGecko\n", time.Now().UTC().Format(time.RFC3339)) - // collect CoinGecko FDVs in one batch call + // One batch call for every token, one call for every open-interest row. cgIDs := []string{} for _, p := range protocols { if p.cgID != "" { cgIDs = append(cgIDs, p.cgID) } } - fdvMap := fetchCGFdvBatch(cgIDs) + markets := fetchCGMarkets(cgIDs) + oi := fetchLlamaOpenInterest() for _, p := range protocols { - rev24h, avg30d, ok := fetchLlamaRevenue(p.llamaSlug) - if !ok { - fmt.Printf(" [%s] DeFiLlama fetch failed\n", p.slug) + fees, feesOK := fetchLlamaSeries(p.llamaSlug, "dailyFees") + if !feesOK { + fmt.Printf(" [%s] DeFiLlama fees fetch failed\n", p.slug) protocolHealth.WithLabelValues(p.slug).Set(0) continue } - - annualRev := avg30d * 365 - fdv, hasFDV := fdvMap[p.cgID] - var pe float64 - hasPE := hasFDV && annualRev > 0 - if hasPE { - pe = fdv / annualRev + // Revenue is optional: several adapters publish fees only, and an + // adapter that answers with a flat zero series is the same thing. + rev, revOK := fetchLlamaSeries(p.llamaSlug, "dailyRevenue") + revOK = revOK && rev.Sum30d > 0 + perpFees := fees + if p.perpSlug() != p.llamaSlug { + if pf, ok := fetchLlamaSeries(p.perpSlug(), "dailyFees"); ok { + perpFees = pf + } } - protocolRev24h.WithLabelValues(p.slug).Set(rev24h) - protocolAnnualRev.WithLabelValues(p.slug).Set(annualRev) - if hasFDV { - protocolFDV.WithLabelValues(p.slug).Set(fdv) - } - if hasPE { - protocolPE.WithLabelValues(p.slug).Set(pe) - } + v := computeValuation(fees, rev, revOK, markets[p.cgID], p.cgID != "") + publish(p, v, perpFees, oi[p.perpSlug()]) protocolHealth.WithLabelValues(p.slug).Set(1) - fmt.Printf(" [%s] rev24h=$%.0f avg30d=$%.0f annual=$%.0f fdv=$%.0f pe=%.1fx\n", - p.slug, rev24h, avg30d, annualRev, fdv, pe) + fmt.Printf(" [%s] fees30d=$%.0f rev30d=$%.0f mcap=$%.0f fdv=$%.0f pf=%.1fx ps=%.1fx pe=%.1fx\n", + p.slug, fees.Sum30d, rev.Sum30d, v.Mcap, v.FDV, v.PF, v.PS, v.PE) + } +} + +// publish writes one protocol's row into the gauge vectors. Ratios that +// could not be computed (no token, zero fees) are removed from the vector +// so a stale value from a previous poll never survives a source outage. +func publish(p protocol, v valuation, perpFees llamaSeries, oiUSD float64) { + l := labelsFor(p.slug) + + protocolFees24h.With(l).Set(v.Fees24h) + protocolFees30d.With(l).Set(v.Fees30d) + protocolFeesPrev30d.With(l).Set(v.FeesPrev30d) + protocolFees1y.With(l).Set(v.Fees1y) + protocolAnnualFees.With(l).Set(v.AnnualFees) + protocolPerpFees30d.With(l).Set(perpFees.Sum30d) + + setOrDelete(protocolRev24h, l, v.Rev24h, v.HasRev) + setOrDelete(protocolRev30d, l, v.Rev30d, v.HasRev) + setOrDelete(protocolAnnualRev, l, v.AnnualRev, v.HasRev) + setOrDelete(protocolRevShare, l, v.RevSharePct, v.HasRev && v.Fees30d > 0) + + setOrDelete(protocolMcap, l, v.Mcap, v.HasToken && v.Mcap > 0) + setOrDelete(protocolFDV, l, v.FDV, v.HasToken && v.FDV > 0) + setOrDelete(protocolFloat, l, v.FloatPct, v.HasToken && v.FloatPct > 0) + + setOrDelete(protocolPF, l, v.PF, v.HasPF) + setOrDelete(protocolPFfdv, l, v.PFfdv, v.HasPFfdv) + setOrDelete(protocolPS, l, v.PS, v.HasPS) + setOrDelete(protocolPE, l, v.PE, v.HasPE) + + setOrDelete(protocolOI, l, oiUSD, oiUSD > 0) + feesToOI := 0.0 + if oiUSD > 0 { + feesToOI = v.AnnualFees / oiUSD } + setOrDelete(protocolFeesToOI, l, feesToOI, feesToOI > 0) } diff --git a/harnesses/perp-token-metrics/cmd/script/metrics.go b/harnesses/perp-token-metrics/cmd/script/metrics.go index 0986f667f..3cab387c2 100644 --- a/harnesses/perp-token-metrics/cmd/script/metrics.go +++ b/harnesses/perp-token-metrics/cmd/script/metrics.go @@ -7,43 +7,66 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" ) +var labels = []string{"protocol"} + +func gauge(name, help string) *prometheus.GaugeVec { + return prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: name, Help: help}, labels) +} + var ( - protocolPE = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Name: "perp_protocol_pe_ratio", - Help: "P/E ratio per perp DEX protocol (FDV / annualized 30d avg daily revenue).", - }, []string{"protocol"}) - - protocolFDV = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Name: "perp_protocol_fdv_usd", - Help: "Fully diluted valuation in USD per perp DEX protocol (CoinGecko).", - }, []string{"protocol"}) - - protocolAnnualRev = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Name: "perp_protocol_annual_rev_usd", - Help: "Annualized protocol revenue in USD (DeFiLlama 30d avg x 365).", - }, []string{"protocol"}) - - protocolRev24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Name: "perp_protocol_rev_24h_usd", - Help: "24h protocol revenue in USD (DeFiLlama dailyRevenue).", - }, []string{"protocol"}) - - protocolHealth = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Name: "perp_protocol_health", - Help: "1 if this protocol returned data in the last poll.", - }, []string{"protocol"}) + // Bench 234 gauges (names unchanged so the live spec keeps resolving). + protocolPE = gauge("perp_protocol_pe_ratio", "P/E ratio per perp DEX protocol (FDV / annualized 30d revenue).") + protocolFDV = gauge("perp_protocol_fdv_usd", "Fully diluted valuation in USD (CoinGecko; market cap when FDV is unknown).") + protocolAnnualRev = gauge("perp_protocol_annual_rev_usd", "Annualized protocol revenue in USD (DeFiLlama dailyRevenue, trailing 30d x 365/30).") + protocolRev24h = gauge("perp_protocol_rev_24h_usd", "24h protocol revenue in USD (DeFiLlama dailyRevenue).") + protocolHealth = gauge("perp_protocol_health", "1 if this protocol returned fee data in the last poll.") + + // Valuation gauges (P/F page). + protocolFees24h = gauge("perp_protocol_fees_24h_usd", "24h total fees paid by users in USD (DeFiLlama dailyFees, token scope).") + protocolFees30d = gauge("perp_protocol_fees_30d_usd", "Trailing 30d fees in USD (DeFiLlama dailyFees, token scope).") + protocolFeesPrev30d = gauge("perp_protocol_fees_prev_30d_usd", "Fees in USD for days 31-60 back, for month-over-month trend.") + protocolFees1y = gauge("perp_protocol_fees_1y_usd", "Trailing 365d fees in USD.") + protocolAnnualFees = gauge("perp_protocol_annual_fees_usd", "Annualized fees in USD (trailing 30d x 365/30).") + protocolPerpFees30d = gauge("perp_protocol_perp_fees_30d_usd", "Trailing 30d fees of the perps product only, in USD (equals fees_30d for single-product protocols).") + protocolRev30d = gauge("perp_protocol_rev_30d_usd", "Trailing 30d protocol revenue in USD (DeFiLlama dailyRevenue).") + protocolRevShare = gauge("perp_protocol_rev_share_pct", "Share of 30d fees that is protocol revenue, in percent.") + + protocolMcap = gauge("perp_protocol_mcap_usd", "Circulating market cap in USD (CoinGecko).") + protocolFloat = gauge("perp_protocol_float_pct", "Circulating supply as a percent of total supply (CoinGecko).") + + protocolPF = gauge("perp_protocol_pf_ratio", "Price-to-fees: market cap / annualized fees.") + protocolPFfdv = gauge("perp_protocol_pf_fdv_ratio", "FDV-to-fees: fully diluted valuation / annualized fees.") + protocolPS = gauge("perp_protocol_ps_ratio", "Price-to-sales: market cap / annualized protocol revenue.") + + protocolOI = gauge("perp_protocol_oi_usd", "Open interest in USD on the perps product (DeFiLlama open-interest overview).") + protocolFeesToOI = gauge("perp_protocol_fees_to_oi_ratio", "Annualized fees / open interest: how hard the book is worked per dollar of OI.") ) func init() { prometheus.MustRegister( - protocolPE, - protocolFDV, - protocolAnnualRev, - protocolRev24h, - protocolHealth, + protocolPE, protocolFDV, protocolAnnualRev, protocolRev24h, protocolHealth, + protocolFees24h, protocolFees30d, protocolFeesPrev30d, protocolFees1y, protocolAnnualFees, + protocolPerpFees30d, protocolRev30d, protocolRevShare, + protocolMcap, protocolFloat, + protocolPF, protocolPFfdv, protocolPS, + protocolOI, protocolFeesToOI, ) } +func labelsFor(slug string) prometheus.Labels { + return prometheus.Labels{"protocol": slug} +} + +// setOrDelete publishes v when defined, otherwise removes the series so a +// stale value from an earlier poll cannot outlive the data that produced it. +func setOrDelete(g *prometheus.GaugeVec, l prometheus.Labels, v float64, defined bool) { + if defined { + g.With(l).Set(v) + return + } + g.Delete(l) +} + func startMetricsServer(addr string) error { mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) diff --git a/harnesses/perp-token-metrics/cmd/script/valuation.go b/harnesses/perp-token-metrics/cmd/script/valuation.go new file mode 100644 index 000000000..9c3739ea8 --- /dev/null +++ b/harnesses/perp-token-metrics/cmd/script/valuation.go @@ -0,0 +1,77 @@ +package main + +// valuation is the computed row for one protocol. Has* flags say which +// ratios are defined; the caller deletes the gauge when a flag is false +// instead of publishing zero, so "no token" and "no data" never read as +// "worth nothing". +type valuation struct { + Fees24h, Fees30d, FeesPrev30d, Fees1y, AnnualFees float64 + Rev24h, Rev30d, AnnualRev, RevSharePct float64 + HasRev bool + + HasToken bool + Mcap, FDV, FloatPct float64 + + PF, PFfdv, PS, PE float64 + HasPF, HasPFfdv, HasPS, HasPE bool +} + +// annualize turns a trailing-30-day sum into a yearly run rate. Zero days +// inside the window count: a live adapter that recorded nothing is a +// real observation, and the same cut is applied to every protocol. +func annualize(sum30d float64) float64 { + return sum30d * 365 / 30 +} + +// computeValuation derives every ratio from one fees series, an optional +// revenue series, and the CoinGecko market row. hasToken=false (no listing) +// publishes fees only. +// +// P/F = market cap / annualized fees (defillama.com/pf convention) +// FDV/F = fully diluted valuation / annualized fees +// P/S = market cap / annualized revenue (revenue = protocol + holders share) +// P/E = FDV / annualized revenue (bench 234, unchanged definition) +func computeValuation(fees, rev llamaSeries, hasRev bool, m cgMarket, hasToken bool) valuation { + v := valuation{ + Fees24h: fees.Total24h, + Fees30d: fees.Sum30d, + FeesPrev30d: fees.SumPrev30, + Fees1y: fees.Sum1y, + AnnualFees: annualize(fees.Sum30d), + HasRev: hasRev, + HasToken: hasToken, + } + if hasRev { + v.Rev24h = rev.Total24h + v.Rev30d = rev.Sum30d + v.AnnualRev = annualize(rev.Sum30d) + if v.Fees30d > 0 { + v.RevSharePct = 100 * v.Rev30d / v.Fees30d + } + } + if !hasToken { + return v + } + v.Mcap = m.Mcap + v.FDV = m.FDV + if m.TotalSupply > 0 && m.Circ > 0 { + v.FloatPct = 100 * m.Circ / m.TotalSupply + } + if v.AnnualFees > 0 { + if v.Mcap > 0 { + v.PF, v.HasPF = v.Mcap/v.AnnualFees, true + } + if v.FDV > 0 { + v.PFfdv, v.HasPFfdv = v.FDV/v.AnnualFees, true + } + } + if hasRev && v.AnnualRev > 0 { + if v.Mcap > 0 { + v.PS, v.HasPS = v.Mcap/v.AnnualRev, true + } + if v.FDV > 0 { + v.PE, v.HasPE = v.FDV/v.AnnualRev, true + } + } + return v +} diff --git a/harnesses/perp-token-metrics/cmd/script/valuation_test.go b/harnesses/perp-token-metrics/cmd/script/valuation_test.go new file mode 100644 index 000000000..f99a013a0 --- /dev/null +++ b/harnesses/perp-token-metrics/cmd/script/valuation_test.go @@ -0,0 +1,226 @@ +package main + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func approx(t *testing.T, name string, got, want float64) { + t.Helper() + if math.Abs(got-want) > 1e-6*math.Max(1, math.Abs(want)) { + t.Fatalf("%s: got %v want %v", name, got, want) + } +} + +// chart builds a DeFiLlama-style totalDataChart of n daily points, latest +// last, where day i (0 = oldest) is worth f(i). +func chart(n int, f func(i int) float64) [][2]json.Number { + out := make([][2]json.Number, 0, n) + for i := 0; i < n; i++ { + t := int64(1_700_000_000 + i*86400) + out = append(out, [2]json.Number{json.Number(fmt.Sprint(t)), json.Number(fmt.Sprintf("%.2f", f(i)))}) + } + return out +} + +func TestWindowsFromChart_Cuts30_60_365(t *testing.T) { + // 400 days of $1/day, except the most recent 30 days at $2/day. + c := chart(400, func(i int) float64 { + if i >= 370 { + return 2 + } + return 1 + }) + s := windowsFromChart(2, c) + approx(t, "Sum30d", s.Sum30d, 60) + approx(t, "SumPrev30", s.SumPrev30, 30) + approx(t, "Sum1y", s.Sum1y, 60+335) + approx(t, "Avg30d", s.Avg30d, 2) + if s.Days30 != 30 { + t.Fatalf("Days30: got %d want 30", s.Days30) + } +} + +func TestWindowsFromChart_UnsortedAndShort(t *testing.T) { + c := chart(10, func(i int) float64 { return float64(i + 1) }) // 1..10 + // Shuffle: move the latest point to the front. + c = append(c[9:], c[:9]...) + s := windowsFromChart(10, c) + approx(t, "Sum30d", s.Sum30d, 55) + approx(t, "SumPrev30", s.SumPrev30, 0) + approx(t, "Sum1y", s.Sum1y, 55) +} + +func TestWindowsFromChart_ZeroDaysCountInSumNotAvg(t *testing.T) { + c := chart(30, func(i int) float64 { + if i%2 == 0 { + return 0 + } + return 10 + }) + s := windowsFromChart(0, c) + approx(t, "Sum30d", s.Sum30d, 150) + if s.Days30 != 15 { + t.Fatalf("Days30: got %d want 15", s.Days30) + } + approx(t, "Avg30d", s.Avg30d, 10) + // Annualization is on the sum, so the zero days lower the run rate. + approx(t, "annualize", annualize(s.Sum30d), 150*365/30) +} + +func TestComputeValuation_AllRatios(t *testing.T) { + fees := llamaSeries{Total24h: 20_000, Sum30d: 600_000} + rev := llamaSeries{Total24h: 15_000, Sum30d: 450_000} + m := cgMarket{Mcap: 10_000_000, FDV: 12_000_000, Circ: 25, TotalSupply: 30} + v := computeValuation(fees, rev, true, m, true) + + annualFees := 600_000.0 * 365 / 30 + annualRev := 450_000.0 * 365 / 30 + approx(t, "AnnualFees", v.AnnualFees, annualFees) + approx(t, "AnnualRev", v.AnnualRev, annualRev) + approx(t, "RevSharePct", v.RevSharePct, 75) + approx(t, "FloatPct", v.FloatPct, 100*25.0/30) + approx(t, "PF", v.PF, 10_000_000/annualFees) + approx(t, "PFfdv", v.PFfdv, 12_000_000/annualFees) + approx(t, "PS", v.PS, 10_000_000/annualRev) + approx(t, "PE", v.PE, 12_000_000/annualRev) + for name, ok := range map[string]bool{"HasPF": v.HasPF, "HasPFfdv": v.HasPFfdv, "HasPS": v.HasPS, "HasPE": v.HasPE, "HasRev": v.HasRev} { + if !ok { + t.Fatalf("%s should be true", name) + } + } +} + +func TestComputeValuation_TokenlessPublishesFeesOnly(t *testing.T) { + fees := llamaSeries{Total24h: 1, Sum30d: 30} + v := computeValuation(fees, llamaSeries{}, false, cgMarket{}, false) + approx(t, "Fees30d", v.Fees30d, 30) + if v.HasPF || v.HasPFfdv || v.HasPS || v.HasPE || v.HasRev { + t.Fatalf("no ratio may be defined without a token: %+v", v) + } + if v.Mcap != 0 || v.FDV != 0 || v.FloatPct != 0 { + t.Fatalf("market fields must stay zero without a token: %+v", v) + } +} + +func TestComputeValuation_ZeroFeesDefinesNoRatio(t *testing.T) { + v := computeValuation(llamaSeries{}, llamaSeries{}, true, cgMarket{Mcap: 1, FDV: 1}, true) + if v.HasPF || v.HasPFfdv || v.HasPS || v.HasPE { + t.Fatalf("ratios must be undefined when the denominator is zero: %+v", v) + } + if v.RevSharePct != 0 { + t.Fatalf("rev share undefined on zero fees, got %v", v.RevSharePct) + } +} + +func TestComputeValuation_NoRevenueKeepsFeeRatios(t *testing.T) { + fees := llamaSeries{Sum30d: 300} + v := computeValuation(fees, llamaSeries{}, false, cgMarket{Mcap: 3650, FDV: 7300}, true) + approx(t, "PF", v.PF, 1) + approx(t, "PFfdv", v.PFfdv, 2) + if v.HasPS || v.HasPE { + t.Fatalf("P/S and P/E need revenue: %+v", v) + } +} + +// fakeUpstream serves the three upstream shapes the harness reads. +func fakeUpstream(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/summary/fees/", func(w http.ResponseWriter, r *http.Request) { + slug := strings.TrimPrefix(r.URL.Path, "/summary/fees/") + dt := r.URL.Query().Get("dataType") + per := map[string]float64{"dailyFees": 100, "dailyRevenue": 40}[dt] + if slug == "broken" { + http.Error(w, "Not found", 404) + return + } + if slug == "empty" { + _ = json.NewEncoder(w).Encode(map[string]any{"total24h": 0, "totalDataChart": [][2]any{}}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "total24h": per, + "totalDataChart": chart(90, func(int) float64 { return per }), + }) + }) + mux.HandleFunc("/coins/markets", func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.RawQuery, "ids=") { + http.Error(w, "missing ids", 400) + return + } + fmt.Fprint(w, `[ + {"id":"tok","market_cap":1000000,"fully_diluted_valuation":2000000,"circulating_supply":50,"total_supply":100}, + {"id":"nofdv","market_cap":500000,"fully_diluted_valuation":null,"circulating_supply":null,"total_supply":null} + ]`) + }) + mux.HandleFunc("/overview/open-interest", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"protocols":[{"slug":"tok-perps","total24h":250000},{"slug":"dead","total24h":null}]}`) + }) + return httptest.NewServer(mux) +} + +func TestFetchers_AgainstFakeUpstream(t *testing.T) { + srv := fakeUpstream(t) + defer srv.Close() + oldL, oldC := llamaBase, cgBase + llamaBase, cgBase = srv.URL, srv.URL + defer func() { llamaBase, cgBase = oldL, oldC }() + + fees, ok := fetchLlamaSeries("tok", "dailyFees") + if !ok { + t.Fatal("fees fetch should succeed") + } + approx(t, "fees.Sum30d", fees.Sum30d, 3000) + approx(t, "fees.SumPrev30", fees.SumPrev30, 3000) + approx(t, "fees.Sum1y", fees.Sum1y, 9000) + + if _, ok := fetchLlamaSeries("broken", "dailyFees"); ok { + t.Fatal("404 must report !ok") + } + if _, ok := fetchLlamaSeries("empty", "dailyFees"); ok { + t.Fatal("empty chart must report !ok") + } + + m := fetchCGMarkets([]string{"tok", "nofdv", "missing"}) + if len(m) != 2 { + t.Fatalf("expected 2 market rows, got %d", len(m)) + } + approx(t, "tok.FDV", m["tok"].FDV, 2_000_000) + approx(t, "nofdv.FDV falls back to mcap", m["nofdv"].FDV, 500_000) + if _, present := m["missing"]; present { + t.Fatal("unknown id must be absent") + } + if fetchCGMarkets(nil) == nil { + t.Fatal("empty id list must return an empty map, not nil") + } + + oi := fetchLlamaOpenInterest() + approx(t, "oi tok-perps", oi["tok-perps"], 250_000) + if _, present := oi["dead"]; present { + t.Fatal("null OI must be absent") + } +} + +func TestProtocolTable_NoDuplicateSlugsOrIDs(t *testing.T) { + seen := map[string]bool{} + for _, p := range protocols { + for _, k := range []string{"slug:" + p.slug, "cg:" + p.cgID} { + if strings.HasSuffix(k, ":") { + continue + } + if seen[k] { + t.Fatalf("duplicate %s", k) + } + seen[k] = true + } + if p.llamaSlug == "" { + t.Fatalf("%s: llamaSlug required", p.slug) + } + } +} diff --git a/harnesses/perp-volume-history/Dockerfile b/harnesses/perp-volume-history/Dockerfile new file mode 100644 index 000000000..2f0838143 --- /dev/null +++ b/harnesses/perp-volume-history/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +COPY cmd ./cmd +RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o /app/harness ./cmd/script + +FROM debian:bookworm-slim +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/harness /app/harness +EXPOSE 2112 +CMD ["/app/harness"] diff --git a/harnesses/perp-volume-history/README.md b/harnesses/perp-volume-history/README.md new file mode 100644 index 000000000..661eb66e2 --- /dev/null +++ b/harnesses/perp-volume-history/README.md @@ -0,0 +1,99 @@ +# perp-volume-history + +Daily perp DEX volume per venue on closed UTC days, backfilled over a +year and refreshed hourly. Feeds bench 266 (`perp-daily-volume`), the +`/compare/-vs-` daily volume hero and the `/perp/` volume +charts. + +## Why it exists + +DeFiLlama's perp volume API is paid since 2026 (`/overview/derivatives` +and `/summary/derivatives/*` answer 402, the site is behind a Cloudflare +challenge). Its adapters are open source though +(github.com/DefiLlama/dimension-adapters), so this harness reads the +same upstream each adapter reads, on the same UTC day buckets. On +closed days the numbers match defillama.com within each venue's +restatement window, and the whole history stays free to reproduce. + +## Sources per venue + +| Venue | Upstream (what the DeFiLlama adapter reads) | Backfill | +|---|---|---| +| hyperliquid | `api.hyperliquid.xyz/info` candleSnapshot 1h for every market of the main dex, base volume x hourly mean price (3.361B vs the exchange's own 3.359B dayNtlVlm on 2026-09-13). DeFiLlama's fill indexer is private and the public `daily_usd_volume` feed stopped on 2026-04-03. | 400 d, paged 200 d | +| hyperliquid-hip3 | same, over every HIP-3 dex from `perpDexs` (xyz, io, mkts, para...). Builder-deployed markets, kept apart from the exchange's own row; DeFiLlama's single line covers them only partially. | 400 d | +| gmx | `gmx.squids.live/gmx-synthetics-{arbitrum,avalanche,botanix,megaeth}` volumeInfos 1d marginVolumeUsd x 1e-30 (perps only). Arbitrum required, others additive. | full | +| gains | `backend-global.gains.trade/api/volume-mix?from=D&to=D`, autoVolumeUsd + directVolumeUsd (opens/closes at notional, resizes at traded delta). With `DUNE_API_KEY`: the Dune view `dune.gains.result_g_trade_stats_defi_llama` (the adapter's source) is published and the backend figure feeds `perp_daily_volume_divergence_pct`. ApeChain is in neither. | 400 d at 24 req/min | +| aster | `fapi.asterdex.com/fapi/v1/klines` 1d quoteVolume per TRADING PERPETUAL symbol | 400 d | +| lighter | `/api/v1/candles` 1d `V` per perp market (market_id < 2048) on `mainnet.zklighter.elliot.ai` and `api.rh.lighter.xyz` (Robinhood chain, DeFiLlama's Lighter RH child) | 400 d | +| dydx | `indexer.dydx.trade/v4/candles/perpetualMarkets/` 1DAY usdVolume per market | 400 d, 100 candles per page | +| paradex | public Metabase card 21187, `PERP_VOLUME` per `TRADE_DATE` | full | +| extended | `api.starknet.extended.exchange/api/v1/exchange/stats/trading?fromDate=D&toDate=D` (+ `api.extended.exchange` before 2025-12-29), both sides halved | 400 d | +| orderly | `api-evm.orderly.org/md/volume/daily_stats` | full | +| aevo | `api.aevo.xyz/statistics?instrument_type=PERPETUAL&end_time=` per day | 400 d | + +`DEFILLAMA_API_KEY` switches every venue to +`pro-api.llama.fi//api/summary/derivatives/?dataType=dailyVolume`. + +Not covered yet: edgeX, GRVT, Pacifica (per-market candles, same +pattern), Vertex, Backpack, Jupiter (no public daily history), Drift and +Ostium (Dune only on DeFiLlama). + +## Outputs + +Prometheus on `:2112/metrics`: + +| Gauge | Labels | Meaning | +|---|---|---| +| `perp_daily_volume_usd` | venue, window=1d/7d/30d | last closed UTC day; 7d and 30d sums, published only when every day of the window is present | +| `perp_daily_volume_share_pct` | venue, window | share of the cohort on the window | +| `perp_daily_volume_cohort_usd` | window | cohort total | +| `perp_daily_volume_history_days` | venue | stored days (bench sample_size) | +| `perp_daily_volume_health` | venue | 1 when a closed day landed within 3 days | +| `perp_daily_volume_source` | venue, source | 1 for the upstream producing the venue | +| `perp_daily_volume_divergence_pct` | venue, secondary | secondary vs published gap over the refreshed days | +| `perp_daily_volume_last_refresh_unix`, `perp_daily_volume_fetch_errors_total`, `perp_daily_volume_last_tick_unix` | | observability | + +JSON on `:2112/v1/history[?days=N][&venue=slug]` and mirrored to +`HISTORY_FILE_PUBLIC`: + +```json +{ "generated_at": "...", "last_closed_day": "2026-09-13", + "venues": [ { "slug": "gmx", "name": "GMX V2", "source": "gmx-squid", "note": "...", + "days": [ { "day": "2025-08-11", "usd": 123.0 }, ... ] } ] } +``` + +On the OCB VPS the public file lives in `/data/state/aggregate/perp-volume/` (the directory Caddy already serves as `/aggregate/`) +and Caddy serves it at `https://kv.openchainbench.com/aggregate/perp-volume/history.json` +(the site reads that URL; override with `PERP_VOLUME_HISTORY_URL`). + +## Env + +| Var | Default | Meaning | +|---|---|---| +| `HISTORY_FILE` | `/data/perp-volume-history.json` | store (mount a volume) | +| `HISTORY_FILE_PUBLIC` | unset | where to mirror the public JSON | +| `BACKFILL_DAYS` | `400` | horizon walked back on first boot, bounded by each source's start | +| `REFRESH_DAYS` | `3` | closed days re-read every tick (sources restate D-1) | +| `TICK_MINUTES` | `60` | sweep cadence | +| `METRICS_ADDR` | `:2112` | | +| `DUNE_API_KEY`, `DUNE_SQL_QUERY_ID` | unset, `3996608` | Gains via the Dune view (one execution per sweep, free tier holds) | +| `DEFILLAMA_API_KEY` | unset | all venues via DeFiLlama Pro | + +## Run + +```bash +go run ./cmd/script # local, store in /data unless HISTORY_FILE is set +curl -s localhost:2112/metrics | grep perp_daily_volume_usd +curl -s 'localhost:2112/v1/history?days=30&venue=gains' | jq . +``` + +A first sweep with the 400 day backfill takes about 25 minutes (Aster +and Hyperliquid candle fan-out dominate); gauges from the store are +published before the sweep starts, so a restart never blanks them. +Hourly sweeps re-read three days per venue and take about 15 minutes. + +```bash +docker build -t ocb-perp-volume-history . +docker run -p 2112:2112 -v /data/state/perp-volume:/data \ + -e HISTORY_FILE_PUBLIC=/data/public/history.json ocb-perp-volume-history +``` diff --git a/harnesses/perp-volume-history/cmd/script/main.go b/harnesses/perp-volume-history/cmd/script/main.go new file mode 100644 index 000000000..8dd6410ce --- /dev/null +++ b/harnesses/perp-volume-history/cmd/script/main.go @@ -0,0 +1,276 @@ +// perp-volume-history: daily perp DEX volume per venue, UTC-day buckets, +// backfilled and kept current, the way DeFiLlama's derivatives page cuts +// it but without the paid endpoint. +// +// DeFiLlama's perp volume API went behind the paid plan in 2026 (402 on +// /overview/derivatives and /summary/derivatives/*). Its adapters are +// open source though (github.com/DefiLlama/dimension-adapters), so this +// harness reads the same upstream each adapter reads, on the same UTC +// day boundaries. With DEFILLAMA_API_KEY set it reads the Pro API +// instead and every venue becomes DeFiLlama's own number. +// +// Outputs: +// - Prometheus gauges on :2112/metrics (last closed day, 7d, 30d sums, +// cohort share, history depth, health) for bench 266. +// - JSON history on :2112/v1/history (all venues, every stored day) +// for the site's compare hero and venue pages. The same document is +// mirrored to HISTORY_FILE_PUBLIC when set, so Caddy can serve it as +// a static file. +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "strconv" + "sync" + "syscall" + "time" +) + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +func main() { + fmt.Println("=== perp-volume-history harness ===") + fmt.Println("OpenChainBench #266: daily perp DEX volume, UTC days, backfilled") + + storePath := env("HISTORY_FILE", "/data/perp-volume-history.json") + publicPath := env("HISTORY_FILE_PUBLIC", "") + backfillDays := envInt("BACKFILL_DAYS", 400) + refreshDays := envInt("REFRESH_DAYS", 3) + tick := time.Duration(envInt("TICK_MINUTES", 60)) * time.Minute + llamaKey := os.Getenv("DEFILLAMA_API_KEY") + + store, err := openStore(storePath) + if err != nil { + fmt.Fprintf(os.Stderr, "store: %v\n", err) + os.Exit(1) + } + fmt.Printf("store: %s (%d venues, %d day-points)\n", storePath, store.venueCount(), store.pointCount()) + + sources := buildSources(llamaKey) + currentSources = sources + fmt.Printf("sources: %d venues | backfill %dd | refresh last %dd | tick %s | defillama pro: %v\n", + len(sources), backfillDays, refreshDays, tick, llamaKey != "") + + go func() { + if err := startServer(env("METRICS_ADDR", ":2112"), store); err != nil { + fmt.Fprintf(os.Stderr, "http server: %v\n", err) + os.Exit(1) + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Publish whatever the store already holds before the first sweep so + // a restart never blanks the gauges while a slow source backfills. + publish(store, sources) + go func() { + if publicPath != "" { + _ = store.writePublic(publicPath, sources) + } + }() + + go runLoop(ctx, store, sources, backfillDays, refreshDays, tick, publicPath) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + fmt.Println("shutting down") + cancel() + _ = store.flush() +} + +// runLoop backfills every venue that has less history than requested, +// then on every tick re-reads the last refreshDays closed days (sources +// restate D-1 for a day or two) and publishes the gauges. +func runLoop(ctx context.Context, store *Store, sources []Source, backfillDays, refreshDays int, tick time.Duration, publicPath string) { + for { + start := time.Now() + today := utcDay(time.Now()) + lastClosed := today.AddDate(0, 0, -1) + + // Every source runs in its own goroutine: they are independent + // HTTP fan-outs and one slow or rate-limited venue (dYdX on + // 2026-09-14) must not hold the others back. The store and the + // gauges are mutex-safe; each venue flushes and publishes as soon + // as it lands. + var wg sync.WaitGroup + for _, src := range sources { + if ctx.Err() != nil { + return + } + from := lastClosed.AddDate(0, 0, -(refreshDays - 1)) + // A venue that has no data yet, or whose oldest stored day is + // younger than the backfill horizon, is walked back to the + // horizon (bounded by the source's own start date). + horizon := today.AddDate(0, 0, -backfillDays) + if s := src.Start(); s.After(horizon) { + horizon = s + } + backfill := false + if oldest, ok := store.oldest(src.Slug()); (!ok || oldest.After(horizon)) && !store.backfilledTo(src.Slug(), horizon) { + from = horizon + backfill = true + } + wg.Add(1) + go func(src Source, from, horizon time.Time, backfill bool) { + defer wg.Done() + if runSource(ctx, store, src, from, lastClosed) && backfill { + store.markBackfilled(src.Slug(), horizon) + } + if err := store.flush(); err != nil { + fmt.Fprintf(os.Stderr, "store flush: %v\n", err) + } + publish(store, sources) + }(src, from, horizon, backfill) + } + wg.Wait() + + publish(store, sources) + if err := store.flush(); err != nil { + fmt.Fprintf(os.Stderr, "store flush: %v\n", err) + } + if publicPath != "" { + if err := store.writePublic(publicPath, sources); err != nil { + fmt.Fprintf(os.Stderr, "public write: %v\n", err) + } + } + lastTick.Set(float64(time.Now().Unix())) + fmt.Printf("[sweep] done in %s\n", time.Since(start).Round(time.Second)) + + select { + case <-ctx.Done(): + return + case <-time.After(tick): + } + } +} + +// runSource fetches one venue over [from, to] and stores what came +// back. It reports whether the fetch succeeded (a zero-day success still +// counts: the source simply has no history that far back). +func runSource(ctx context.Context, store *Store, src Source, from, to time.Time) bool { + t0 := time.Now() + points, err := src.Daily(ctx, from, to) + if err != nil { + fetchErrors.WithLabelValues(src.Slug(), src.Name()).Inc() + fmt.Printf("[%s][%s] %s..%s err: %v\n", src.Slug(), src.Name(), fmtDay(from), fmtDay(to), err) + return false + } + n := 0 + for day, usd := range points { + if usd < 0 { + continue + } + store.set(src.Slug(), day, usd, src.Name()) + n++ + } + lastRefresh.WithLabelValues(src.Slug(), src.Name()).Set(float64(time.Now().Unix())) + fmt.Printf("[%s][%s] %s..%s ok: %d days in %s\n", src.Slug(), src.Name(), fmtDay(from), fmtDay(to), n, time.Since(t0).Round(time.Millisecond)) + return true +} + +// publish derives the bench gauges from the store: last closed UTC day, +// trailing 7 and 30 closed days, share of the cohort on each window, and +// how many days of history each venue holds. +func publish(store *Store, sources []Source) { + lastClosed := utcDay(time.Now()).AddDate(0, 0, -1) + type agg struct{ d1, d7, d30 float64 } + per := map[string]agg{} + var cohort agg + for _, src := range sources { + slug := src.Slug() + d1, ok1 := store.get(slug, lastClosed) + d7, n7 := store.sum(slug, lastClosed.AddDate(0, 0, -6), lastClosed) + d30, n30 := store.sum(slug, lastClosed.AddDate(0, 0, -29), lastClosed) + days := store.days(slug) + + historyDays.WithLabelValues(slug).Set(float64(days)) + if ok1 { + volume.WithLabelValues(slug, "1d").Set(d1) + health.WithLabelValues(slug).Set(1) + } else { + volume.DeleteLabelValues(slug, "1d") + // A venue whose last closed day is missing is late, not down: + // the D-1 row usually lands within hours. Health drops only + // when nothing arrived for three days. + if _, ok := store.get(slug, lastClosed.AddDate(0, 0, -3)); ok { + health.WithLabelValues(slug).Set(1) + } else { + health.WithLabelValues(slug).Set(0) + } + } + // A window is published only when every day inside it is present, + // so a partial backfill never reads as a low week. + if n7 == 7 { + volume.WithLabelValues(slug, "7d").Set(d7) + } else { + volume.DeleteLabelValues(slug, "7d") + } + if n30 == 30 { + volume.WithLabelValues(slug, "30d").Set(d30) + } else { + volume.DeleteLabelValues(slug, "30d") + } + a := agg{} + if ok1 { + a.d1 = d1 + } + if n7 == 7 { + a.d7 = d7 + } + if n30 == 30 { + a.d30 = d30 + } + per[slug] = a + cohort.d1 += a.d1 + cohort.d7 += a.d7 + cohort.d30 += a.d30 + sourceUsed.WithLabelValues(slug, src.Name()).Set(1) + } + for slug, a := range per { + setShare(slug, "1d", a.d1, cohort.d1) + setShare(slug, "7d", a.d7, cohort.d7) + setShare(slug, "30d", a.d30, cohort.d30) + } + cohortVolume.WithLabelValues("1d").Set(cohort.d1) + cohortVolume.WithLabelValues("7d").Set(cohort.d7) + cohortVolume.WithLabelValues("30d").Set(cohort.d30) +} + +func setShare(slug, window string, v, total float64) { + if v > 0 && total > 0 { + share.WithLabelValues(slug, window).Set(v / total * 100) + } else { + share.DeleteLabelValues(slug, window) + } +} + +// utcDay truncates t to midnight UTC. +func utcDay(t time.Time) time.Time { + t = t.UTC() + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) +} + +func fmtDay(t time.Time) string { return t.UTC().Format("2006-01-02") } + +func parseDay(s string) (time.Time, error) { + return time.Parse("2006-01-02", s) +} diff --git a/harnesses/perp-volume-history/cmd/script/server.go b/harnesses/perp-volume-history/cmd/script/server.go new file mode 100644 index 000000000..675047f46 --- /dev/null +++ b/harnesses/perp-volume-history/cmd/script/server.go @@ -0,0 +1,110 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + // Bench 266 gauges. window is 1d (last closed UTC day), 7d or 30d + // (sums over closed days, published only when every day is present). + volume = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_daily_volume_usd", + Help: "Perp DEX notional volume in USD over closed UTC days: window=1d is the last closed day, 7d and 30d are sums.", + }, []string{"venue", "window"}) + share = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_daily_volume_share_pct", + Help: "Venue share of the cohort's volume on the window, in percent.", + }, []string{"venue", "window"}) + cohortVolume = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_daily_volume_cohort_usd", + Help: "Sum of the cohort's volume on the window, in USD.", + }, []string{"window"}) + historyDays = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_daily_volume_history_days", + Help: "Number of UTC days stored for the venue (backfill depth).", + }, []string{"venue"}) + health = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_daily_volume_health", + Help: "1 when the venue has a closed-day figure within the last three days.", + }, []string{"venue"}) + sourceUsed = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_daily_volume_source", + Help: "1 for the upstream that produces the venue's published series.", + }, []string{"venue", "source"}) + divergence = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_daily_volume_divergence_pct", + Help: "Percent gap between a secondary source and the published one over the refreshed days (secondary minus published, over published).", + }, []string{"venue", "secondary"}) + lastRefresh = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_daily_volume_last_refresh_unix", + Help: "Unix time of the last successful fetch per venue and source.", + }, []string{"venue", "source"}) + fetchErrors = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "perp_daily_volume_fetch_errors_total", + Help: "Fetch failures per venue and source.", + }, []string{"venue", "source"}) + lastTick = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "perp_daily_volume_last_tick_unix", + Help: "Unix time of the last completed sweep.", + }) +) + +func init() { + prometheus.MustRegister(volume, share, cohortVolume, historyDays, health, sourceUsed, divergence, lastRefresh, fetchErrors, lastTick) +} + +// startServer serves /metrics for the shared Prometheus and /v1/history +// for the site. The history document is small (a few hundred KB) and +// rebuilt on each request from the in-memory store; the site caches it +// for minutes, and Caddy can serve the mirrored file instead. +func startServer(addr string, store *Store) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "venues": store.venueCount(), + "points": store.pointCount(), + "time": time.Now().UTC().Format(time.RFC3339), + }) + }) + mux.HandleFunc("/v1/history", func(w http.ResponseWriter, r *http.Request) { + doc := store.publicDoc(currentSources) + if days, err := strconv.Atoi(r.URL.Query().Get("days")); err == nil && days > 0 { + cut := fmtDay(utcDay(time.Now()).AddDate(0, 0, -days)) + for i := range doc.Venues { + pts := doc.Venues[i].Days + j := 0 + for j < len(pts) && pts[j].Day < cut { + j++ + } + doc.Venues[i].Days = pts[j:] + } + } + if v := r.URL.Query().Get("venue"); v != "" { + kept := doc.Venues[:0] + for _, pv := range doc.Venues { + if pv.Slug == v { + kept = append(kept, pv) + } + } + doc.Venues = kept + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=900") + _ = json.NewEncoder(w).Encode(doc) + }) + return http.ListenAndServe(addr, mux) +} + +// currentSources is set once at boot so the HTTP handler can label the +// history document without threading the slice through the server. +var currentSources []Source diff --git a/harnesses/perp-volume-history/cmd/script/source_gmx_gains_hl.go b/harnesses/perp-volume-history/cmd/script/source_gmx_gains_hl.go new file mode 100644 index 000000000..d1beff4ee --- /dev/null +++ b/harnesses/perp-volume-history/cmd/script/source_gmx_gains_hl.go @@ -0,0 +1,482 @@ +package main + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// --------------------------------------------------------------------- +// GMX V2 (dimension-adapters/dexs/gmx-v2-gmx-v2-trade.ts) +// --------------------------------------------------------------------- + +// gmxSource reads the GMX synthetics squids, one per chain: +// volumeInfos(period "1d").marginVolumeUsd scaled by 1e-30, summed over +// chains. marginVolumeUsd is perps only (swaps are a separate field), so +// this is the "GMX V2 Perps" line on DeFiLlama. Arbitrum is required; +// the other chains are additive and a squid that 404s (Botanix did on +// 2026-09-14) is skipped for that sweep rather than failing the venue. +type gmxSource struct{ meta venueMeta } + +var gmxSquids = []struct{ chain, url string }{ + {"arbitrum", "https://gmx.squids.live/gmx-synthetics-arbitrum:prod/api/graphql"}, + {"avalanche", "https://gmx.squids.live/gmx-synthetics-avalanche:prod/api/graphql"}, + {"botanix", "https://gmx.squids.live/gmx-synthetics-botanix:prod/api/graphql"}, + {"megaeth", "https://gmx.squids.live/gmx-synthetics-megaeth:prod/api/graphql"}, +} + +func (s *gmxSource) Slug() string { return s.meta.slug } +func (s *gmxSource) DisplayName() string { return s.meta.name } +func (s *gmxSource) Name() string { return "gmx-squid" } +func (s *gmxSource) Note() string { + return "GMX V2 synthetics squids, marginVolumeUsd (perps only, swaps excluded), Arbitrum + Avalanche + Botanix + MegaETH" +} +func (s *gmxSource) Start() time.Time { return s.meta.start } + +func (s *gmxSource) Daily(ctx context.Context, from, to time.Time) (map[time.Time]float64, error) { + days := int(to.Sub(from).Hours()/24) + 1 + limit := days + 5 + if limit > 2000 { + limit = 2000 + } + q := fmt.Sprintf(`{ volumeInfos(where: {period_eq: "1d"}, orderBy: timestamp_DESC, limit: %d) { timestamp marginVolumeUsd } }`, limit) + out := map[time.Time]float64{} + gotArbitrum := false + for _, sq := range gmxSquids { + var resp struct { + Data struct { + VolumeInfos []struct { + Timestamp int64 `json:"timestamp"` + MarginVolumeUsd string `json:"marginVolumeUsd"` + } `json:"volumeInfos"` + } `json:"data"` + } + if err := postJSON(ctx, sq.url, map[string]string{"query": q}, &resp); err != nil { + if sq.chain == "arbitrum" { + return nil, fmt.Errorf("arbitrum squid: %w", err) + } + fmt.Printf("[gmx][%s] skipped: %v\n", sq.chain, err) + continue + } + if sq.chain == "arbitrum" { + gotArbitrum = true + } + for _, r := range resp.Data.VolumeInfos { + d := utcDay(time.Unix(r.Timestamp, 0)) + if !inRange(d, from, to) { + continue + } + out[d] += scale1e30(r.MarginVolumeUsd) + } + } + if !gotArbitrum { + return nil, fmt.Errorf("arbitrum squid returned nothing") + } + return out, nil +} + +// scale1e30 converts a 30-decimals fixed-point decimal string to a +// float64 in USD without going through big.Int: the integer part of +// a USD amount fits comfortably in float64 precision here. +func scale1e30(s string) float64 { + s = strings.TrimSpace(s) + neg := strings.HasPrefix(s, "-") + s = strings.TrimPrefix(s, "-") + if len(s) <= 30 { + s = strings.Repeat("0", 31-len(s)) + s + } + whole := s[:len(s)-30] + frac := s[len(s)-30:] + if len(frac) > 6 { + frac = frac[:6] + } + v, err := strconv.ParseFloat(whole+"."+frac, 64) + if err != nil { + return 0 + } + if neg { + return -v + } + return v +} + +// --------------------------------------------------------------------- +// Gains (dimension-adapters/dexs/gains-network/index.ts) +// --------------------------------------------------------------------- + +// gainsSource has two upstreams for the same number: +// +// - The Dune view dune.gains.result_g_trade_stats_defi_llama, which is +// what DeFiLlama's adapter reads (Arbitrum, Polygon, Base, MegaETH; +// ApeChain is added by DeFiLlama from events and is not in the view). +// Used when DUNE_API_KEY is set. One execution returns the whole +// history, so a sweep costs one query. +// - The Gains backend /api/volume-mix?from=D&to=D, autoVolumeUsd + +// directVolumeUsd: opens and closes at full notional, resizes at +// their traded delta, gov-only rows excluded. That is the trade +// perimeter of the Dune view. Same chains minus ApeChain (the +// backend reports 33139 in missingChainIds). One call per day, +// rate limited to 30/min upstream. +// +// With the key set, the Dune figure is published and the backend figure +// is kept for the divergence gauge; without it the backend figure is +// published. +// +// Dune executions cost credits (about 10 per run on the free tier's +// 2,500 a month), so the view is queried at most every +// DUNE_MIN_INTERVAL_HOURS (default 12, about 60 runs a month). In +// between, the backend fills only the days the view has not produced +// yet (a fresh D-1), so a day never flips between the two conventions. +type gainsSource struct { + meta venueMeta + duneKey string + duneQID string + duneInterval time.Duration + lastDune time.Time + duneDays map[time.Time]bool + throttle *throttle +} + +func newGainsSource(v venueMeta) *gainsSource { + return &gainsSource{ + meta: v, + duneKey: os.Getenv("DUNE_API_KEY"), + duneQID: env("DUNE_SQL_QUERY_ID", "3996608"), + duneInterval: time.Duration(envInt("DUNE_MIN_INTERVAL_HOURS", 12)) * time.Hour, + duneDays: map[time.Time]bool{}, + throttle: newThrottle(24), + } +} + +func (s *gainsSource) Slug() string { return s.meta.slug } +func (s *gainsSource) DisplayName() string { return s.meta.name } +func (s *gainsSource) Name() string { + if s.duneKey != "" { + return "gains-dune-view" + } + return "gains-backend" +} +func (s *gainsSource) Note() string { + if s.duneKey != "" { + return "Dune view dune.gains.result_g_trade_stats_defi_llama (the DeFiLlama adapter's source): Arbitrum, Polygon, Base, MegaETH; ApeChain not included" + } + return "Gains backend volume-mix, auto + direct notional (resizes at traded delta): Arbitrum, Polygon, Base, MegaETH; ApeChain not included" +} +func (s *gainsSource) Start() time.Time { return s.meta.start } + +func (s *gainsSource) Daily(ctx context.Context, from, to time.Time) (map[time.Time]float64, error) { + backend, backendErr := s.dailyBackend(ctx, from, to) + if s.duneKey == "" { + return backend, backendErr + } + if !s.lastDune.IsZero() && time.Since(s.lastDune) < s.duneInterval { + // Dune not due: publish the backend figure only for days the + // view has not covered yet. + out := map[time.Time]float64{} + for d, v := range backend { + if !s.duneDays[d] { + out[d] = v + } + } + return out, backendErr + } + dune, err := s.dailyDune(ctx, from, to) + if err != nil { + fmt.Printf("[gains][dune] err: %v (publishing backend figure)\n", err) + return backend, backendErr + } + s.lastDune = time.Now() + for d := range dune { + s.duneDays[d] = true + } + if backendErr == nil { + var sumD, sumB float64 + for d, v := range dune { + if b, ok := backend[d]; ok { + sumD += v + sumB += b + } + } + if sumD > 0 && sumB > 0 { + divergence.WithLabelValues(s.meta.slug, "gains-backend").Set((sumB - sumD) / sumD * 100) + } + } + return dune, nil +} + +func (s *gainsSource) dailyBackend(ctx context.Context, from, to time.Time) (map[time.Time]float64, error) { + out := map[time.Time]float64{} + var firstErr error + err := eachDay(ctx, from, to, func(d time.Time) error { + if err := s.throttle.wait(ctx); err != nil { + return err + } + var resp struct { + AutoVolumeUsd float64 `json:"autoVolumeUsd"` + DirectVolumeUsd float64 `json:"directVolumeUsd"` + TotalVolumeUsd float64 `json:"totalVolumeUsd"` + LastTradeTS int64 `json:"lastTradeTimestamp"` + } + url := fmt.Sprintf("https://backend-global.gains.trade/api/volume-mix?from=%s&to=%s", fmtDay(d), fmtDay(d)) + if err := getJSON(ctx, url, &resp); err != nil { + if firstErr == nil { + firstErr = err + } + fmt.Printf("[gains][backend] %s err: %v\n", fmtDay(d), err) + return nil + } + out[d] = resp.AutoVolumeUsd + resp.DirectVolumeUsd + return nil + }) + if err != nil { + return out, err + } + if len(out) == 0 && firstErr != nil { + return nil, firstErr + } + return out, nil +} + +func (s *gainsSource) dailyDune(ctx context.Context, from, to time.Time) (map[time.Time]float64, error) { + sql := fmt.Sprintf(`select unix_ts, blockchain, daily_volume +from dune.gains.result_g_trade_stats_defi_llama +where day >= date '%s' and day <= date '%s' +order by unix_ts, blockchain`, fmtDay(from), fmtDay(to)) + rows, err := runDuneSQL(ctx, s.duneKey, s.duneQID, sql) + if err != nil { + return nil, err + } + out := map[time.Time]float64{} + for _, r := range rows { + ts, _ := toFloat(r["unix_ts"]) + usd, _ := toFloat(r["daily_volume"]) + if ts == 0 { + continue + } + d := utcDay(time.Unix(int64(ts), 0)) + if inRange(d, from, to) { + out[d] += usd + } + } + if len(out) == 0 { + return nil, fmt.Errorf("dune view returned no rows") + } + return out, nil +} + +// runDuneSQL executes arbitrary SQL through a saved Dune query that +// forwards its fullQuery parameter (the public query 3996608 does), then +// polls the execution and pages the results. +func runDuneSQL(ctx context.Context, key, queryID, sql string) ([]map[string]any, error) { + const api = "https://api.dune.com/api/v1" + hdr := map[string]string{"X-Dune-API-Key": key} + var started struct { + ExecutionID string `json:"execution_id"` + } + body := map[string]any{"query_parameters": map[string]string{"fullQuery": sql}} + if err := doJSONHeaders(ctx, "POST", api+"/query/"+queryID+"/execute", body, hdr, &started); err != nil { + return nil, fmt.Errorf("execute: %w", err) + } + if started.ExecutionID == "" { + return nil, fmt.Errorf("execute: no execution_id") + } + deadline := time.Now().Add(4 * time.Minute) + for { + var st struct { + State string `json:"state"` + } + if err := doJSONHeaders(ctx, "GET", api+"/execution/"+started.ExecutionID+"/status", nil, hdr, &st); err != nil { + return nil, fmt.Errorf("status: %w", err) + } + if st.State == "QUERY_STATE_COMPLETED" { + break + } + if st.State == "QUERY_STATE_FAILED" || st.State == "QUERY_STATE_CANCELLED" || st.State == "QUERY_STATE_EXPIRED" { + return nil, fmt.Errorf("execution %s", st.State) + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("execution still %s after 4m", st.State) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(3 * time.Second): + } + } + var rows []map[string]any + for offset := 0; ; offset += 1000 { + var page struct { + Result struct { + Rows []map[string]any `json:"rows"` + } `json:"result"` + } + url := fmt.Sprintf("%s/execution/%s/results?limit=1000&offset=%d", api, started.ExecutionID, offset) + if err := doJSONHeaders(ctx, "GET", url, nil, hdr, &page); err != nil { + return nil, fmt.Errorf("results: %w", err) + } + rows = append(rows, page.Result.Rows...) + if len(page.Result.Rows) < 1000 { + break + } + } + return rows, nil +} + +func toFloat(v any) (float64, bool) { + switch x := v.(type) { + case float64: + return x, true + case int64: + return float64(x), true + case string: + f, err := strconv.ParseFloat(x, 64) + return f, err == nil + } + return 0, false +} + +// --------------------------------------------------------------------- +// Hyperliquid (dimension-adapters/dexs/hyperliquid-perp) +// --------------------------------------------------------------------- + +// hyperliquidSource rebuilds daily perp notional from the public info +// API: candleSnapshot 1h for every market of one perp dex, notional per +// hour = base volume x hourly mean price (o+h+l+c)/4, summed per UTC +// day. Verified against Hyperliquid's own dayNtlVlm at UTC midnight on +// 2026-09-13: 3.361B from candles vs 3.359B native (0.06%). +// +// The venue is split in two rows on purpose: +// - hyperliquid: the main dex (dex ""), Hyperliquid's own markets, +// the figure the exchange reports as its 24h notional. +// - hyperliquid-hip3: every HIP-3 dex from perpDexs (xyz, flx, km, +// io...), markets deployed by third parties on the same chain. +// xyz alone printed 1.2B on a Sunday in 2026-09. +// +// DeFiLlama's hyperliquid-perps line sits between the two (4.245B vs +// 3.36B main and 4.58B main + HIP-3 on 2026-09-13): its private indexer +// covers HIP-3 only partially, so neither row will match it exactly; +// the main row matches Hyperliquid itself. +// +// candleSnapshot returns at most 5000 candles, so a long backfill pages +// by 200 days. DeFiLlama's old public feed (cloudfront daily_usd_volume) +// stopped on 2026-04-03 and is not used. +type hyperliquidSource struct { + meta venueMeta + hip3 bool +} + +func (s *hyperliquidSource) Slug() string { return s.meta.slug } +func (s *hyperliquidSource) DisplayName() string { return s.meta.name } +func (s *hyperliquidSource) Name() string { return "hl-info-candles" } +func (s *hyperliquidSource) Note() string { + if s.hip3 { + return "Hyperliquid info candleSnapshot 1h over every HIP-3 dex (perpDexs), base volume x hourly mean price; builder-deployed markets, not Hyperliquid's own" + } + return "Hyperliquid info candleSnapshot 1h over the main dex, base volume x hourly mean price; matches the exchange's own dayNtlVlm, HIP-3 dexs published as a separate row" +} +func (s *hyperliquidSource) Start() time.Time { return s.meta.start } + +const hlInfo = "https://api.hyperliquid.xyz/info" + +func (s *hyperliquidSource) coins(ctx context.Context) ([]string, error) { + var out []string + if !s.hip3 { + var meta struct { + Universe []struct { + Name string `json:"name"` + } `json:"universe"` + } + if err := postJSON(ctx, hlInfo, map[string]string{"type": "meta"}, &meta); err != nil { + return nil, fmt.Errorf("meta: %w", err) + } + for _, u := range meta.Universe { + out = append(out, u.Name) + } + return out, nil + } + var dexs []*struct { + Name string `json:"name"` + } + if err := postJSON(ctx, hlInfo, map[string]string{"type": "perpDexs"}, &dexs); err != nil { + return nil, fmt.Errorf("perpDexs: %w", err) + } + for _, dx := range dexs { + if dx == nil || dx.Name == "" { + continue + } + var meta struct { + Universe []struct { + Name string `json:"name"` + } `json:"universe"` + } + if err := postJSON(ctx, hlInfo, map[string]string{"type": "meta", "dex": dx.Name}, &meta); err != nil { + fmt.Printf("[hyperliquid-hip3] dex %s meta skipped: %v\n", dx.Name, err) + continue + } + for _, u := range meta.Universe { + out = append(out, u.Name) + } + } + return out, nil +} + +func (s *hyperliquidSource) Daily(ctx context.Context, from, to time.Time) (map[time.Time]float64, error) { + coins, err := s.coins(ctx) + if err != nil { + return nil, err + } + out := map[time.Time]float64{} + th := newThrottle(90) + failed := 0 + for _, c := range coins { + // Page by 200 days: 4800 hourly candles per request. + for pageEnd := to.AddDate(0, 0, 1); pageEnd.After(from); pageEnd = pageEnd.AddDate(0, 0, -200) { + pageStart := pageEnd.AddDate(0, 0, -200) + if pageStart.Before(from) { + pageStart = from + } + if err := th.wait(ctx); err != nil { + return out, err + } + var candles []struct { + T int64 `json:"t"` + O string `json:"o"` + H string `json:"h"` + L string `json:"l"` + C string `json:"c"` + V string `json:"v"` + } + req := map[string]any{"type": "candleSnapshot", "req": map[string]any{ + "coin": c, "interval": "1h", + "startTime": pageStart.UnixMilli(), "endTime": pageEnd.UnixMilli() - 1, + }} + if err := postJSON(ctx, hlInfo, req, &candles); err != nil { + failed++ + break + } + for _, k := range candles { + d := utcDay(time.UnixMilli(k.T)) + if !inRange(d, from, to) { + continue + } + o, _ := strconv.ParseFloat(k.O, 64) + h, _ := strconv.ParseFloat(k.H, 64) + l, _ := strconv.ParseFloat(k.L, 64) + cl, _ := strconv.ParseFloat(k.C, 64) + v, _ := strconv.ParseFloat(k.V, 64) + out[d] += v * (o + h + l + cl) / 4 + } + if len(candles) == 0 { + // No candles this page: the market did not exist yet. + break + } + } + } + if len(coins) > 0 && failed > len(coins)/2 { + return nil, fmt.Errorf("%d of %d markets failed", failed, len(coins)) + } + return out, nil +} diff --git a/harnesses/perp-volume-history/cmd/script/source_venues.go b/harnesses/perp-volume-history/cmd/script/source_venues.go new file mode 100644 index 000000000..08c051379 --- /dev/null +++ b/harnesses/perp-volume-history/cmd/script/source_venues.go @@ -0,0 +1,494 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/url" + "strconv" + "time" +) + +// --------------------------------------------------------------------- +// Orderly (dimension-adapters/dexs/orderly-perps-new.ts) +// --------------------------------------------------------------------- + +// orderlySource reads api-evm.orderly.org/md/volume/daily_stats: one row +// per UTC day since 2023-10-26 with the day's perp volume in USD. +type orderlySource struct{ meta venueMeta } + +func (s *orderlySource) Slug() string { return s.meta.slug } +func (s *orderlySource) DisplayName() string { return s.meta.name } +func (s *orderlySource) Name() string { return "orderly-daily-stats" } +func (s *orderlySource) Note() string { return "Orderly Network md/volume/daily_stats, all chains" } +func (s *orderlySource) Start() time.Time { return s.meta.start } + +func (s *orderlySource) Daily(ctx context.Context, from, to time.Time) (map[time.Time]float64, error) { + var rows []struct { + Volume string `json:"volume"` + Date string `json:"date"` + } + if err := getJSON(ctx, "https://api-evm.orderly.org/md/volume/daily_stats", &rows); err != nil { + return nil, err + } + out := map[time.Time]float64{} + for _, r := range rows { + if len(r.Date) < 10 { + continue + } + d, err := parseDay(r.Date[:10]) + if err != nil || !inRange(d, from, to) { + continue + } + v, err := strconv.ParseFloat(r.Volume, 64) + if err != nil { + continue + } + out[d] = v + } + return out, nil +} + +// --------------------------------------------------------------------- +// Paradex (dimension-adapters/dexs/paradex/index.ts) +// --------------------------------------------------------------------- + +// paradexSource reads the public Metabase card Paradex publishes (the +// one the DeFiLlama adapter reads): TRADE_DATE, PERP_VOLUME, ... one row +// per UTC day. Metabase answers 202 with the body on cold cache; the +// JSON helper treats any 2xx as success. +type paradexSource struct{ meta venueMeta } + +const paradexDailyURL = "https://tradeparadigm.metabaseapp.com/api/public/dashboard/e4d7b84d-f95f-48eb-b7a6-141b3dcef4e2/dashcard/20065/card/21187?parameters=%5B%5D" + +func (s *paradexSource) Slug() string { return s.meta.slug } +func (s *paradexSource) DisplayName() string { return s.meta.name } +func (s *paradexSource) Name() string { return "paradex-metabase" } +func (s *paradexSource) Note() string { + return "Paradex public Metabase daily card, PERP_VOLUME column (perps only, options and spot excluded)" +} +func (s *paradexSource) Start() time.Time { return s.meta.start } + +func (s *paradexSource) Daily(ctx context.Context, from, to time.Time) (map[time.Time]float64, error) { + var resp struct { + Data struct { + Cols []struct { + Name string `json:"name"` + } `json:"cols"` + Rows [][]any `json:"rows"` + } `json:"data"` + } + if err := getJSON(ctx, paradexDailyURL, &resp); err != nil { + return nil, err + } + dateIdx, volIdx := -1, -1 + for i, c := range resp.Data.Cols { + switch c.Name { + case "TRADE_DATE": + dateIdx = i + case "PERP_VOLUME": + volIdx = i + } + } + if dateIdx < 0 || volIdx < 0 { + return nil, fmt.Errorf("card columns changed: %v", resp.Data.Cols) + } + out := map[time.Time]float64{} + for _, r := range resp.Data.Rows { + if len(r) <= dateIdx || len(r) <= volIdx { + continue + } + ds, _ := r[dateIdx].(string) + if len(ds) < 10 { + continue + } + d, err := parseDay(ds[:10]) + if err != nil || !inRange(d, from, to) { + continue + } + v, ok := toFloat(r[volIdx]) + if !ok { + continue + } + out[d] = v + } + return out, nil +} + +// --------------------------------------------------------------------- +// Extended (dimension-adapters/dexs/extended/index.ts) +// --------------------------------------------------------------------- + +// extendedSource reads /api/v1/exchange/stats/trading?fromDate=D&toDate=D +// on the Starknet deployment (and the Ethereum one until it went dead +// on 2025-12-29). Rows are per market and count both sides of each +// trade, hence the /2, as in the adapter. +// +// The Ethereum host answers 504 since the deployment shut down: a host +// that fails three days in a row is skipped for the rest of the sweep, +// so a dead deployment costs seconds, not a retry ladder per day. +type extendedSource struct{ meta venueMeta } + +var extendedEthDeadFrom = day("2025-12-29") + +func (s *extendedSource) Slug() string { return s.meta.slug } +func (s *extendedSource) DisplayName() string { return s.meta.name } +func (s *extendedSource) Name() string { return "extended-stats" } +func (s *extendedSource) Note() string { + return "Extended exchange/stats/trading per day, Starknet (Ethereum deployment until 2025-12-29), both sides halved" +} +func (s *extendedSource) Start() time.Time { return s.meta.start } + +func (s *extendedSource) Daily(ctx context.Context, from, to time.Time) (map[time.Time]float64, error) { + out := map[time.Time]float64{} + var firstErr error + consecutive := map[string]int{} + err := eachDay(ctx, from, to, func(d time.Time) error { + hosts := []string{"https://api.starknet.extended.exchange"} + if d.Before(extendedEthDeadFrom) { + hosts = append(hosts, "https://api.extended.exchange") + } + var total float64 + got := false + for _, h := range hosts { + if consecutive[h] >= 3 { + continue + } + var resp struct { + Data []struct { + TradingVolume string `json:"tradingVolume"` + } `json:"data"` + } + u := fmt.Sprintf("%s/api/v1/exchange/stats/trading?fromDate=%s&toDate=%s", h, fmtDay(d), fmtDay(d)) + if err := getJSON(ctx, u, &resp); err != nil { + if firstErr == nil { + firstErr = err + } + consecutive[h]++ + if consecutive[h] == 3 { + fmt.Printf("[extended] %s failed 3 days in a row, skipped for this sweep: %v\n", h, err) + } + continue + } + consecutive[h] = 0 + got = true + for _, r := range resp.Data { + v, _ := strconv.ParseFloat(r.TradingVolume, 64) + total += v + } + } + if got { + out[d] = total / 2 + } + return nil + }) + if err != nil { + return out, err + } + if len(out) == 0 && firstErr != nil { + return nil, firstErr + } + return out, nil +} + +// --------------------------------------------------------------------- +// Aevo (dimension-adapters/dexs/aevo/index.ts) +// --------------------------------------------------------------------- + +// aevoSource reads /statistics?instrument_type=PERPETUAL&end_time= +// where end_time is the start of the next UTC day in nanoseconds; the +// response's daily_volume is the 24h ending there. +type aevoSource struct{ meta venueMeta } + +func (s *aevoSource) Slug() string { return s.meta.slug } +func (s *aevoSource) DisplayName() string { return s.meta.name } +func (s *aevoSource) Name() string { return "aevo-statistics" } +func (s *aevoSource) Note() string { return "Aevo /statistics PERPETUAL daily_volume per UTC day" } +func (s *aevoSource) Start() time.Time { return s.meta.start } + +func (s *aevoSource) Daily(ctx context.Context, from, to time.Time) (map[time.Time]float64, error) { + out := map[time.Time]float64{} + var firstErr error + err := eachDay(ctx, from, to, func(d time.Time) error { + endNs := d.AddDate(0, 0, 1).Unix() * 1_000_000_000 + var resp struct { + DailyVolume string `json:"daily_volume"` + } + u := fmt.Sprintf("https://api.aevo.xyz/statistics?instrument_type=PERPETUAL&end_time=%d", endNs) + if err := getJSON(ctx, u, &resp); err != nil { + if firstErr == nil { + firstErr = err + } + return nil + } + v, err := strconv.ParseFloat(resp.DailyVolume, 64) + if err != nil { + return nil + } + out[d] = v + return nil + }) + if err != nil { + return out, err + } + if len(out) == 0 && firstErr != nil { + return nil, firstErr + } + return out, nil +} + +// --------------------------------------------------------------------- +// dYdX v4 (dimension-adapters/dexs/dydx-v4) +// --------------------------------------------------------------------- + +// dydxSource sums 1DAY candles (usdVolume) across every perpetual market +// on the public indexer. DeFiLlama's adapter reads the live volume24H +// at run time; the candles are the same fills bucketed on UTC days, +// which is what a backfill needs. Candles are paged 100 per call from +// the most recent, so a 400-day backfill is four pages per market. +// +// The indexer rate-limits per IP and, once tripped, answers 429 for a +// while: the throttle stays at one request a second and the sweep gives +// up after a run of rate-limited calls rather than retrying every +// market through the backoff ladder (that took an hour on 2026-09-14). +type dydxSource struct{ meta venueMeta } + +func (s *dydxSource) Slug() string { return s.meta.slug } +func (s *dydxSource) DisplayName() string { return s.meta.name } +func (s *dydxSource) Name() string { return "dydx-indexer-candles" } +func (s *dydxSource) Note() string { + return "dYdX v4 indexer 1DAY candles usdVolume summed over all perpetual markets" +} +func (s *dydxSource) Start() time.Time { return s.meta.start } + +func (s *dydxSource) Daily(ctx context.Context, from, to time.Time) (map[time.Time]float64, error) { + var markets struct { + Markets map[string]struct { + Status string `json:"status"` + } `json:"markets"` + } + if err := getJSON(ctx, "https://indexer.dydx.trade/v4/perpetualMarkets?limit=1000", &markets); err != nil { + return nil, err + } + out := map[time.Time]float64{} + th := newThrottle(60) + failed := 0 + limited := 0 + for ticker := range markets.Markets { + toISO := to.AddDate(0, 0, 1).Format(time.RFC3339) + for page := 0; page < 8; page++ { + if limited >= 6 { + return nil, fmt.Errorf("indexer rate limited, giving up this sweep (%d markets done)", len(markets.Markets)-failed) + } + if err := th.wait(ctx); err != nil { + return out, err + } + var resp struct { + Candles []struct { + StartedAt string `json:"startedAt"` + UsdVolume string `json:"usdVolume"` + } `json:"candles"` + } + u := fmt.Sprintf("https://indexer.dydx.trade/v4/candles/perpetualMarkets/%s?resolution=1DAY&limit=100&toISO=%s", + url.PathEscape(ticker), url.QueryEscape(toISO)) + if err := getJSON(ctx, u, &resp); err != nil { + failed++ + if errors.Is(err, ErrRateLimited) { + limited++ + } + break + } + limited = 0 + if len(resp.Candles) == 0 { + break + } + var oldest time.Time + for _, c := range resp.Candles { + t, err := time.Parse(time.RFC3339Nano, c.StartedAt) + if err != nil { + continue + } + d := utcDay(t) + oldest = d + if inRange(d, from, to) { + v, _ := strconv.ParseFloat(c.UsdVolume, 64) + out[d] += v + } + } + if oldest.IsZero() || !oldest.After(from) || len(resp.Candles) < 100 { + break + } + toISO = oldest.Format(time.RFC3339) + } + } + if failed > len(markets.Markets)/2 { + return nil, fmt.Errorf("%d of %d markets failed", failed, len(markets.Markets)) + } + return out, nil +} + +// --------------------------------------------------------------------- +// Lighter (dimension-adapters/dexs/lighterv2 + dexs/lighter-rh) +// --------------------------------------------------------------------- + +// lighterSource sums 1d candles (V, quote volume in USD) across every +// perp market (market_id < 2048, the adapters' filter that drops spot) +// on both deployments: zkLighter mainnet and the Robinhood chain one +// (api.rh.lighter.xyz, live since 2026-06-26). DeFiLlama's Lighter +// parent adds the two the same way (Lighter Perps + Lighter RH); +// mainnet alone read 23% under its page on 2026-09-13. +// +// count_back returns the latest N candles regardless of end_timestamp +// on mainnet, so N covers the window plus the running day and the +// range filter drops the rest. The RH API rejects end == start. +type lighterSource struct{ meta venueMeta } + +var lighterAPIs = []struct{ name, api string }{ + {"mainnet", "https://mainnet.zklighter.elliot.ai/api/v1"}, + {"robinhood", "https://api.rh.lighter.xyz/api/v1"}, +} + +func (s *lighterSource) Slug() string { return s.meta.slug } +func (s *lighterSource) DisplayName() string { return s.meta.name } +func (s *lighterSource) Name() string { return "lighter-candles" } +func (s *lighterSource) Note() string { + return "Lighter 1d candles quote volume summed over perp markets (market_id < 2048), zkLighter mainnet + Robinhood chain deployment" +} +func (s *lighterSource) Start() time.Time { return s.meta.start } + +func (s *lighterSource) Daily(ctx context.Context, from, to time.Time) (map[time.Time]float64, error) { + out := map[time.Time]float64{} + th := newThrottle(240) + days := int(to.Sub(from).Hours()/24) + 2 + gotMainnet := false + for _, dep := range lighterAPIs { + var books struct { + OrderBooks []struct { + MarketID int `json:"market_id"` + } `json:"order_books"` + } + if err := getJSON(ctx, dep.api+"/orderBooks?market_id=255", &books); err != nil { + if dep.name == "mainnet" { + return nil, fmt.Errorf("mainnet orderBooks: %w", err) + } + fmt.Printf("[lighter][%s] skipped: %v\n", dep.name, err) + continue + } + failed, total := 0, 0 + for _, b := range books.OrderBooks { + if b.MarketID >= 2048 { + continue + } + total++ + if err := th.wait(ctx); err != nil { + return out, err + } + var resp struct { + Candles []struct { + T int64 `json:"t"` + V float64 `json:"V"` + } `json:"c"` + } + u := fmt.Sprintf("%s/candles?market_id=%d&resolution=1d&start_timestamp=%d&end_timestamp=%d&count_back=%d", + dep.api, b.MarketID, from.Unix(), to.AddDate(0, 0, 1).Unix(), days) + if err := getJSON(ctx, u, &resp); err != nil { + failed++ + continue + } + for _, c := range resp.Candles { + d := utcDay(time.UnixMilli(c.T)) + if inRange(d, from, to) { + out[d] += c.V + } + } + } + if total > 0 && failed > total/2 { + if dep.name == "mainnet" { + return nil, fmt.Errorf("%d of %d mainnet markets failed", failed, total) + } + fmt.Printf("[lighter][%s] %d of %d markets failed\n", dep.name, failed, total) + continue + } + if dep.name == "mainnet" { + gotMainnet = true + } + } + if !gotMainnet { + return nil, fmt.Errorf("mainnet returned nothing") + } + return out, nil +} + +// --------------------------------------------------------------------- +// Aster (Binance-compatible fapi) +// --------------------------------------------------------------------- + +// asterSource sums 1d klines quoteVolume (USDT notional) across every +// TRADING perpetual symbol on fapi.asterdex.com. Aster's DeFiLlama +// derivatives adapter reads the same fapi tickers; klines are the +// backfillable form of the same series. ~570 symbols, one call each +// for up to 1500 days. +type asterSource struct{ meta venueMeta } + +func (s *asterSource) Slug() string { return s.meta.slug } +func (s *asterSource) DisplayName() string { return s.meta.name } +func (s *asterSource) Name() string { return "aster-fapi-klines" } +func (s *asterSource) Note() string { + return "Aster fapi 1d klines quoteVolume summed over TRADING perpetual symbols" +} +func (s *asterSource) Start() time.Time { return s.meta.start } + +func (s *asterSource) Daily(ctx context.Context, from, to time.Time) (map[time.Time]float64, error) { + var info struct { + Symbols []struct { + Symbol string `json:"symbol"` + ContractType string `json:"contractType"` + Status string `json:"status"` + } `json:"symbols"` + } + if err := getJSON(ctx, "https://fapi.asterdex.com/fapi/v1/exchangeInfo", &info); err != nil { + return nil, err + } + out := map[time.Time]float64{} + th := newThrottle(240) + failed, total := 0, 0 + days := int(to.Sub(from).Hours()/24) + 1 + if days > 1500 { + days = 1500 + } + for _, sym := range info.Symbols { + if sym.ContractType != "PERPETUAL" || sym.Status != "TRADING" { + continue + } + total++ + if err := th.wait(ctx); err != nil { + return out, err + } + var klines [][]any + u := fmt.Sprintf("https://fapi.asterdex.com/fapi/v1/klines?symbol=%s&interval=1d&startTime=%d&endTime=%d&limit=%d", + url.QueryEscape(sym.Symbol), from.UnixMilli(), to.AddDate(0, 0, 1).UnixMilli()-1, days) + if err := getJSON(ctx, u, &klines); err != nil { + failed++ + continue + } + for _, k := range klines { + if len(k) < 8 { + continue + } + openMs, ok := toFloat(k[0]) + if !ok { + continue + } + d := utcDay(time.UnixMilli(int64(openMs))) + if !inRange(d, from, to) { + continue + } + qv, _ := toFloat(k[7]) + out[d] += qv + } + } + if total > 0 && failed > total/2 { + return nil, fmt.Errorf("%d of %d symbols failed", failed, total) + } + return out, nil +} diff --git a/harnesses/perp-volume-history/cmd/script/sources.go b/harnesses/perp-volume-history/cmd/script/sources.go new file mode 100644 index 000000000..fbe620ea1 --- /dev/null +++ b/harnesses/perp-volume-history/cmd/script/sources.go @@ -0,0 +1,288 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// Source produces UTC-day perp volume for one venue. Daily returns every +// day it can fill inside [from, to] (inclusive); missing days are simply +// absent, never zero, so a source outage leaves holes rather than false +// lows. Sources mirror DeFiLlama's dimension-adapters for the venue +// (path noted on each type) so the numbers line up with defillama.com +// on closed days. +type Source interface { + // Slug is the OCB provider slug (bench label venue=""). + Slug() string + // DisplayName is the venue name on the public JSON. + DisplayName() string + // Name identifies the upstream (gmx-squid, gains-backend, defillama-pro...). + Name() string + // Note is a one-line perimeter caveat for the public JSON. + Note() string + // Start is the first day the source has data for. + Start() time.Time + Daily(ctx context.Context, from, to time.Time) (map[time.Time]float64, error) +} + +type venueMeta struct { + slug, name, llamaSlug string + start time.Time +} + +func day(s string) time.Time { + t, err := parseDay(s) + if err != nil { + panic(err) + } + return t +} + +// cohort is the ordered venue list. llamaSlug is the DeFiLlama +// derivatives slug used when DEFILLAMA_API_KEY is set. Start dates are +// the adapters' own start fields. +var cohort = []venueMeta{ + {"hyperliquid", "Hyperliquid", "hyperliquid-perps", day("2023-06-13")}, + {"hyperliquid-hip3", "Hyperliquid HIP-3", "hyperliquid-perps", day("2025-10-01")}, + {"gmx", "GMX V2", "gmx-v2-perps", day("2023-08-01")}, + {"gains", "Gains", "gains-network", day("2023-05-25")}, + {"aster", "Aster", "aster-perps", day("2024-10-01")}, + {"lighter", "Lighter", "lighter-perps", day("2025-01-17")}, + {"dydx", "dYdX v4", "dydx-v4", day("2023-10-26")}, + {"paradex", "Paradex", "paradex-perps", day("2023-09-01")}, + {"extended", "Extended", "extended-perps", day("2025-03-11")}, + {"orderly", "Orderly", "orderly-perps", day("2023-10-26")}, + {"aevo", "Aevo", "aevo-perps", day("2023-04-14")}, +} + +// buildSources wires one source per cohort venue. With a DeFiLlama Pro +// key every venue reads the Pro API (the reference number); otherwise +// each venue reads the upstream its DeFiLlama adapter reads. Gains gets +// the Dune view DeFiLlama reads when DUNE_API_KEY is set, the Gains +// backend otherwise. +func buildSources(llamaKey string) []Source { + out := make([]Source, 0, len(cohort)) + for _, v := range cohort { + if llamaKey != "" { + out = append(out, &llamaProSource{meta: v, key: llamaKey}) + continue + } + switch v.slug { + case "hyperliquid": + out = append(out, &hyperliquidSource{meta: v}) + case "hyperliquid-hip3": + out = append(out, &hyperliquidSource{meta: v, hip3: true}) + case "gmx": + out = append(out, &gmxSource{meta: v}) + case "gains": + out = append(out, newGainsSource(v)) + case "aster": + out = append(out, &asterSource{meta: v}) + case "lighter": + out = append(out, &lighterSource{meta: v}) + case "dydx": + out = append(out, &dydxSource{meta: v}) + case "paradex": + out = append(out, ¶dexSource{meta: v}) + case "extended": + out = append(out, &extendedSource{meta: v}) + case "orderly": + out = append(out, &orderlySource{meta: v}) + case "aevo": + out = append(out, &aevoSource{meta: v}) + } + } + return out +} + +// --------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------- + +var httpClient = &http.Client{Timeout: 45 * time.Second} + +// ErrRateLimited is returned after a 429 survived the retry ladder. +var ErrRateLimited = errors.New("rate_limited") + +const userAgent = "OpenChainBench-PerpVolumeHistory/1.0 contact@openchainbench.com" + +func getJSON(ctx context.Context, url string, out any) error { + return doJSON(ctx, http.MethodGet, url, nil, nil, out) +} + +func postJSON(ctx context.Context, url string, body any, out any) error { + b, err := json.Marshal(body) + if err != nil { + return err + } + return doJSON(ctx, http.MethodPost, url, b, nil, out) +} + +// doJSONHeaders is postJSON/getJSON with extra request headers (API keys). +func doJSONHeaders(ctx context.Context, method, url string, body any, headers map[string]string, out any) error { + var b []byte + if body != nil { + var err error + if b, err = json.Marshal(body); err != nil { + return err + } + } + return doJSON(ctx, method, url, b, headers, out) +} + +func doJSON(ctx context.Context, method, url string, body []byte, headers map[string]string, out any) error { + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(attempt*attempt) * 2 * time.Second): + } + } + req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := httpClient.Do(req) + if err != nil { + lastErr = err + continue + } + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) + resp.Body.Close() + if resp.StatusCode == 429 { + // Honour Retry-After (capped) so a rate-limited host is not + // hammered through the backoff ladder; ErrRateLimited lets + // per-market sources stop the sweep early instead of burning + // an hour on retries. + lastErr = ErrRateLimited + wait := 5 * time.Second + if ra, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && ra > 0 { + wait = time.Duration(ra) * time.Second + } + if wait > 30*time.Second { + wait = 30 * time.Second + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(wait): + } + continue + } + if resp.StatusCode >= 500 { + lastErr = fmt.Errorf("status_%d", resp.StatusCode) + continue + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(raw), 160)) + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("parse: %w (%s)", err, truncate(string(raw), 120)) + } + return nil + } + return lastErr +} + +func truncate(s string, n int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// inRange reports whether d (a UTC midnight) lies in [from, to]. +func inRange(d, from, to time.Time) bool { + return !d.Before(from) && !d.After(to) +} + +// eachDay calls fn for every day in [from, to] in ascending order, +// stopping on context cancellation. +func eachDay(ctx context.Context, from, to time.Time, fn func(d time.Time) error) error { + for d := from; !d.After(to); d = d.AddDate(0, 0, 1) { + if err := ctx.Err(); err != nil { + return err + } + if err := fn(d); err != nil { + return err + } + } + return nil +} + +// throttle sleeps so that per-venue sources stay under a requests per +// minute ceiling without a token-bucket dependency. +type throttle struct { + interval time.Duration + last time.Time +} + +func newThrottle(perMinute int) *throttle { + return &throttle{interval: time.Minute / time.Duration(perMinute)} +} + +func (t *throttle) wait(ctx context.Context) error { + if wait := t.interval - time.Since(t.last); wait > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(wait): + } + } + t.last = time.Now() + return nil +} + +// --------------------------------------------------------------------- +// DeFiLlama Pro (all venues, when keyed) +// --------------------------------------------------------------------- + +// llamaProSource reads GET https://pro-api.llama.fi//api/summary/derivatives/?dataType=dailyVolume +// which carries the whole totalDataChart. One call per venue per sweep. +type llamaProSource struct { + meta venueMeta + key string +} + +func (s *llamaProSource) Slug() string { return s.meta.slug } +func (s *llamaProSource) DisplayName() string { return s.meta.name } +func (s *llamaProSource) Name() string { return "defillama-pro" } +func (s *llamaProSource) Note() string { return "DeFiLlama derivatives dailyVolume (Pro API)" } +func (s *llamaProSource) Start() time.Time { return s.meta.start } + +func (s *llamaProSource) Daily(ctx context.Context, from, to time.Time) (map[time.Time]float64, error) { + var resp struct { + TotalDataChart [][2]float64 `json:"totalDataChart"` + } + url := fmt.Sprintf("https://pro-api.llama.fi/%s/api/summary/derivatives/%s?dataType=dailyVolume", s.key, s.meta.llamaSlug) + if err := getJSON(ctx, url, &resp); err != nil { + return nil, err + } + out := map[time.Time]float64{} + for _, p := range resp.TotalDataChart { + d := utcDay(time.Unix(int64(p[0]), 0)) + if inRange(d, from, to) { + out[d] = p[1] + } + } + return out, nil +} diff --git a/harnesses/perp-volume-history/cmd/script/store.go b/harnesses/perp-volume-history/cmd/script/store.go new file mode 100644 index 000000000..ac6a05bd8 --- /dev/null +++ b/harnesses/perp-volume-history/cmd/script/store.go @@ -0,0 +1,246 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +// Store is the on-disk history: venue -> day -> point. One JSON file, +// written atomically (temp + rename) after every sweep. A few hundred +// venues x days is well under a megabyte, so a flat file beats a DB +// dependency here; hl-archive needs DuckDB because it folds millions of +// fills, this harness stores one number per venue per day. +type Store struct { + mu sync.RWMutex + path string + dirty bool + Venues map[string]map[string]Point `json:"venues"` + // BackfilledFrom is the oldest day each venue's backfill has already + // walked to. A source whose history stops short of the horizon + // (Hyperliquid candles reach back about 340 days) would otherwise be + // re-walked in full on every sweep because its oldest stored day + // stays younger than the horizon. + BackfilledFrom map[string]string `json:"backfilled_from,omitempty"` +} + +// Point is one UTC day of one venue. Source names which upstream wrote +// it so a DeFiLlama Pro point and a squid point are distinguishable in +// the history file and the public JSON. +type Point struct { + USD float64 `json:"usd"` + Source string `json:"source"` +} + +func openStore(path string) (*Store, error) { + s := &Store{path: path, Venues: map[string]map[string]Point{}, BackfilledFrom: map[string]string{}} + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + return s, nil + } + return nil, err + } + if err := json.Unmarshal(b, s); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + if s.Venues == nil { + s.Venues = map[string]map[string]Point{} + } + if s.BackfilledFrom == nil { + s.BackfilledFrom = map[string]string{} + } + return s, nil +} + +// backfilledTo reports whether venue has already been walked back to +// horizon (or earlier). +func (s *Store) backfilledTo(venue string, horizon time.Time) bool { + s.mu.RLock() + defer s.mu.RUnlock() + k, ok := s.BackfilledFrom[venue] + return ok && k <= fmtDay(horizon) +} + +func (s *Store) markBackfilled(venue string, horizon time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + k := fmtDay(horizon) + if cur, ok := s.BackfilledFrom[venue]; ok && cur <= k { + return + } + s.BackfilledFrom[venue] = k + s.dirty = true +} + +func (s *Store) set(venue string, day time.Time, usd float64, source string) { + s.mu.Lock() + defer s.mu.Unlock() + m := s.Venues[venue] + if m == nil { + m = map[string]Point{} + s.Venues[venue] = m + } + k := fmtDay(day) + if p, ok := m[k]; ok && p.USD == usd && p.Source == source { + return + } + m[k] = Point{USD: usd, Source: source} + s.dirty = true +} + +func (s *Store) get(venue string, day time.Time) (float64, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + p, ok := s.Venues[venue][fmtDay(day)] + return p.USD, ok +} + +// sum adds the days from..to inclusive and reports how many were +// present, so callers can refuse to publish a short window. +func (s *Store) sum(venue string, from, to time.Time) (float64, int) { + s.mu.RLock() + defer s.mu.RUnlock() + m := s.Venues[venue] + var total float64 + n := 0 + for d := from; !d.After(to); d = d.AddDate(0, 0, 1) { + if p, ok := m[fmtDay(d)]; ok { + total += p.USD + n++ + } + } + return total, n +} + +func (s *Store) days(venue string) int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.Venues[venue]) +} + +func (s *Store) oldest(venue string) (time.Time, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + var best string + for k := range s.Venues[venue] { + if best == "" || k < best { + best = k + } + } + if best == "" { + return time.Time{}, false + } + t, err := parseDay(best) + return t, err == nil +} + +func (s *Store) venueCount() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.Venues) +} + +func (s *Store) pointCount() int { + s.mu.RLock() + defer s.mu.RUnlock() + n := 0 + for _, m := range s.Venues { + n += len(m) + } + return n +} + +func (s *Store) flush() error { + s.mu.Lock() + defer s.mu.Unlock() + if !s.dirty { + return nil + } + b, err := json.Marshal(s) + if err != nil { + return err + } + if err := writeAtomic(s.path, b); err != nil { + return err + } + s.dirty = false + return nil +} + +// PublicDoc is the JSON the site reads. Days are sorted ascending and +// every venue carries its display name and the source that produced +// each point, so the compare page can footnote the methodology per side. +type PublicDoc struct { + GeneratedAt string `json:"generated_at"` + LastClosed string `json:"last_closed_day"` + Venues []PublicVenue `json:"venues"` +} + +type PublicVenue struct { + Slug string `json:"slug"` + Name string `json:"name"` + Source string `json:"source"` + Note string `json:"note,omitempty"` + Days []PublicPoint `json:"days"` +} + +type PublicPoint struct { + Day string `json:"day"` + USD float64 `json:"usd"` + Source string `json:"source,omitempty"` +} + +func (s *Store) publicDoc(sources []Source) PublicDoc { + s.mu.RLock() + defer s.mu.RUnlock() + doc := PublicDoc{ + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + LastClosed: fmtDay(utcDay(time.Now()).AddDate(0, 0, -1)), + } + for _, src := range sources { + m := s.Venues[src.Slug()] + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + pv := PublicVenue{Slug: src.Slug(), Name: src.DisplayName(), Source: src.Name(), Note: src.Note(), Days: []PublicPoint{}} + for _, k := range keys { + p := m[k] + pp := PublicPoint{Day: k, USD: p.USD} + if p.Source != src.Name() { + pp.Source = p.Source + } + pv.Days = append(pv.Days, pp) + } + doc.Venues = append(doc.Venues, pv) + } + return doc +} + +func (s *Store) writePublic(path string, sources []Source) error { + b, err := json.Marshal(s.publicDoc(sources)) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return writeAtomic(path, b) +} + +func writeAtomic(path string, b []byte) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/harnesses/perp-volume-history/go.mod b/harnesses/perp-volume-history/go.mod new file mode 100644 index 000000000..560511d05 --- /dev/null +++ b/harnesses/perp-volume-history/go.mod @@ -0,0 +1,18 @@ +module perp-volume-history + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/perp-volume-history/go.sum b/harnesses/perp-volume-history/go.sum new file mode 100644 index 000000000..d6b8ca98b --- /dev/null +++ b/harnesses/perp-volume-history/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/rpc-capabilities/cmd/script/config.go b/harnesses/rpc-capabilities/cmd/script/config.go index b61dc0d04..3882ee45c 100644 --- a/harnesses/rpc-capabilities/cmd/script/config.go +++ b/harnesses/rpc-capabilities/cmd/script/config.go @@ -237,7 +237,7 @@ func chains() []Chain { {Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_MEGAETH_TENDERLY", "https://megaeth.gateway.tenderly.co")}, }, }, - // ─── Ethereum mainnet (8 providers) ──────────────────────── + // ─── Ethereum mainnet (9 providers) ──────────────────────── { Slug: "ethereum", Name: "Ethereum", @@ -250,6 +250,7 @@ func chains() []Chain { {Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_ETHEREUM_NODIES", "https://eth-pokt.nodies.app")}, {Slug: "lava", Name: "Lava Network", URL: envDefault("RPC_URL_ETHEREUM_LAVA", "https://eth1.lava.build")}, {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_ETHEREUM_BLOXROUTE", "https://eth.rpc.blxrbdn.com")}, + {Slug: "nownodes", Name: "NOWNodes", URL: envDefault("RPC_URL_ETHEREUM_NOWNODES", "https://public-eth.nownodes.io")}, }, }, // ─── Polygon PoS (5 providers) ────────────────────────────── @@ -304,7 +305,7 @@ func chains() []Chain { {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_BASE_BLOXROUTE", "https://base.rpc.blxrbdn.com")}, }, }, - // ─── BNB Chain (5 providers) ──────────────────────────────── + // ─── BNB Chain (7 providers) ──────────────────────────────── { Slug: "bnb", Name: "BNB Chain", @@ -315,6 +316,7 @@ func chains() []Chain { {Slug: "binance", Name: "Binance Official", URL: envDefault("RPC_URL_BNB_OFFICIAL", "https://bsc-dataseed1.binance.org")}, {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_BNB_BLASTAPI", "https://bsc-mainnet.public.blastapi.io")}, {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_BNB_BLOXROUTE", "https://bsc.rpc.blxrbdn.com")}, + {Slug: "nownodes", Name: "NOWNodes", URL: envDefault("RPC_URL_BNB_NOWNODES", "https://public-bsc.nownodes.io")}, }, }, // ─── Avalanche C-Chain (6 providers) ──────────────────────── @@ -1258,6 +1260,7 @@ func chains() []Chain { {Slug: "polkachu", Name: "Polkachu", URL: envDefault("RPC_URL_COREUM_POLKACHU", "https://coreum-rpc.polkachu.com")}, {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_COREUM_PUBLICNODE", "https://coreum-rpc.publicnode.com")}, {Slug: "coreum-official", Name: "Coreum Foundation", URL: envDefault("RPC_URL_COREUM_OFFICIAL", "https://full-node.mainnet-1.coreum.dev:26657")}, + {Slug: "nownodes", Name: "NOWNodes", URL: envDefault("RPC_URL_COREUM_NOWNODES", "https://public-coreum.nownodes.io")}, }, }, // Nolus — DeFi lease protocol (pirin-1). 2 keyless providers. @@ -1629,6 +1632,27 @@ func chains() []Chain { {Slug: "ngd2", Name: "NGD (2)", URL: envDefault("RPC_URL_NEO_NGD2", "https://n3seed2.ngd.network:10332")}, }, }, + // 2026-09-09 — Bitcoin. Bitcoin Core JSON-RPC getblockcount (no key). ~10min/block. + { + Slug: "bitcoin", + Name: "Bitcoin", + Kind: "bitcoin", + Providers: []Provider{ + {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_BTC_PUBLICNODE", "https://bitcoin-rpc.publicnode.com")}, + {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_BTC_DRPC", "https://bitcoin.drpc.org")}, + {Slug: "nownodes", Name: "NOWNodes", URL: envDefault("RPC_URL_BTC_NOWNODES", "https://public-btc.nownodes.io")}, + }, + }, + // 2026-09-09 — LUKSO (EVM L1, chain 42). eth_getBlockByNumber (no key). + { + Slug: "lukso", + Name: "LUKSO", + Providers: []Provider{ + {Slug: "lukso-official", Name: "LUKSO Foundation", URL: envDefault("RPC_URL_LUKSO_OFFICIAL", "https://rpc.mainnet.lukso.network")}, + {Slug: "nownodes", Name: "NOWNodes", URL: envDefault("RPC_URL_LUKSO_NOWNODES", "https://public-lukso.nownodes.io")}, + {Slug: "thirdweb", Name: "thirdweb", URL: envDefault("RPC_URL_LUKSO_THIRDWEB", "https://42.rpc.thirdweb.com")}, + }, + }, // 2026-08-27 wave-10. Litecoin — Tatum JSON-RPC (getblockcount) + BlockCypher REST + LitecoinSpace Esplora REST. ~150 s/block. { Slug: "litecoin", diff --git a/harnesses/rpc-capabilities/cmd/script/probe.go b/harnesses/rpc-capabilities/cmd/script/probe.go index 77a2560cc..a21f367a0 100644 --- a/harnesses/rpc-capabilities/cmd/script/probe.go +++ b/harnesses/rpc-capabilities/cmd/script/probe.go @@ -271,6 +271,12 @@ func probeOne(ctx context.Context, c Chain, p Provider) { block, hash, result, latency, err = callSubstrateHeader(probeCtx, p.URL) case "cosmos": block, hash, result, latency, err = callCosmosStatus(probeCtx, p.URL) + // Some Cosmos providers (NOWNodes public Coreum) answer 501 on + // Tendermint /status but serve the gRPC-gateway REST. Fall back so a + // REST-only endpoint isn't scored dead. + if result == "http_err" { + block, hash, result, latency, err = callCosmosRESTBlock(probeCtx, p.URL) + } case "starknet": block, result, latency, err = callStarknetBlockNumber(probeCtx, p.URL) case "stellar": @@ -315,6 +321,8 @@ func probeOne(ctx context.Context, c Chain, p Provider) { } else { block, result, latency, err = callBCHNodeInfo(probeCtx, p.URL) } + case "bitcoin": + block, result, latency, err = callNeoBlockCount(probeCtx, p.URL) case "litecoin": if strings.Contains(p.URL, "blockcypher.com") { block, result, latency, err = callBlockCypherLTC(probeCtx, p.URL) @@ -666,6 +674,54 @@ func callCosmosStatus(ctx context.Context, url string) (block uint64, hash strin return n, st.SyncInfo.LatestBlockHash, "ok", latencyMs, nil } +// callCosmosRESTBlock is the REST fallback for Cosmos providers that don't +// serve Tendermint /status (NOWNodes public Coreum answers 501 on /status +// but serves the gRPC-gateway REST). GET +// /cosmos/base/tendermint/v1beta1/blocks/latest, read block.header.height. +func callCosmosRESTBlock(ctx context.Context, url string) (block uint64, hash string, result string, latencyMs float64, err error) { + target := strings.TrimRight(url, "/") + "/cosmos/base/tendermint/v1beta1/blocks/latest" + req, _ := http.NewRequestWithContext(ctx, "GET", target, nil) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + client := &http.Client{Timeout: probeTimeout} + start := time.Now() + resp, err := client.Do(req) + latencyMs = float64(time.Since(start).Nanoseconds()) / 1e6 + if err != nil { + if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") { + return 0, "", "timeout", latencyMs, err + } + return 0, "", "http_err", latencyMs, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + _, _ = io.Copy(io.Discard, resp.Body) + return 0, "", "http_err", latencyMs, fmt.Errorf("status %d", resp.StatusCode) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return 0, "", "http_err", latencyMs, err + } + var r struct { + Block struct { + Header struct { + Height string `json:"height"` + } `json:"header"` + } `json:"block"` + } + if err := json.Unmarshal(raw, &r); err != nil { + return 0, "", "jsonrpc_err", latencyMs, err + } + if r.Block.Header.Height == "" { + return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("cosmos REST missing block.header.height") + } + n, err := strconv.ParseUint(r.Block.Header.Height, 10, 64) + if err != nil { + return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("non-numeric height: %q", r.Block.Header.Height) + } + return n, "", "ok", latencyMs, nil +} + // callStarknetBlockNumber is the Starknet probe path: starknet_blockNumber // with a rotating request id. Returns the current block number as uint64. // Staleness uses starknetStaleBlockGap in probeOne. 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/infrastructure/monitoring/prometheus/ocb_bridge_execution.yml b/infrastructure/monitoring/prometheus/ocb_bridge_execution.yml new file mode 100644 index 000000000..e4d000609 --- /dev/null +++ b/infrastructure/monitoring/prometheus/ocb_bridge_execution.yml @@ -0,0 +1,30 @@ +# Recording rules for the bridge EXECUTION benches (261 latency, 263 realized +# cost). These metrics come from real executions that run ~once/day, so the +# raw gauges go Prom-stale between runs and instant queries see nothing. Each +# rule re-evaluates the 24h window every 30s and emits a FRESH ocb: series, +# so the site always has a current value to render (standard OCB pattern; the +# labels are preserved so the chain / amount_usd / region tabs still filter). +groups: + - name: ocb_bridge_execution + interval: 30s + rules: + - record: ocb:bridge_exec_latency_ms:p50_24h + expr: quantile_over_time(0.50, bridge_exec_latency_ms[24h]) + - record: ocb:bridge_exec_latency_ms:p90_24h + expr: quantile_over_time(0.90, bridge_exec_latency_ms[24h]) + - record: ocb:bridge_exec_latency_ms:p99_24h + expr: quantile_over_time(0.99, bridge_exec_latency_ms[24h]) + - record: ocb:bridge_exec_latency_ms:mean_24h + expr: avg_over_time(bridge_exec_latency_ms[24h]) + - record: ocb:bridge_quote_slippage_usd:p50_24h + expr: quantile_over_time(0.50, bridge_quote_slippage_usd[24h]) + - record: ocb:bridge_quote_slippage_usd:p90_24h + expr: quantile_over_time(0.90, bridge_quote_slippage_usd[24h]) + - record: ocb:bridge_quote_slippage_usd:p99_24h + expr: quantile_over_time(0.99, bridge_quote_slippage_usd[24h]) + - record: ocb:bridge_quote_slippage_usd:mean_24h + expr: avg_over_time(bridge_quote_slippage_usd[24h]) + - record: ocb:bridge_exec_gas_usd:avg_24h + expr: avg_over_time(bridge_exec_gas_usd[24h]) + - record: ocb:bridge_realized_output_usd:avg_24h + expr: avg_over_time(bridge_realized_output_usd[24h]) diff --git a/infrastructure/monitoring/prometheus/ocb_bridge_quote_solana.yml b/infrastructure/monitoring/prometheus/ocb_bridge_quote_solana.yml new file mode 100644 index 000000000..470283427 --- /dev/null +++ b/infrastructure/monitoring/prometheus/ocb_bridge_quote_solana.yml @@ -0,0 +1,18 @@ +# Recording rules for the dedicated Solana quote-latency bench (SOL as source +# to several destinations). The general quote rules aggregate away from_chain, +# so these re-derive the same shapes SCOPED to from_chain="Solana", keeping +# the destination in the chain label so the per-destination tabs work. +groups: + - name: ocb_bridge_quote_solana + interval: 30s + rules: + - record: ocb:bridge_quote_latency_ms_bucket:sol_rate24h + expr: sum by (bridge, region, chain, amount_usd, le) (rate(bridge_quote_latency_ms_bucket{from_chain="Solana"}[24h])) + - record: ocb:bridge_quote_success:sol_avg24h + expr: avg by (bridge, region, chain, amount_usd) (avg_over_time(bridge_quote_success{from_chain="Solana"}[24h])) + - record: ocb:bridge_quote_latency_ms_count:sol_increase24h + expr: sum by (bridge, region, chain, amount_usd) (increase(bridge_quote_latency_ms_count{from_chain="Solana"}[24h])) + - record: ocb:bridge_quote_latency_ms_sum:sol_rate24h + expr: sum by (bridge, region, chain, amount_usd) (rate(bridge_quote_latency_ms_sum{from_chain="Solana"}[24h])) + - record: ocb:bridge_quote_latency_ms_count:sol_rate24h + expr: sum by (bridge, region, chain, amount_usd) (rate(bridge_quote_latency_ms_count{from_chain="Solana"}[24h])) diff --git a/infrastructure/prometheus/prometheus.yml b/infrastructure/prometheus/prometheus.yml index ba996e4b5..b90ec21ad 100644 --- a/infrastructure/prometheus/prometheus.yml +++ b/infrastructure/prometheus/prometheus.yml @@ -144,6 +144,35 @@ scrape_configs: benchmark: pm-rate-limits host: openchainbench + # ─ Fiat on-ramp all-in cost (bench 262) ─────────────────────────────── + # One EU vantage (persona FR/EUR), container ocb-fiat-onramp-cost on the + # Paris VPS next to Prom. Harness quotes every 5 min; the scrape only + # refreshes gauges, sample counts come from onramp_quote_samples_total. + - job_name: fiat-onramp-cost + metrics_path: /metrics + scheme: http + static_configs: + - targets: + - ocb-fiat-onramp-cost:2113 + labels: + benchmark: fiat-onramp-cost + host: openchainbench + + # ─ Perp DEX daily volume (bench 266) ────────────────────────────────── + # Container ocb-perp-volume-history on the Paris VPS. One number per + # venue per closed UTC day, backfilled 400 days; the scrape refreshes + # the last-day / 7d / 30d gauges, the full series is served as JSON by + # Caddy at kv.openchainbench.com/aggregate/perp-volume/history.json. + - job_name: perp-volume-history + metrics_path: /metrics + scheme: http + static_configs: + - targets: + - ocb-perp-volume-history:2112 + labels: + benchmark: perp-daily-volume + host: openchainbench + # ─ Self-scrape ──────────────────────────────────────────────────────── # Surfaces Prometheus's own health (scrape error rates, target up/down) # in the same UI used to debug harnesses. diff --git a/next.config.ts b/next.config.ts index 3a23ca7b3..4ee1fa846 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import { RENAMED_BENCH_SLUGS } from "./src/lib/removed-benches"; // Content-Security-Policy. `'unsafe-inline'` for scripts is needed because // three pages emit JSON-LD via `dangerouslySetInnerHTML` (layout.tsx, @@ -112,6 +113,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 @@ -294,6 +314,14 @@ const nextConfig: NextConfig = { }, ...chainRedirects, ...rpcClusterRedirects, + // Renamed / split benches: 308 the legacy URL to its successor so + // backlinks keep their PageRank. Used to be a 301 in middleware; + // a routing-layer redirect costs no function or edge invocation. + ...Object.entries(RENAMED_BENCH_SLUGS).map(([from, to]) => ({ + source: `/benchmarks/${from}`, + destination: `/benchmarks/${to}`, + permanent: true, + })), ]; }, }; diff --git a/public/logos/apex.svg b/public/logos/apex.svg new file mode 100644 index 000000000..88a02e910 --- /dev/null +++ b/public/logos/apex.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/logos/avantis.svg b/public/logos/avantis.svg new file mode 100644 index 000000000..df2c5a2bd --- /dev/null +++ b/public/logos/avantis.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/logos/decibel.svg b/public/logos/decibel.svg new file mode 100644 index 000000000..856129f19 --- /dev/null +++ b/public/logos/decibel.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/logos/derive.svg b/public/logos/derive.svg new file mode 100644 index 000000000..57ecd3aa7 --- /dev/null +++ b/public/logos/derive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/lukso.svg b/public/logos/lukso.svg new file mode 100644 index 000000000..cfab20ee5 --- /dev/null +++ b/public/logos/lukso.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/public/logos/meld.svg b/public/logos/meld.svg new file mode 100644 index 000000000..9c26132c6 --- /dev/null +++ b/public/logos/meld.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/logos/mercuryo.svg b/public/logos/mercuryo.svg new file mode 100644 index 000000000..a947869be --- /dev/null +++ b/public/logos/mercuryo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/logos/moonpay.svg b/public/logos/moonpay.svg new file mode 100644 index 000000000..4c7309083 --- /dev/null +++ b/public/logos/moonpay.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/logos/nado.svg b/public/logos/nado.svg new file mode 100644 index 000000000..8f24ff696 --- /dev/null +++ b/public/logos/nado.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/logos/nownodes.svg b/public/logos/nownodes.svg index 297c5a4e1..5e94ea570 100644 --- a/public/logos/nownodes.svg +++ b/public/logos/nownodes.svg @@ -1,4 +1,4 @@ - - - NN + + + diff --git a/public/logos/onramper.svg b/public/logos/onramper.svg new file mode 100644 index 000000000..bcf06b1e0 --- /dev/null +++ b/public/logos/onramper.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/logos/ramp.svg b/public/logos/ramp.svg new file mode 100644 index 000000000..687664e80 --- /dev/null +++ b/public/logos/ramp.svg @@ -0,0 +1,8 @@ + + + + + + + + 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/public/logos/transak.svg b/public/logos/transak.svg new file mode 100644 index 000000000..95a9d8ce7 --- /dev/null +++ b/public/logos/transak.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + 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/about/opengraph-image.tsx b/src/app/about/opengraph-image.tsx index 39c0408c1..b1ad72077 100644 --- a/src/app/about/opengraph-image.tsx +++ b/src/app/about/opengraph-image.tsx @@ -1,6 +1,10 @@ import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; export const alt = "About OpenChainBench. Open benchmarks for crypto infrastructure."; export const size = OG_SIZE; export const contentType = "image/png"; diff --git a/src/app/alternatives/[slug]/opengraph-image.tsx b/src/app/alternatives/[slug]/opengraph-image.tsx index ad37dbde8..43cb32836 100644 --- a/src/app/alternatives/[slug]/opengraph-image.tsx +++ b/src/app/alternatives/[slug]/opengraph-image.tsx @@ -2,6 +2,10 @@ import { ImageResponse } from "next/og"; import { loadAlternative } from "@/lib/alternatives"; export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; export const alt = "OpenChainBench. Open benchmarks for crypto infrastructure"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; diff --git a/src/app/alternatives/[slug]/page.tsx b/src/app/alternatives/[slug]/page.tsx index 7a6bf1a69..be9b8c3a7 100644 --- a/src/app/alternatives/[slug]/page.tsx +++ b/src/app/alternatives/[slug]/page.tsx @@ -28,7 +28,7 @@ export const revalidate = 3600; // Same budget as /products/[slug]: on-demand renders span the whole bench // catalog and the 60s default killed them mid-flight. -export const maxDuration = 300; +export const maxDuration = 120; type Params = { slug: string }; diff --git a/src/app/alternatives/opengraph-image.tsx b/src/app/alternatives/opengraph-image.tsx new file mode 100644 index 000000000..17a6d5a8b --- /dev/null +++ b/src/app/alternatives/opengraph-image.tsx @@ -0,0 +1,19 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; +export const alt = "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..826438b2d --- /dev/null +++ b/src/app/answers/[slug]/opengraph-image.tsx @@ -0,0 +1,32 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; +import { loadAllAnswers } from "@/lib/answers"; + +export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; +export const 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/[slug]/page.tsx b/src/app/answers/[slug]/page.tsx index d9dcd63a8..60dfd03a4 100644 --- a/src/app/answers/[slug]/page.tsx +++ b/src/app/answers/[slug]/page.tsx @@ -27,7 +27,7 @@ import { capDescription } from "@/lib/seo-text"; import { getBenchCreatedAt } from "@/lib/seo/bench-dates"; export const revalidate = 3600; -export const maxDuration = 300; +export const maxDuration = 120; type Params = { slug: string }; diff --git a/src/app/answers/opengraph-image.tsx b/src/app/answers/opengraph-image.tsx new file mode 100644 index 000000000..6bdb93a42 --- /dev/null +++ b/src/app/answers/opengraph-image.tsx @@ -0,0 +1,19 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; +export const alt = "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/aggregate/route.ts b/src/app/api/aggregate/route.ts index 2f7fd8306..6500c8b28 100644 --- a/src/app/api/aggregate/route.ts +++ b/src/app/api/aggregate/route.ts @@ -4,7 +4,7 @@ // (no CDN). Vercel functions run in IAD1 (US East) and were timing out // on the 7.5 MB payload at the old 8 s limit. This route re-exposes the // blob through Vercel's own CDN: the first request fetches from VPS -// (slow), Vercel caches the response at IAD1's edge for 60 s, all +// (slow), Vercel caches the response at IAD1's edge for 300 s, all // subsequent calls within that window cost ~1 ms instead of ~4 s. // // aggregate-blob.ts can point AGGREGATE_BLOB_URL at this route instead @@ -13,7 +13,7 @@ export const runtime = "nodejs"; // force-dynamic + explicit Cache-Control: CDN caches via s-maxage without // generating ISR write entries. revalidateTag("bench-aggregate") from the -// worker has no effect here (route is not ISR), but the CDN's own 60s TTL +// worker has no effect here (route is not ISR), but the CDN's own 300 s TTL // keeps the blob fresh. export const dynamic = "force-dynamic"; @@ -21,13 +21,17 @@ const UPSTREAM = "https://kv.openchainbench.com/aggregate/latest.json"; // Paris VPS → IAD1 with 7.3 MB payload: measured 37-49 s uncompressed. // With Accept-Encoding:gzip Caddy compresses to ~2.3 MB in ~18 s. Set // the timeout to 70 s so cold-cache first requests always complete. -const UPSTREAM_TIMEOUT_MS = 70_000; +// 40 s: gzip transfer measured at ~18 s, so this still covers a slow +// Paris -> IAD1 hop while halving the worst case (two attempts) from +// 140 s to 80 s of billed wall time when the VPS is down. +const UPSTREAM_TIMEOUT_MS = 40_000; +export const maxDuration = 90; export async function GET() { // Retry once: some Vercel function instances can't reach the Paris VPS // (connection refused / reset). A second attempt uses a different // outbound connection and typically succeeds. Without retry, a single - // bad instance poisons the 60s unstable_cache window for loadAggregateFromBlob, + // bad instance poisons the 900 s unstable_cache window for loadAggregateFromBlob, // forcing all benchmark loads onto the slow Redis fan-out path. let lastErr: string = "unknown"; for (let attempt = 0; attempt < 2; attempt++) { @@ -56,10 +60,10 @@ export async function GET() { status: 200, headers: { "Content-Type": "application/json", - // Vercel CDN caches 60 s + 5 min SWR — aligns with the materialize + // Vercel CDN caches 300 s + 15 min SWR, aligned with the data caches // worker's publish cadence (~60 s). The sitemap and homepage get a // warm edge hit after the first request. - "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300", + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=900", }, }); } diff --git a/src/app/api/citable/route.ts b/src/app/api/citable/route.ts index 66dfba3bd..ce2d0f898 100644 --- a/src/app/api/citable/route.ts +++ b/src/app/api/citable/route.ts @@ -5,6 +5,7 @@ import { AllBenchmarksDraftError } from "@/lib/spec"; import { citableAsOf, citeBundle, fieldValue, leader, headlineSentence } from "@/lib/citation"; import { valueInDeclaredUnit } from "@/lib/format"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; +import { stripQueryRedirect } from "@/lib/canonical-query"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -35,6 +36,8 @@ function unavailable(): NextResponse { * cite without needing to read the footer of every page. */ export async function GET(req: Request) { + const canonical = stripQueryRedirect(req); + if (canonical) return canonical; const r = rateLimit(clientKey(req, "citable"), 60, 60, req); if (!r.ok) return tooManyRequests(r.retryAfterSec); 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/freshness/route.ts b/src/app/api/freshness/route.ts index 5c0c56034..c72e7dbb4 100644 --- a/src/app/api/freshness/route.ts +++ b/src/app/api/freshness/route.ts @@ -2,6 +2,7 @@ import { unstable_cache } from "next/cache"; import { Prometheus } from "@/lib/prometheus"; import { getSpecs } from "@/lib/spec"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; +import { stripQueryRedirect } from "@/lib/canonical-query"; export const runtime = "nodejs"; @@ -91,6 +92,8 @@ const computeFreshness = unstable_cache( ); export async function GET(req: Request) { + const canonical = stripQueryRedirect(req); + if (canonical) return canonical; const r = rateLimit(clientKey(req, "freshness"), 120, 60, req); if (!r.ok) return tooManyRequests(r.retryAfterSec); diff --git a/src/app/api/internal/revalidate-aggregate/route.ts b/src/app/api/internal/revalidate-aggregate/route.ts index 0fdde6a41..9d2464659 100644 --- a/src/app/api/internal/revalidate-aggregate/route.ts +++ b/src/app/api/internal/revalidate-aggregate/route.ts @@ -1,6 +1,7 @@ import { timingSafeEqual } from "node:crypto"; import { revalidateTag } from "next/cache"; import { NextResponse } from "next/server"; +import { acquireRevalidateSlot, minIntervalSec } from "@/lib/revalidate-throttle"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -35,6 +36,14 @@ export async function POST(req: Request): Promise { // Next 16's revalidateTag takes a required cache-profile argument. // "default" applies the standard purge semantics for the CDN + ISR // cache layers this route is meant to invalidate. + // The worker calls this after every 60 s publish. Purging the + // `benchmarks` tag that often invalidated every ISR page each minute + // (see revalidate-throttle.ts); the data caches refresh on their own + // timers, so one purge per interval is enough for the pages. + const interval = minIntervalSec(); + if (!(await acquireRevalidateSlot(interval))) { + return NextResponse.json({ revalidated: false, throttled: true, intervalSec: interval }); + } revalidateTag("bench-aggregate", "default"); // Also invalidate per-bench caches so answer detail pages (/answers/[slug]) // and benchmark pages refresh from the same generation as the aggregate diff --git a/src/app/api/llm-context/route.ts b/src/app/api/llm-context/route.ts index 39e964052..959682953 100644 --- a/src/app/api/llm-context/route.ts +++ b/src/app/api/llm-context/route.ts @@ -10,6 +10,7 @@ import { rankedCandidates, } from "@/lib/citation"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; +import { stripQueryRedirect } from "@/lib/canonical-query"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -27,6 +28,8 @@ export const dynamic = "force-dynamic"; * per-region breakdown) but covers all 8 benches in one round-trip. */ export async function GET(req: Request) { + const canonical = stripQueryRedirect(req); + if (canonical) return canonical; const r = rateLimit(clientKey(req, "llm-context"), 30, 60, req); if (!r.ok) { const tooMany = tooManyRequests(r.retryAfterSec); diff --git a/src/app/api/mcp/[transport]/route.ts b/src/app/api/mcp/[transport]/route.ts index 1041dcc7c..2fd2660e8 100644 --- a/src/app/api/mcp/[transport]/route.ts +++ b/src/app/api/mcp/[transport]/route.ts @@ -49,6 +49,17 @@ const QUERY_PROM_ALLOWED_METRIC_PREFIXES = [ "bridge_output", "bridge_estimated_time", "bridge_quote_success", + // Bridge execution bench (#261): real on-chain settlement + realized cost + "bridge_execution_latency_ms", + "bridge_e2e_latency_ms", + "bridge_success_total", + "bridge_reverts_total", + "bridge_refunds_total", + "bridge_stuck_total", + "bridge_refund_latency_ms", + "bridge_realized_output_usd", + "bridge_quote_slippage_usd", + "bridge_exec_gas_usd", // L1 finality + L2 block time "l1_finality_", "l2_block_time_", @@ -349,6 +360,9 @@ const mcpHandler = createMcpHandler( "Allowed metric namespaces (one prefix per OCB bench family):", " head_lag_seconds (aggregator latency)", " bridge_quote_latency_ms*, bridge_cost*, bridge_fees*, bridge_fix_fee*,", + " bridge_execution_latency_ms*, bridge_e2e_latency_ms*, bridge_success_total,", + " bridge_reverts_total, bridge_refunds_total, bridge_stuck_total,", + " bridge_refund_latency_ms*, bridge_realized_output_usd, bridge_quote_slippage_usd, bridge_exec_gas_usd,", " bridge_gas*, bridge_output*, bridge_estimated_time*, bridge_quote_success", " l1_finality_*, l2_block_time_*", " metadata_coverage_*, metadata_api_latency_*, network_coverage_*,", 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/openapi.json/route.ts b/src/app/api/openapi.json/route.ts index dfe847d2b..db4749baf 100644 --- a/src/app/api/openapi.json/route.ts +++ b/src/app/api/openapi.json/route.ts @@ -3,6 +3,9 @@ import { SITE } from "@/data/site"; export const runtime = "nodejs"; export const revalidate = 3600; +// No query-string canonicalisation here (the other canonical API routes +// do it in-handler): reading `request.url` would opt this route out of +// ISR, and a 3600 s static response is worth more than the 308. /** * Minimal OpenAPI 3.1 description so agent frameworks (LangChain, diff --git a/src/app/api/search/index/route.ts b/src/app/api/search/index/route.ts new file mode 100644 index 000000000..a37ad76d7 --- /dev/null +++ b/src/app/api/search/index/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { buildSearchIndex } from "@/lib/search/buildIndex"; + +/** + * Search corpus for the Cmd+K dialog, fetched by the client on demand. + * + * This used to be built in the root layout and passed as props to the + * SearchProvider, which serialized all ~880 entries into the HTML of + * every page: 278 KB raw / 57 KB gzipped, 63 % of a bench page, sent + * on every request including the crawler traffic that never opens the + * dialog. Fast Data Transfer was the largest cost line on the Vercel + * bill because of it. Served here as an ISR route instead: one CDN + * object, fetched only when someone hovers or opens search. + * + * No request data is read so the route stays static and the CDN can + * hold it for the full revalidate window. + */ +export const runtime = "nodejs"; +export const revalidate = 3600; + +export async function GET() { + const items = await buildSearchIndex(); + return NextResponse.json( + { ok: true, count: items.length, items }, + { + headers: { + "cache-control": "public, s-maxage=3600, stale-while-revalidate=86400", + "access-control-allow-origin": "*", + }, + }, + ); +} diff --git a/src/app/api/series/[slug]/route.ts b/src/app/api/series/[slug]/route.ts index b833a15ef..f3234a273 100644 --- a/src/app/api/series/[slug]/route.ts +++ b/src/app/api/series/[slug]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { hasSeriesHistory, loadSeriesHistory } from "@/lib/series-history"; import { unstable_cache } from "next/cache"; import { getBenchmark } from "@/data/benchmarks"; import { filterSig, loadSpecsUncached, specToBenchmark } from "@/lib/materialize/load"; @@ -38,6 +39,23 @@ const getSeriesMapCached = unstable_cache( venue: string | undefined, panelId: string | undefined, ): Promise | null> => { + // ── Harness-backfilled history (bench 266 and friends) ────────────── + // Prom only holds samples since the first scrape; a bench whose + // harness keeps a year of daily points serves every range from that + // history instead (main series only, panels stay on Prom). + if (!panelId && hasSeriesHistory(slug)) { + const specs = await loadSpecsUncached(); + const spec = specs.find((s) => s.slug === slug); + if (spec) { + const fromHistory = await loadSeriesHistory( + slug, + range, + spec.providers.map((p) => p.slug), + ); + if (fromHistory) return fromHistory; + } + } + // ── Standard ranges (7d / 30d): blob → Redis → live build ────────── if (range === "7d" || range === "30d") { const sig = filterSig({ chain, region, kind, venue }); @@ -108,9 +126,12 @@ const getSeriesMapCached = unstable_cache( const prom = new Prometheus(promUrl); const windowSec = range === "90d" ? 90 * 86_400 : 365 * 86_400; - // ~1 point per day for 90d, ~3 days per point for 1y — dense enough - // for a smooth bar-chart race without blowing Prom step budgets. - const numPoints = range === "90d" ? 90 : 120; + // Resolution, not a fixed point budget: ~6h step for 90d, ~18h for 1y. + // A fixed low point count (90 / 120) collapsed young benches — 23 days of + // data rendered as ~24 points on 90d and ~8 on 1y, so a fresh bench looked + // like a sliver near "now". Stepping by time instead means every day of + // available history shows at full fidelity regardless of the window width. + const numPoints = range === "90d" ? 360 : 480; const result: Record = {}; await Promise.all( @@ -135,8 +156,8 @@ const getSeriesMapCached = unstable_cache( return Object.keys(result).length > 0 ? result : null; }, - // v7: targeted Prom fallback for panel 7d/30d misses (avoids specToBenchmark O(providers×panels)). - ["series-by-range-v7"], + // v8: denser 90d/1y resolution (6h / 18h step) so young benches render at full fidelity. + ["series-by-range-v9"], { revalidate: 300, tags: ["benchmarks"] }, ); @@ -160,8 +181,8 @@ const RANGE_CONFIG = { "24h": { windowMs: 24 * 3600 * 1000, points: 72 }, "7d": { windowMs: 7 * 24 * 3600 * 1000, points: 84 }, "30d": { windowMs: 30 * 24 * 3600 * 1000, points: 60 }, - "90d": { windowMs: 90 * 24 * 3600 * 1000, points: 90 }, - "1y": { windowMs: 365 * 24 * 3600 * 1000, points: 120 }, + "90d": { windowMs: 90 * 24 * 3600 * 1000, points: 360 }, + "1y": { windowMs: 365 * 24 * 3600 * 1000, points: 480 }, } as const; type RangeKey = keyof typeof RANGE_CONFIG; 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..930334e90 --- /dev/null +++ b/src/app/badges/opengraph-image.tsx @@ -0,0 +1,19 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; +export const alt = "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..328f0d062 --- /dev/null +++ b/src/app/benchmarks/[slug]/[chain]/opengraph-image.tsx @@ -0,0 +1,120 @@ +import { ImageResponse } from "next/og"; +import { getBenchmark } from "@/data/benchmarks"; +import { leader } from "@/lib/citation"; +import { OgClaimSentence } from "@/lib/og-claim"; +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"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; +export const alt = "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 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} +
+ +
+ +
+ openchainbench.com/benchmarks/{b.slug}/{chain} + {headline} +
+
+ ), + { ...size }, + ); +} diff --git a/src/app/benchmarks/[slug]/[chain]/page.tsx b/src/app/benchmarks/[slug]/[chain]/page.tsx index 66bed2901..0e49f5151 100644 --- a/src/app/benchmarks/[slug]/[chain]/page.tsx +++ b/src/app/benchmarks/[slug]/[chain]/page.tsx @@ -37,11 +37,11 @@ import type { Benchmark, ProviderResult } from "@/types/benchmark"; // with per-region leaders called out when they differ - // a provider that wins on 6 of 10 chains in one region // must never be presented as the global winner. -export const revalidate = 3600; +export const revalidate = 600; // Dimension pages fan out to (chain x region) variant fetches; a cold // render can take a while when the unstable_cache is empty. -export const maxDuration = 300; +export const maxDuration = 120; type Params = { slug: string; chain: string }; diff --git a/src/app/benchmarks/[slug]/opengraph-image.tsx b/src/app/benchmarks/[slug]/opengraph-image.tsx index 4b50596e4..6efc66537 100644 --- a/src/app/benchmarks/[slug]/opengraph-image.tsx +++ b/src/app/benchmarks/[slug]/opengraph-image.tsx @@ -1,12 +1,17 @@ import { ImageResponse } from "next/og"; import { getBenchmark } from "@/data/benchmarks"; -import { headlineSentence, leader } from "@/lib/citation"; +import { leader } from "@/lib/citation"; +import { OgClaimSentence } from "@/lib/og-claim"; import { fmtUnit } from "@/lib/format"; import { CATEGORY_COLOR } from "@/lib/category-colors"; import { matchesChainSlug } from "@/lib/chain-aliases"; import { loadBenchmark } from "@/lib/spec"; export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; export const alt = "OpenChainBench. Open benchmarks for crypto infrastructure"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; @@ -53,7 +58,6 @@ export default async function OG({ 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 chainLabel = chainId ? b.dimensions?.chain?.find((c) => matchesChainSlug(c.value, chainId)) @@ -119,18 +123,7 @@ export default async function OG({ > {titleText}
-
- {top ? sentence : b.subtitle} -
+
), - { ...size } + { ...size, headers: { "cache-control": "public, s-maxage=86400, stale-while-revalidate=604800" } } ); } diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index dd95803d8..faa50cb00 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import Link from "next/link"; import { ArrowLeft, ArrowUpRight, ChevronDown } from "lucide-react"; -import { getBenchmark, getBenchmarksSafe } from "@/data/benchmarks"; +import { getBenchmark, getBenchIndexSafe } from "@/data/benchmarks"; import { Pill } from "@/components/pill"; import { BenchmarkBody } from "@/components/benchmark-body"; import { BenchInfobox } from "@/components/bench-infobox"; @@ -60,7 +60,13 @@ import type { Benchmark } from "@/types/benchmark"; // using useSearchParams (BenchmarkBody, wrapped in Suspense) won't // flip the route to fully dynamic because they're behind Suspense, so // the route still prerenders cleanly. -export const revalidate = 3600; +// +// 600 s, not 3600: the worker's revalidate hook is now throttled to one +// tag purge per REVALIDATE_MIN_INTERVAL_SEC (default 600), so this is +// the real ceiling on how stale a bench page's SSR numbers can be. It +// used to be purged every minute, which made the 3600 here meaningless +// and re-rendered the page on every crawler hit. +export const revalidate = 600; // Cold start render does thousands of Prom calls on the heaviest benches // (hyperliquid-frontends: 75 providers × 7 queries + 11 panels × values + @@ -68,9 +74,12 @@ export const revalidate = 3600; // the ISR regeneration itself was killed ("Vercel Runtime Timeout Error: // Task timed out after 60 seconds"), so the cache could NEVER replace a // build-time render that had failed its panel queries — staging served -// empty panel values for hours (2026-06-11). 300s gives the regeneration +// empty panel values for hours (2026-06-11). 300s gave the regeneration // room to finish; the hot path is CDN-cached and stays sub-second. -export const maxDuration = 300; +// Since the materialize worker, the page reads one 66 KB blob plus the +// CDN-cached aggregate (cold 18-49 s), so 120 is ample and bounds the +// wall time Vercel bills as provisioned memory on a stuck upstream. +export const maxDuration = 120; type Params = { slug: string }; @@ -261,7 +270,9 @@ export default async function BenchmarkPage({ // /api/bench/[slug]/variant when a tab is flipped (per-variant // unstable_cache keeps that at one cheap Prom roundtrip per 60 s // across all users), and renders the aggregate while it loads. - const all = await getBenchmarksSafe(); + // Light index (slug/title/category), not the 8 MB aggregate: the + // related-benches rail below only needs titles. See getBenchIndexSafe. + const all = await getBenchIndexSafe(); // Seed ONLY the unfiltered key. Seeding the initially-selected // chain/region/kind combo with the aggregate made the client believe // it already had that variant, so it never fetched the real one: the @@ -271,6 +282,9 @@ export default async function BenchmarkPage({ const variants: Record = { [variantKey(null, null, null)]: aggregate, }; + // Region confinement for single-origin builds lives in overlayEditorial + // (src/lib/spec.ts), so `aggregate` and everything derived from it, + // including `variants`, is already scoped by the time it reaches here. const benchmark = aggregate; const isDraft = benchmark.status === "draft"; @@ -586,7 +600,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: CARD_HEADERS } ); } @@ -1060,7 +1067,7 @@ async function renderLeaderboard(
), - { ...SIZE } + { ...SIZE, headers: CARD_HEADERS } ); } @@ -1296,7 +1303,7 @@ async function renderSnapshot( ), - { ...SIZE } + { ...SIZE, headers: CARD_HEADERS } ); } @@ -1413,7 +1420,7 @@ async function renderHeadline( ), - { ...SIZE } + { ...SIZE, headers: CARD_HEADERS } ); } @@ -1565,7 +1572,7 @@ async function renderCompare( ), - { ...SIZE } + { ...SIZE, headers: CARD_HEADERS } ); } diff --git a/src/app/benchmarks/[slug]/twitter-image.tsx b/src/app/benchmarks/[slug]/twitter-image.tsx index f720ed0e4..82aa1365b 100644 --- a/src/app/benchmarks/[slug]/twitter-image.tsx +++ b/src/app/benchmarks/[slug]/twitter-image.tsx @@ -1,12 +1,17 @@ import { ImageResponse } from "next/og"; import { getBenchmark } from "@/data/benchmarks"; -import { headlineSentence, leader } from "@/lib/citation"; +import { leader } from "@/lib/citation"; +import { OgClaimSentence } from "@/lib/og-claim"; import { fmtUnit } from "@/lib/format"; import { CATEGORY_COLOR } from "@/lib/category-colors"; import { matchesChainSlug } from "@/lib/chain-aliases"; import { loadBenchmark } from "@/lib/spec"; export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; export const alt = "OpenChainBench. Open benchmarks for crypto infrastructure"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; @@ -64,7 +69,6 @@ export default async function TwitterImage({ 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 chainLabel = chainId ? b.dimensions?.chain?.find((c) => matchesChainSlug(c.value, chainId)) @@ -129,18 +133,7 @@ export default async function TwitterImage({ > {titleText} -
- {top ? sentence : b.subtitle} -
+
), - { ...size } + { ...size, headers: { "cache-control": "public, s-maxage=86400, stale-while-revalidate=604800" } } ); } 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..469a49290 --- /dev/null +++ b/src/app/benchmarks/category/[cat]/opengraph-image.tsx @@ -0,0 +1,23 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; +import { CATEGORY_BY_SLUG } from "@/lib/categories"; + +export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; +export const 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/benchmarks/opengraph-image.tsx b/src/app/benchmarks/opengraph-image.tsx index 2b5b38c05..3506ccb1a 100644 --- a/src/app/benchmarks/opengraph-image.tsx +++ b/src/app/benchmarks/opengraph-image.tsx @@ -1,6 +1,10 @@ import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; export const alt = "All OpenChainBench benchmarks. Live performance across crypto infrastructure."; export const size = OG_SIZE; export const contentType = "image/png"; diff --git a/src/app/bridge/opengraph-image.tsx b/src/app/bridge/opengraph-image.tsx new file mode 100644 index 000000000..69f96a887 --- /dev/null +++ b/src/app/bridge/opengraph-image.tsx @@ -0,0 +1,19 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; +export const alt = "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/[slug]/opengraph-image.tsx b/src/app/chains/[slug]/opengraph-image.tsx index 215385dcc..6895f064a 100644 --- a/src/app/chains/[slug]/opengraph-image.tsx +++ b/src/app/chains/[slug]/opengraph-image.tsx @@ -2,6 +2,10 @@ import { ImageResponse } from "next/og"; import { CHAIN_BY_SLUG, getBenchmarksForChain } from "@/lib/chains"; export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; export const alt = "OpenChainBench. Open benchmarks for crypto infrastructure"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; diff --git a/src/app/chains/[slug]/twitter-image.tsx b/src/app/chains/[slug]/twitter-image.tsx index 215385dcc..6895f064a 100644 --- a/src/app/chains/[slug]/twitter-image.tsx +++ b/src/app/chains/[slug]/twitter-image.tsx @@ -2,6 +2,10 @@ import { ImageResponse } from "next/og"; import { CHAIN_BY_SLUG, getBenchmarksForChain } from "@/lib/chains"; export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; export const alt = "OpenChainBench. Open benchmarks for crypto infrastructure"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; diff --git a/src/app/chains/opengraph-image.tsx b/src/app/chains/opengraph-image.tsx new file mode 100644 index 000000000..8aeec4afe --- /dev/null +++ b/src/app/chains/opengraph-image.tsx @@ -0,0 +1,19 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; +export const alt = "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/[slug]/opengraph-image.tsx b/src/app/compare/[slug]/opengraph-image.tsx index 411346e4a..e4a50608d 100644 --- a/src/app/compare/[slug]/opengraph-image.tsx +++ b/src/app/compare/[slug]/opengraph-image.tsx @@ -3,6 +3,10 @@ import { getComparePair } from "@/data/compare-pairs"; import { canonicalize } from "@/lib/providers"; export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; export const alt = "OpenChainBench. Open benchmarks for crypto infrastructure"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx index 19ace8bc6..c429ab2ca 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -19,6 +19,8 @@ import { buildBreadcrumbJsonLd, safeJsonLd } from "@/lib/jsonld"; import { SITE } from "@/data/site"; import { CREATOR_PUBLISHER, DATASET_LICENSE } from "@/lib/dataset-jsonld"; import { CompareBenchCard } from "@/components/compare-bench-card"; +import { PerpVolumeHeadToHead } from "@/components/perp-volume-head-to-head-section"; +import { PERP_VOLUME_COHORT } from "@/lib/perp-volume-history"; import type { CompareBench } from "@/components/compare-bench-card"; import { computeInputsHash, @@ -44,7 +46,7 @@ export const revalidate = 3600; // calls per shared bench. Cached, but cold ISR regeneration needs head // room above the 60 s default to avoid mid-flight timeouts on a pair // with multiple dimension-shape benches. -export const maxDuration = 300; +export const maxDuration = 120; type Params = { slug: string }; @@ -895,6 +897,9 @@ export default async function ComparePage({ }; const comparisonProse = buildComparisonProse(shared, a.name, b.name); + const perpPair = + pair.hero === "perp-volume" || + (PERP_VOLUME_COHORT.has(a.slug) && PERP_VOLUME_COHORT.has(b.slug)); return (
@@ -979,6 +984,15 @@ export default async function ComparePage({ /> + {perpPair && ( + + )} +

Side by side measurements @@ -990,6 +1004,8 @@ export default async function ComparePage({ bench={s} aName={a.name} bName={b.name} + aSlug={a.slug} + bSlug={b.slug} /> ))} diff --git a/src/app/compare/opengraph-image.tsx b/src/app/compare/opengraph-image.tsx new file mode 100644 index 000000000..fdbf54c1d --- /dev/null +++ b/src/app/compare/opengraph-image.tsx @@ -0,0 +1,19 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; +export const alt = "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/contribute/opengraph-image.tsx b/src/app/contribute/opengraph-image.tsx index f5287b6b6..afc99f6ae 100644 --- a/src/app/contribute/opengraph-image.tsx +++ b/src/app/contribute/opengraph-image.tsx @@ -1,6 +1,10 @@ import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; export const alt = "Contribute a benchmark to OpenChainBench. Six steps from idea to live spec."; export const size = OG_SIZE; export const contentType = "image/png"; diff --git a/src/app/data-api/opengraph-image.tsx b/src/app/data-api/opengraph-image.tsx new file mode 100644 index 000000000..274fe385d --- /dev/null +++ b/src/app/data-api/opengraph-image.tsx @@ -0,0 +1,19 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; +export const alt = "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..6459a43ea --- /dev/null +++ b/src/app/fee-compare/[venueA]/[venueB]/[wallet]/opengraph-image.tsx @@ -0,0 +1,31 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; +import { PERP_VENUES } from "@/lib/perp-stats"; + +export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; +export const 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..7d203910f --- /dev/null +++ b/src/app/fee-compare/opengraph-image.tsx @@ -0,0 +1,19 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +// Share cards change slowly (title, leader, headline value); crawlers +// and link unfurlers fetch them constantly. Without a revalidate the +// image was regenerated (satori, ~1-2 s of CPU) on every request. +export const revalidate = 86400; +export const alt = "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 (
+