diff --git a/benchmarks/bridge-fee.yml b/benchmarks/bridge-fee.yml index 872221f6..f87ca3f5 100644 --- a/benchmarks/bridge-fee.yml +++ b/benchmarks/bridge-fee.yml @@ -4,7 +4,7 @@ slug: bridge-fee number: "003" title: Cheapest cross-chain bridge for USDC at $300 notional seo_title: "Cheapest cross-chain bridge 2026" -seo_description: "Cheapest cross-chain bridge for USDC at $300. Total cost (fees, slippage, gas) across deBridge, LI.FI, Mobula, Relay." +seo_description: "Cheapest cross-chain bridge for USDC at $300. Total cost (fees, slippage, gas) across Across, deBridge, LI.FI, Mobula, Relay, Near Intents." subtitle: Total cost as a percent of notional, fees plus slippage plus destination gas combined, sampled at $300 USDC across Solana, Base and Arbitrum corridors. category: Bridges status: live @@ -54,9 +54,12 @@ 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." + - "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." - "Region: eu-west. Single point of measurement; multi-region requires running additional monitor instances." - "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) bake cost into the spread of a settlement intent rather than charging an explicit fee." + - "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." # Per-corridor leader rewrite deferred: this YAML does not yet declare # `dimensions:` for `from_chain`/`to_chain` / corridor. The underlying @@ -73,6 +76,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." - "{{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: @@ -81,7 +85,7 @@ faq: - q: "How much does it cost to bridge $300 USDC?" a: "Total cost varies between providers. The current p50 across {{count}} bridges spans from {{best_p50}} (leader) up to {{worst_p50}} (laggard) of the $300 notional. The figure already includes fees, slippage and destination gas because we report `bridge_cost_percent`, the all-in number that actually leaves the user's wallet, not the explicit fee field providers advertise." - q: "Why do bridge fees vary 10x between providers?" - a: "Three pricing architectures cohabit in this leaderboard. Direct protocols (deBridge) charge a native-token fee front-loaded into the quote. Aggregators (LI.FI) pay an underlying bridge plus a thin markup. Intent layers (Mobula, Relay) compress all of it into a single spread quoted on a settlement intent. On small trades the fixed-fee component dominates and the gap stretches, on large trades the spread component dominates and the leaderboard re-orders." + a: "Three pricing architectures cohabit in this leaderboard. Direct protocols (deBridge) charge a native-token fee front-loaded into the quote. Aggregators (LI.FI) pay an underlying bridge plus a thin markup. Intent and relay layers (Mobula, Relay, Across, Near Intents) compress all of it into a single spread quoted on a settlement intent or an optimistic fill. On small trades the fixed-fee component dominates and the gap stretches, on large trades the spread component dominates and the leaderboard re-orders." - q: "Are bridge aggregators cheaper than direct bridges?" a: "Not consistently. Aggregators route through whichever underlying bridge is cheapest at quote time, but they add a markup on top and their best route is constrained to the bridges they have integrated. Intent layers can route to any solver willing to settle the intent, including aggregators themselves. On liquid USDC corridors intent layers usually lead this leaderboard, but aggregators win when a corridor has only one underlying bridge and that bridge is cheaper than every solver's quote." - q: "Why does OpenChainBench use $300 notional instead of $1000 or $10000?" @@ -111,69 +115,83 @@ providers: - slug: mobula name: Mobula tag: Aggregator + intent layer - formula: "Median over 24h of total cost percent (fees + slippage + destination gas) on a $300 USDC quote returned by Mobula's intent layer, sampled every 5 minutes from eu-west." + formula: "Per-corridor 24h median of total cost percent (fees + slippage + destination gas) on a $300 USDC quote from Mobula's intent layer, averaged across corridors. Sampled every 5 minutes from eu-west." type: intent queries: - p50: quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", amount_usd="300"}[24h]) - p90: quantile_over_time(0.90, bridge_cost_percent{bridge="mobula", amount_usd="300"}[24h]) - p99: quantile_over_time(0.99, bridge_cost_percent{bridge="mobula", amount_usd="300"}[24h]) - mean: avg_over_time(bridge_cost_percent{bridge="mobula", amount_usd="300"}[24h]) - success: avg(avg_over_time(bridge_quote_success{bridge="mobula", amount_usd="300"}[24h])) - sample_size: sum(count_over_time(bridge_cost_percent{bridge="mobula", amount_usd="300"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="mobula", amount_usd="300"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="mobula", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="mobula", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="mobula", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="mobula", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="mobula", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="mobula", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h]) - slug: relay name: Relay tag: Cross-chain relay - formula: "Median over 24h of total cost percent (fees + slippage + destination gas) on a $300 USDC quote returned by Relay's API, sampled every 5 minutes from eu-west." + formula: "Per-corridor 24h median of total cost percent (fees + slippage + destination gas) on a $300 USDC quote from Relay's API, averaged across corridors. Sampled every 5 minutes from eu-west." type: relay queries: - p50: quantile_over_time(0.50, bridge_cost_percent{bridge="relay", amount_usd="300"}[24h]) - p90: quantile_over_time(0.90, bridge_cost_percent{bridge="relay", amount_usd="300"}[24h]) - p99: quantile_over_time(0.99, bridge_cost_percent{bridge="relay", amount_usd="300"}[24h]) - mean: avg_over_time(bridge_cost_percent{bridge="relay", amount_usd="300"}[24h]) - success: avg(avg_over_time(bridge_quote_success{bridge="relay", amount_usd="300"}[24h])) - sample_size: sum(count_over_time(bridge_cost_percent{bridge="relay", amount_usd="300"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="relay", amount_usd="300"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="relay", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="relay", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="relay", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="relay", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="relay", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="relay", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h]) - slug: lifi name: LI.FI tag: Aggregator - formula: "Median over 24h of total cost percent (underlying bridge fee + slippage + destination gas + LI.FI markup) on a $300 USDC quote, sampled every 5 minutes from eu-west." + formula: "Per-corridor 24h median of total cost percent (underlying bridge fee + slippage + destination gas + LI.FI markup) on a $300 USDC quote, averaged across corridors. Sampled every 5 minutes from eu-west." type: aggregator queries: - p50: quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", amount_usd="300"}[24h]) - p90: quantile_over_time(0.90, bridge_cost_percent{bridge="lifi", amount_usd="300"}[24h]) - p99: quantile_over_time(0.99, bridge_cost_percent{bridge="lifi", amount_usd="300"}[24h]) - mean: avg_over_time(bridge_cost_percent{bridge="lifi", amount_usd="300"}[24h]) - success: avg(avg_over_time(bridge_quote_success{bridge="lifi", amount_usd="300"}[24h])) - sample_size: sum(count_over_time(bridge_cost_percent{bridge="lifi", amount_usd="300"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="lifi", amount_usd="300"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="lifi", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="lifi", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="lifi", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="lifi", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="lifi", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="lifi", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h]) - slug: debridge name: deBridge tag: Native-token fees - formula: "Median over 24h of total cost percent (native-token protocol fee + slippage + destination gas) on a $300 USDC quote from deBridge's direct API, sampled every 5 minutes from eu-west." + formula: "Per-corridor 24h median of total cost percent (native-token protocol fee + slippage + destination gas) on a $300 USDC quote from deBridge's direct API, averaged across corridors. Sampled every 5 minutes from eu-west." type: protocol queries: - p50: quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", amount_usd="300"}[24h]) - p90: quantile_over_time(0.90, bridge_cost_percent{bridge="debridge", amount_usd="300"}[24h]) - p99: quantile_over_time(0.99, bridge_cost_percent{bridge="debridge", amount_usd="300"}[24h]) - mean: avg_over_time(bridge_cost_percent{bridge="debridge", amount_usd="300"}[24h]) - success: avg(avg_over_time(bridge_quote_success{bridge="debridge", amount_usd="300"}[24h])) - sample_size: sum(count_over_time(bridge_cost_percent{bridge="debridge", amount_usd="300"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="debridge", amount_usd="300"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="debridge", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="debridge", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="debridge", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="debridge", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="debridge", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="debridge", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h]) + + - slug: across + name: Across + tag: Optimistic relay + formula: "Per-corridor 24h median of total cost percent on a $300 USDC quote from the keyless Across Swap API, averaged across corridors. The provider-computed USD fee total nets relayer capital, LP fee, destination gas and swap impact." + type: intent + queries: + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="across", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="across", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="across", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="across", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="across", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="across", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h]) - slug: near-intents name: Near Intents tag: Intent layer (NEAR) - formula: "Median over 24h of total cost percent (solver spread + bridge fee) on a $300 USDC quote returned by Near Intents 1Click API, sampled every 5 minutes from eu-west." + formula: "Per-corridor 24h median of total cost percent on a $300 USDC quote from the Near Intents 1Click API, averaged across corridors. The quoted amountOut already nets out solver spread, bridge fee and destination withdrawal fee." type: intent queries: - p50: quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", amount_usd="300"}[24h]) - p90: quantile_over_time(0.90, bridge_cost_percent{bridge="near-intents", amount_usd="300"}[24h]) - p99: quantile_over_time(0.99, bridge_cost_percent{bridge="near-intents", amount_usd="300"}[24h]) - mean: avg_over_time(bridge_cost_percent{bridge="near-intents", amount_usd="300"}[24h]) - success: avg(avg_over_time(bridge_quote_success{bridge="near-intents", amount_usd="300"}[24h])) - sample_size: sum(count_over_time(bridge_cost_percent{bridge="near-intents", amount_usd="300"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="near-intents", amount_usd="300"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="near-intents", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="near-intents", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="near-intents", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="near-intents", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="near-intents", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="near-intents", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h]) diff --git a/benchmarks/l1-finality.yml b/benchmarks/l1-finality.yml index 9ddc8885..a56a3b59 100644 --- a/benchmarks/l1-finality.yml +++ b/benchmarks/l1-finality.yml @@ -4,10 +4,10 @@ slug: l1-finality number: "006" title: Fastest L1 blockchain finality, live across 11 chains seo_title: "Fastest L1 finality 2026" -seo_description: "Fastest L1 finality live across 11 chains: Gram, SUI, Hedera, Stellar, Solana, Ethereum and more." -subtitle: Wall-clock seconds from latest block to the finalized block on Ethereum, Solana, Gram, SUI, Stellar and 5 more chains, refreshed every 10 seconds. +seo_description: "Fastest L1 finality live across 11 chains: Gram, SUI, Hedera, Stellar, Solana, Ethereum, Bitcoin and more." +subtitle: Wall-clock seconds from latest block to the finalized block on Ethereum, Solana, Bitcoin, Gram, SUI, Stellar and 5 more chains, refreshed every 10 seconds. seo_intro: | - This page measures L1 finality time live for every major Layer-1 blockchain, with p50 / p90 / p99 refreshed every 10 seconds. Stellar finality time is ~5 seconds, the close interval the Stellar Consensus Protocol locks in via federated Byzantine agreement. Solana finality time goes from sub-second on the processed commitment to ~12.8 s on finalized after 32 confirmed slots. Ethereum finality time is ~12.8 minutes, the 2-epoch Casper FFG window. Hedera finality time clears in 3-5 seconds via Hashgraph aBFT. SUI finality time and Gram finality time (formerly TON) both sit under one second via Mysticeti DAG-BFT and BAG consensus. BNB and Avalanche finality time land near two seconds through fast-finality forks. Probabilistic chains (Litecoin, Monero) settle on a confirmation-depth convention measured here in minutes. + This page measures L1 finality time live for every major Layer-1 blockchain, with p50 / p90 / p99 refreshed every 10 seconds. Stellar finality time is ~5 seconds, the close interval the Stellar Consensus Protocol locks in via federated Byzantine agreement. Solana finality time goes from sub-second on the processed commitment to ~12.8 s on finalized after 32 confirmed slots. Ethereum finality time is about 12.8 minutes in the ideal case, the 2-epoch Casper FFG window; observed wall-clock finality runs longer, and the table below shows the live measurement. Hedera finality time clears in 3-5 seconds via Hashgraph aBFT. SUI finality time and Gram finality time (formerly TON) both sit under one second via Mysticeti DAG-BFT and BAG consensus. BNB and Avalanche finality time land near two seconds through fast-finality forks. Probabilistic chains (Bitcoin, Litecoin, Monero) settle on a confirmation-depth convention measured here in minutes. Bitcoin finality time is roughly one hour at the industry-standard 6 confirmations; the protocol itself never reaches absolute finality. faq: - q: "What is blockchain finality?" @@ -15,7 +15,7 @@ faq: - q: "Probabilistic vs deterministic finality, what is the difference?" a: "Deterministic finality is a binary state. A block is either finalized or it is not, and finalized blocks cannot be reorganized. Probabilistic finality is a confidence curve: a block becomes more final with each confirmation that builds on top of it. Exchanges set a convention (Bitcoin 6, Litecoin 6, Monero 10, Cardano 15) past which they treat funds as settled, but the protocol itself never declares strict finality. This benchmark uses each chain's native convention rather than imposing a uniform depth across all chains." - q: "What is Ethereum finality time?" - a: "Ethereum finality time is {{p50:ethereum}} (p50, 24h). That is 2 epochs of 32 slots of 12 seconds each, the window Casper FFG needs to justify and finalize a checkpoint. The benchmark measures it live by polling eth_getBlockByNumber(\"latest\") and eth_getBlockByNumber(\"finalized\") every 10 seconds and taking the timestamp delta." + a: "Ethereum finality time measured live on this page is {{p50:ethereum}} (p50, 24h). Casper FFG finalizes a checkpoint two epochs after it is justified, where each epoch is 32 slots of 12 seconds, about 12.8 minutes in the ideal case. Observed wall-clock finality runs longer because a block lands mid-epoch and waits before the two-epoch countdown starts. The live table above shows the measured value. The benchmark polls eth_getBlockByNumber(\"latest\") and eth_getBlockByNumber(\"finalized\") every 10 seconds and reports the timestamp delta." - q: "What is Solana finality time?" a: "Solana exposes two commitments. Processed is optimistic and lands sub-second, typically under 500 ms. Finalized requires 32 confirmed slots and clocks {{p50:solana}} (24h average). The leaderboard value is finalized, the stricter guarantee. Sub-second Solana finality is real but only at the processed commitment level, not finalized." - q: "What is SUI finality time?" @@ -29,7 +29,9 @@ faq: - q: "What is BNB Chain finality time?" a: "BNB Smart Chain finalizes via the BEP-126 fast-finality fork, dropping the confirmation depth that legacy probabilistic chains require. p50 sits at {{p50:bnb}} (24h), measured via persistent WebSocket subscription that records T1 when block N first appears as latest and T2 when it crosses the finalized threshold." - q: "Which blockchain has the fastest finality time?" - a: "Sub-second BFT chains lead. Gram and SUI both clock under one second on this live benchmark. BNB and Avalanche sit around one to two seconds via their fast-finality forks. Solana finalized lands around 12.8 s, Ethereum at 12.8 min, and probabilistic chains (Litecoin, Monero) trail at 15 to 30 minutes by confirmation-depth convention." + a: "Sub-second BFT chains lead. Gram and SUI both clock under one second on this live benchmark. BNB and Avalanche sit around one to two seconds via their fast-finality forks. Solana finalized lands around 12.8 s, Ethereum finalizes in minutes (about 12.8 in the ideal case, longer as observed; the live table shows the current p50), and probabilistic chains trail by confirmation-depth convention: Litecoin and Monero at 15 to 30 minutes, Bitcoin at roughly one hour for 6 confirmations." + - q: "What is Bitcoin finality time?" + a: "Bitcoin never reaches absolute finality. Nakamoto consensus gives probabilistic finality: any block can in principle be reorganized, with a probability that falls exponentially as more blocks are mined on top. The exchange-industry deposit convention is 6 confirmations, roughly one hour at the 10-minute target block interval, and that is what this benchmark measures: {{p50:bitcoin}} (p50, 24h) wall-clock from a block first appearing at the chain tip to reaching 6 confirmations, timed with the harness clock rather than miner-set block timestamps. Variance is high because block intervals are Poisson distributed; a lucky streak settles in about 30 minutes, an unlucky one takes well over 90. Six confirmations is a risk threshold, not a protocol guarantee, and deep reorgs remain possible with vanishing probability." - q: "How is L1 finality time measured on this page?" a: "Two methods, picked per chain. RPC pollers compare latest vs finalized block timestamps every 10 seconds, used for Ethereum, Solana, TRON, Stellar, SUI, Gram, Litecoin, Monero. WebSocket subscribers record wall-clock time T1 when a block first appears on the head stream and T2 when it crosses the finality threshold, giving millisecond-precision lag for sub-poll chains (BNB, Avalanche)." @@ -37,7 +39,7 @@ per_chain_explainer: - slug: ethereum h2: "Ethereum finality time" body: | - Ethereum finality time is {{p50:ethereum}} (p50, 24h). Casper FFG finalizes a checkpoint two epochs after it is justified, where each epoch is 32 slots of 12 seconds, the documented 12.8-minute target. Finality is deterministic once reached, but reorgs of unfinalized blocks remain possible inside the two-epoch window. Measured by polling `eth_getBlockByNumber("finalized")` against `latest` every 10 seconds and reporting the timestamp delta. + Ethereum finality time is {{p50:ethereum}} (p50, 24h), measured live. Casper FFG finalizes a checkpoint two epochs after it is justified, where each epoch is 32 slots of 12 seconds, which puts the ideal case at about 12.8 minutes. Observed wall-clock finality runs longer because a block lands mid-epoch and waits before the two-epoch countdown starts. Finality is deterministic once reached, but reorgs of unfinalized blocks remain possible before finalization. Measured by polling `eth_getBlockByNumber("finalized")` against `latest` every 10 seconds and reporting the timestamp delta. - slug: solana h2: "Solana finality time" body: | @@ -70,6 +72,10 @@ per_chain_explainer: h2: "TRON finality time" body: | TRON's solidity confirmation hovers around {{p50:tron}} (24h average), matching the documented 20-block delay at 3-second block intervals. The solidity flag in TRON consensus is the chain's equivalent of finalized: a block survives until 20 confirmations before being considered irreversible. Measured via `/wallet/getnowblock` (head) minus `/walletsolidity/getnowblock` (solidity-confirmed). + - slug: bitcoin + h2: "Bitcoin finality time" + body: | + Bitcoin is the reference probabilistic chain: Nakamoto consensus never declares a block final, confidence simply grows with confirmation depth. The exchange deposit convention is 6 confirmations, and this benchmark reports the wall-clock time to reach it, {{p50:bitcoin}} (p50, 24h). Bitcoin block timestamps are miner-set and may legally skew by up to two hours, so the harness does not trust them. It polls the mempool.space tip height every 30 seconds and uses its own clock: T1 when a block first appears at the tip, T2 when the tip is 6 blocks past it. Expect high variance; block intervals are Poisson, so the 6-confirmation wait ranges from about 30 minutes to well over 90. A block with 6 confirmations can still be reversed by a deeper reorg, with probability that vanishes as depth grows. - slug: litecoin h2: "Litecoin finality time" body: | @@ -98,6 +104,12 @@ abstract: | precision. The WS path is the only honest way to measure sub-poll-cadence finality. Comparing two pointers at a single instant collapses to zero when finalization catches up to head. + Bitcoin uses the same own-clock idea over HTTP polling: T1 when a + block first appears at the mempool.space tip, T2 when it reaches the + 6-confirmation exchange deposit convention. Miner-set Bitcoin block + timestamps (up to 2h skew is consensus-legal) are never trusted, and + Bitcoin never reaches absolute finality; the row reports the wall + clock to a conventional risk threshold, not a protocol commitment. Hedera is mentioned in the FAQ but not currently measured, its mirror node only exposes already-final blocks, which makes wall-clock lag impossible. Re-enabling is gated on Block Nodes @@ -112,6 +124,7 @@ methodology: - "Stellar (SSE wall-clock): Horizon `/ledgers?cursor=now&order=asc` event-stream records T1 on first sight of ledger N and T2 on the next ledger close (SCP-final at every close)." - "SUI: `sui_getLatestCheckpointSequenceNumber` minus 5 checkpoints back." - "Gram: tonapi.io `/blockchain/masterchain-head` minus 3 seqno." + - "Bitcoin (probabilistic, own-clock): mempool.space `/blocks/tip/height` polled every 30 s. T1 = first poll where block N is the tip, T2 = first poll where tip − N ≥ 6 (the exchange deposit convention). lag = T2 − T1 on the harness clock; miner-set block timestamps (consensus allows 2h skew) are never used. The startup tip is not timed. On a tip regression, pending timers above the new tip are dropped; same-height reorgs are invisible to height polling, bounding error to about one block interval." - "Litecoin (probabilistic): blockchair `/stats.best_block_height` minus 6 confirmations via `/dashboards/block/{height}.block.time`." - "Monero (probabilistic): monero-rpc `get_info` minus 10 confirmations via `get_block_header_by_height`." - "Hedera (planned, currently disabled): mirror-node `/api/v1/blocks` only exposes already-final blocks, so true wall-clock lag is impossible from public endpoints. Re-enables once Block Nodes (HIP-1056) leave private preview." @@ -121,9 +134,10 @@ methodology: findings: - "{{name:sui}} returns {{p50:sui}} (p50, 24 h), matching its Mysticeti DAG BFT design. The chain exposes ms timestamps so the measurement is genuinely sub-second." - - "{{name:ethereum}} sits at {{p50:ethereum}} (24 h average), aligned with the 2-epoch finalization spec (32 slots × 12 s × 2)." + - "{{name:ethereum}} sits at {{p50:ethereum}} (24 h average), consistent with the 2-epoch Casper FFG design (32 slots × 12 s × 2 = 12.8 min ideal) plus the mid-epoch wait before the countdown starts." - "{{name:tron}}'s solidity confirmation hovers around {{p50:tron}}, exactly the documented 20-block delay at 3 s blocks." - "{{name:litecoin}} and {{name:monero}} sit at {{p50:litecoin}} and {{p50:monero}} (24 h average) by construction, set by the chosen confirmation depth." + - "{{name:bitcoin}} reaches the 6-confirmation deposit convention in {{p50:bitcoin}} (p50, 24 h). This is a convention, not protocol finality: Bitcoin is probabilistic, never finalizes absolutely, and the reorg probability only vanishes with depth." - "{{best_name}} currently leads the leaderboard at {{best_p50}} (p50, 24 h) across {{count}} measured L1s. {{worst_name}} trails at {{worst_p50}}." - "Hedera's Hashgraph aBFT is designed to finalize in 3-5 s but is excluded from the live leaderboard because the mirror node only exposes finalized blocks, wall-clock measurement requires the Block Node service (HIP-1056), still in private preview." @@ -242,7 +256,7 @@ providers: - slug: ethereum name: Ethereum tag: Casper FFG, 2-epoch finalization - formula: "Mean elapsed time (in ms) between `eth_getBlockByNumber(\"latest\")` and `eth_getBlockByNumber(\"finalized\")` timestamps, the 2-epoch Casper FFG window (~12.8 min), over 24h." + formula: "Mean elapsed time (in ms) between `eth_getBlockByNumber(\"latest\")` and `eth_getBlockByNumber(\"finalized\")` timestamps, the 2-epoch Casper FFG window plus intra-epoch wait, over 24h." queries: p50: avg_over_time(l1_finality_lag_seconds{chain="ethereum"}[24h]) * 1000 p90: quantile_over_time(0.90, l1_finality_lag_seconds{chain="ethereum"}[24h]) * 1000 @@ -277,3 +291,16 @@ providers: success: avg_over_time(l1_finality_health{chain="monero"}[24h]) sample_size: count_over_time(l1_finality_lag_seconds{chain="monero"}[24h]) series: l1_finality_lag_seconds{chain="monero"} * 1000 + + - slug: bitcoin + name: Bitcoin + tag: Probabilistic, 6 confirmations, measured via own-clock polling + formula: "Median wall-clock milliseconds for a Bitcoin block to reach 6 confirmations (the exchange deposit convention, not absolute finality), timed with the harness clock from first sight at the mempool.space tip, p50 over 24h." + queries: + p50: quantile_over_time(0.50, l1_finality_wallclock_lag_milliseconds{chain="bitcoin"}[24h]) + p90: quantile_over_time(0.90, l1_finality_wallclock_lag_milliseconds{chain="bitcoin"}[24h]) + p99: quantile_over_time(0.99, l1_finality_wallclock_lag_milliseconds{chain="bitcoin"}[24h]) + mean: avg_over_time(l1_finality_wallclock_lag_milliseconds{chain="bitcoin"}[24h]) + success: avg_over_time(l1_finality_wallclock_health{chain="bitcoin"}[24h]) + sample_size: increase(l1_finality_wallclock_samples_total{chain="bitcoin"}[24h]) + series: l1_finality_wallclock_lag_milliseconds{chain="bitcoin"} diff --git a/benchmarks/metadata-coverage.yml b/benchmarks/metadata-coverage.yml index da882920..0381ff2f 100644 --- a/benchmarks/metadata-coverage.yml +++ b/benchmarks/metadata-coverage.yml @@ -66,7 +66,7 @@ methodology: - "Coverage check: for each fresh token, query each aggregator's metadata endpoint and record whether `logo`, `description`, `twitter` and `website` are populated." - "Aggregators in scope: Mobula (`/api/2/token/details`), Codex (GraphQL `token` query, JWT-authenticated), Jupiter (`/v6/tokens/`, Solana only)." - "Cadence: queue-driven; every newly-discovered token is checked once across the three aggregators. Steady-state ≈ several hundred checks per provider per hour." - - "Region: `eu-west` (single point. multi-region requires running additional monitor instances)." + - "Region: single probe origin (one monitor instance on our infrastructure). The bench intentionally declares no region dimension; multi-region would require additional monitor instances and is not currently planned." - "Ratio: `metadata_coverage_success_total / metadata_coverage_checks_total`, expressed in percent. Failures of the metadata API itself (timeouts, 5xx) are not counted in the denominator. only successful responses with the field check applied." - "Jupiter is Solana-only. it appears with zero coverage on EVM chains by construction; the cross-chain headline excludes Jupiter on chains it does not support." @@ -106,20 +106,12 @@ dimensions: chain: - { value: solana, label: Solana } - { value: bnb, label: BNB Chain } - # Region values match the raw harness labels. Declared so the - # /compare/[slug] page can render a chain x region matrix when - # both providers compete in this bench. - region: - - { value: all, label: All regions } - - { value: us-east, label: US-East } - - { value: eu-west, label: EU-West } - - { value: sgp, label: Singapore } # Rank matrix query gates the per-region badge scoping. Coverage is a # success-rate ratio; metric labels already carry the `provider` token # natively (metadata_coverage_success_total{provider, chain, field, # region}) so no label_replace required. -rank_matrix_query: avg by (provider, chain, region) (rate(metadata_coverage_success_total[24h]) / clamp_min(rate(metadata_coverage_checks_total[24h]), 0.001)) +rank_matrix_query: avg by (provider, chain) (rate(metadata_coverage_success_total[24h]) / clamp_min(rate(metadata_coverage_checks_total[24h]), 0.001)) # Real metrics exposed by the aggregator-head-lag harness: # metadata_coverage_checks_total{provider, chain, field, region} counter diff --git a/benchmarks/perp-fees.yml b/benchmarks/perp-fees.yml index f61af0b6..2736d650 100644 --- a/benchmarks/perp-fees.yml +++ b/benchmarks/perp-fees.yml @@ -23,8 +23,10 @@ seo_intro: | of which are missing from every comparison roundup currently ranking for "best perp DEX". We read taker fees from each venue's own API (no hardcoded schedules), walk the orderbook for $1000 of buy-side - notional to compute spread plus impact, and sum the two into a single - basis-point figure refreshed every five minutes. Hyperliquid vs + notional to compute spread plus impact where a book exists (GMX and + gains.trade are oracle priced, so their spread terms come from + protocol parameters), and sum the two into a single basis-point + figure refreshed every five minutes. Hyperliquid vs Lighter vs dYdX vs GMX vs gains.trade is then a one-number comparison, not a five-tab spreadsheet. @@ -36,19 +38,22 @@ abstract: | read live, no hardcoded fee schedules. Taker fees come from each venue's own API (Hyperliquid `userFees`, dYdX Cosmos REST `/feetiers/perpetual_fee_params`, GMX Subsquid `positionFeeFactor`, - Lighter `/orderBookDetails`). Spreads come from walking each venue's - orderbook for $1000 of buy-side notional. The bench refreshes every - 5 minutes. + Lighter `/orderBookDetails`, Gains v8 on-chain `fees()`). On the + orderbook venues (Hyperliquid, dYdX, Lighter) the spread comes from + walking the book for $1000 of buy-side notional. GMX v2 is oracle + priced with no book, so its spread term is zero. gains.trade uses + half of the on-chain `spreadP`. The bench refreshes every 5 minutes. methodology: - "Cadence: every 5 minutes, in parallel across all venues." - "Trade simulated: ETH long 10x, $1000 notional ($100 collateral)." - "Hyperliquid: `POST /info {type: l2Book}` for asks + `{type: userFees, user: 0x000...000}` for taker fee + `{type: metaAndAssetCtxs}` for funding." - "dYdX v4: indexer `/orderbooks/perpetualMarket/ETH-USD` + `/perpetualMarkets` (funding) + Cosmos REST `/dydxprotocol/v4/feetiers/perpetual_fee_params` for tier-0 default fee." - - "GMX v2 (Arbitrum): Subsquid GraphQL on the synthetics-arbitrum subgraph for `positionFeeFactorForNegativeImpact` (worst-case open) + gmxinfra REST for funding." + - "GMX v2 (Arbitrum): Subsquid GraphQL on the synthetics-arbitrum subgraph for `positionFeeFactorForNegativeImpact` (worst-case open) on the ETH/USD, BTC/USD and SOL/USD markets + gmxinfra REST for funding. Oracle priced, no orderbook: the spread term is zero." - "Lighter: `/orderBookDetails` (taker fee per market) + `/orderBookOrders` (orderbook walk)." - - "gains.trade (Gains v8 on Base): we read the fee directly on-chain. `eth_call pairs(N)` to find the pair index for the asset and its `feeIndex`, then `eth_call fees(feeIndex).openFeeP` for the actual taker fee. Spread comes from `pairs(N).spreadP`. Both values in 1e10 precision per Gains v8 convention. Cached 1h to keep Base RPC quota low." + - "gains.trade (Gains v8 on Base): fees read directly on-chain. `eth_call pairs(N)` gives `spreadP` and `feeIndex`, then `eth_call fees(feeIndex)` gives the open fee (`totalPositionSizeFeeP`). The taker crosses one side of the book, so we charge half of `spreadP`. SOL has `spreadP` 0 on-chain (Gains prices that pair's spread dynamically), so its figure is the open fee alone. Values in 1e10 precision per Gains v8 convention. Fee tier cached 1h, pair config 6h." - "All-in formula: `all_in_bps = taker_fee_bps + spread_bps`. Both components emitted as separate metrics for transparency." + - "Notional tiers: the same measurement runs at $1,000, $10,000 and $100,000 by rewalking the already fetched book, published to `perp_fees_all_in_bps_tier{venue, chain, notional}`. The headline `perp_fees_all_in_bps` stays defined at $1,000. A tier the book cannot fill is skipped and counted in `perp_fees_tier_skipped_total`, never extrapolated. Oracle priced venues (GMX v2, gains.trade) charge a flat percentage of size, so their figure repeats across tiers." - "Failures (5xx, timeouts, rate limits) leave the previous gauge in place and increment a per-venue `fetch_errors_total` counter. The page falls back to the last successful sample." per_chain_explainer: @@ -92,6 +97,8 @@ faq: a: "Rack-rate taker fee is what every comparison article quotes; it is also what every trader stops being able to read off a marketing page the second they place a market order. Spread plus impact at notional is the rest of the bill. On a tight book the gap is small, on a thin book it can dominate a 5 bps taker. Bundling both into one number is the only way to make a fair across-venue ranking that survives contact with a $1000 trade." - q: "How does GMX v2 fee work and why does this benchmark show the higher number?" a: "GMX v2 splits the position fee into a positive-impact branch (4 bps, when your trade reduces the venue's net open interest skew) and a negative-impact branch (6 bps, when it adds to the skew). Which branch fires depends on the current open interest at the moment of the trade and is not predictable from the user's side. We report the negative branch as the conservative upper bound; the positive branch is read by the harness as well and would lower the all-in number by 2 bps when it fires." + - q: "How do perp fees change with position size?" + a: "The taker fee does not change: every venue here charges a flat percentage of notional, so 4.5 bps on Hyperliquid costs 45 cents at $1,000 and $45 at $100,000, the same rate. What scales is the impact term: a bigger market order walks deeper into the book, so the effective price moves further from mid. On deep books like BTC on Hyperliquid the $100k figure sits within a fraction of a bp of the $1k figure; on thinner books like SOL the extra impact can exceed the entire taker fee. Oracle priced venues (GMX v2, gains.trade) quote the same bps at any size because there is no book to walk. The harness measures all three tiers every cycle and publishes them to `perp_fees_all_in_bps_tier` with a notional label of 1000, 10000 or 100000; the $10k and $100k panels on this page chart them live. When a book cannot fill a tier we skip the sample rather than extrapolate, so a missing tier is itself a depth signal." - q: "Does this benchmark include funding rates or only the open cost?" a: "Open cost only. Funding is paid every hour you hold, not at open, so bundling it into a one-number leaderboard would conflate two different fee mechanics. The harness still records funding rate per venue (`perp_fees_funding_rate_per_hour_bps`, signed) so a trader can read both numbers and compute their own time-adjusted cost for a longer hold." @@ -115,12 +122,14 @@ dimensions: # perp_fees_fetch_errors_total{venue, asset, error_type} counter # perp_fees_health{venue, asset} gauge (0|1) # perp_fees_last_refresh_timestamp_seconds{venue, asset} +# perp_fees_all_in_bps_tier{venue, chain, notional} gauge ($1k/$10k/$100k tiers; notional=1000 duplicates the headline) +# perp_fees_tier_skipped_total{venue, chain, notional} counter (fetched book too thin to fill the tier, sample skipped) providers: - slug: lighter name: Lighter tag: zk-rollup, zero taker fee - formula: "Average over 24h of (taker fee from Lighter `/orderBookDetails` + half-spread plus impact from walking `/orderBookOrders` for $1000 of ETH buy notional), in bps." + formula: "Average over 24h of (taker fee from Lighter `/orderBookDetails` + half-spread plus impact from walking `/orderBookOrders` for $1000 of buy notional in the selected asset), in bps." queries: p50: avg_over_time(perp_fees_all_in_bps{venue="lighter"}[24h]) p90: quantile_over_time(0.90, perp_fees_all_in_bps{venue="lighter"}[24h]) @@ -133,7 +142,7 @@ providers: - slug: hyperliquid name: Hyperliquid tag: HyperBFT L1 perp DEX - formula: "Average over 24h of (Hyperliquid `userFees` taker rate + half-spread plus impact from the `l2Book` asks side walked for $1000 of ETH buy notional), in bps." + formula: "Average over 24h of (Hyperliquid `userFees` taker rate + half-spread plus impact from the `l2Book` asks side walked for $1000 of buy notional in the selected asset), in bps." queries: p50: avg_over_time(perp_fees_all_in_bps{venue="hyperliquid"}[24h]) p90: quantile_over_time(0.90, perp_fees_all_in_bps{venue="hyperliquid"}[24h]) @@ -146,7 +155,7 @@ providers: - slug: dydx name: dYdX v4 tag: Cosmos-based decentralized perp - formula: "Average over 24h of (dYdX Cosmos REST tier-0 taker fee + half-spread plus impact from the indexer `/orderbooks/perpetualMarket/ETH-USD` walked at $1000 notional), in bps." + formula: "Average over 24h of (dYdX Cosmos REST tier-0 taker fee + half-spread plus impact from the indexer `/orderbooks/perpetualMarket/{ASSET}-USD` walked at $1000 notional), in bps." queries: p50: avg_over_time(perp_fees_all_in_bps{venue="dydx"}[24h]) p90: quantile_over_time(0.90, perp_fees_all_in_bps{venue="dydx"}[24h]) @@ -159,7 +168,7 @@ providers: - slug: gmx name: GMX v2 tag: Synthetics on Arbitrum, oracle-priced - formula: "Average over 24h of GMX v2 `positionFeeFactorForNegativeImpact` (worst-case open) from the synthetics-arbitrum subgraph, expressed in bps; oracle-priced so no spread component." + formula: "Average over 24h of GMX v2 `positionFeeFactorForNegativeImpact` (worst-case open) for the selected market (ETH, BTC or SOL) from the synthetics-arbitrum subgraph, in bps; oracle priced so no spread component." queries: p50: avg_over_time(perp_fees_all_in_bps{venue="gmx"}[24h]) p90: quantile_over_time(0.90, perp_fees_all_in_bps{venue="gmx"}[24h]) @@ -172,7 +181,7 @@ providers: - slug: gains name: gains.trade tag: Synthetic perps on Base, fees read on-chain - formula: "Average over 24h of (Gains v8 `fees(feeIndex).openFeeP` taker rate + `pairs(N).spreadP` synthetic spread), both read on-chain via Base RPC and converted to bps." + formula: "Average over 24h of (Gains v8 `fees(feeIndex)` open fee + half of `pairs(N).spreadP`), both read on-chain via Base RPC and converted to bps." queries: p50: avg_over_time(perp_fees_all_in_bps{venue="gains"}[24h]) p90: quantile_over_time(0.90, perp_fees_all_in_bps{venue="gains"}[24h]) @@ -181,3 +190,22 @@ providers: success: avg_over_time(perp_fees_health{venue="gains"}[24h]) sample_size: count_over_time(perp_fees_all_in_bps{venue="gains"}[24h]) series: perp_fees_all_in_bps{venue="gains"} + +# Notional-tier companion panels. Same pattern as perp-funding: the panel +# metric pins the asset label so the unfiltered "All" view reads one series +# per venue; on the BTC/SOL tabs the dimension injection skips chain= +# because it is already set, so the panels stay ETH-scoped by design. +# notional=1000 is not paneled: it duplicates the headline ledger. +metric_panels: + - id: all_in_10k + label: "All-in at $10k (ETH)" + metric: perp_fees_all_in_bps_tier{chain="ETH",notional="10000"} + label_key: venue + unit: bps + description: "All-in opening cost measured at $10,000 notional on the ETH pair. Same formula as the headline (taker fee plus spread plus impact); only the orderbook walk depth changes." + - id: all_in_100k + label: "All-in at $100k (ETH)" + metric: perp_fees_all_in_bps_tier{chain="ETH",notional="100000"} + label_key: venue + unit: bps + description: "All-in opening cost at $100,000 notional on the ETH pair. Books that cannot fill the size are skipped by the harness rather than extrapolated, so a venue missing here is itself a depth signal." diff --git a/benchmarks/rpc-capabilities.yml b/benchmarks/rpc-capabilities.yml index d9c8137c..c9ac15ad 100644 --- a/benchmarks/rpc-capabilities.yml +++ b/benchmarks/rpc-capabilities.yml @@ -12,6 +12,15 @@ metric: RPC latency unit: ms higher_is_better: false +disclaimer: > + Latency is conditional on success: the harness times only calls that + return a valid, fresh block, so an endpoint failing most calls can + still post a fast p50 from its rare successes. Treat rows below 50 + percent success as degraded. Providers whose probes stop succeeding + are pinned below the table as unresponsive. On the All chains tab + each number is the mean of per chain, per region 24h p50s; single + chain providers are measured on their one chain only. + seo_intro: | This benchmark answers the question every developer reaching for a free public RPC asks before pasting a URL into their dapp. which @@ -57,13 +66,17 @@ abstract: | detection compares each provider's reported block against the cross-provider tip and flags anything more than 20 blocks behind so a dead endpoint stuck on an old number is not silently ranked - as the fastest. + as the fastest. Latency is recorded only for successful calls, so + every percentile is conditional on success; the `rpc_call_total` + counters are the companion signal that keeps a mostly failing + endpoint from reading as fast. methodology: - - "Cadence: every 60 seconds per (provider, chain) pair, from each of 3 Railway replicas (us-east Virginia, eu-west Amsterdam, sgp Singapore). The harness reads `$RAILWAY_REPLICA_REGION` at boot, normalizes it to the canonical 3-region set, and stamps a `region` label on every emitted metric. Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are available on the time-series chart." + - "Cadence: every 60 seconds per (provider, chain) pair, from each of 3 Railway replicas (us-east Virginia, eu-west Amsterdam, sgp Singapore). The harness stamps a `region` label on every metric from `$RAILWAY_REPLICA_REGION`. Headline p50/p90/p99 are the mean of per (chain, region) `quantile_over_time` cells: all 3 regions, and on the All chains tab every chain the provider serves. Per-region breakdowns are on the time-series chart." - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}`. Plain HTTP POST, identical for every endpoint, no API key in any request. Non-cacheable by design: the latest-header fetch with a rotating id defeats edge caches that answer eth_blockNumber without touching a node." - - "Latency: client-side `time.Now()` delta around the round-trip, in milliseconds. Exposed as both a gauge (`rpc_latency_milliseconds`) and a histogram (`rpc_latency_milliseconds_histogram`) with buckets 50 ms → 10 s, so p50/p90/p99 are computed via Prometheus `histogram_quantile` / `quantile_over_time`." + - "Latency: client-side `time.Now()` delta around the round-trip, in milliseconds. Exposed as both a gauge (`rpc_latency_milliseconds`) and a histogram (`rpc_latency_milliseconds_histogram`) with buckets 50 ms → 10 s, so p50/p90/p99 are computed via Prometheus `histogram_quantile` / `quantile_over_time`. Latency is recorded only for calls classified `ok`; failed calls never enter the latency distribution, so every percentile is conditional on success." - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err` (status ≠ 200 or transport failure), `jsonrpc_err` (HTTP 200 with an `error` field, the Cloudflare-eth trap), `stale` (returned block more than 20 behind the cross-provider tip for that chain), `timeout`. Counter `rpc_call_total{result}` powers the reliability leaderboard." + - "Success gating: because failed probes record no latency, a mostly failing endpoint can post a fast p50 from its rare successes. The ledger pairs every latency figure with a Success column computed as ok calls over total calls in the same 24h window; read rows below 50 percent success as degraded. Providers with under 5 percent success lose their latency series to Prometheus staleness and are pinned below the table as unresponsive instead of being ranked." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head, depth) for `depth` in {300, 7200, 216_000, 1_296_000, 5_000_000}. Gauge `rpc_archive_depth_supported{depth}` is 1 when the response is non-pruned, 0 otherwise. The 300/7200 thresholds cover Geth's default pruned-cap range; 216k ≈ 1 month; 1.3M ≈ 6 months; 5M ≈ genesis-era full archive." - "Chain coverage: 10 EVM chains. Ethereum (9 providers), Arbitrum (8), Base (6), Optimism (6), Avalanche (6), BNB (5), Polygon (5), Linea (4), Scroll (4), Mantle (4). Provider matrix per chain is documented in `miniapps/rpc-capabilities/cmd/script/config.go`." - "Excluded by design: Ankr (key-gated), gateway.fm (29 req/IP budget too tight for 15s polling), LlamaRPC + BlockPI + OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal + GetBlock + Chainstack (key-gated). Merkle on Ethereum + Lava on non-ETH/Arb + MeowRPC on non-ETH/Arb all excluded chain-by-chain. Every (provider, chain) was live-verified no-key before inclusion." @@ -73,8 +86,8 @@ findings: - "On Base the leader is {{best_name:chain:base}} at {{best_p50:chain:base}} (p50, 24 h) across 6 providers. Base sees Coinbase's `mainnet.base.org` go head-to-head with PublicNode, dRPC, Tenderly and Merkle, an unusually clean comparison because the chain-official endpoint is operated by the same team that runs the sequencer." - "On BNB Chain the leader is {{best_name:chain:bnb}} at {{best_p50:chain:bnb}} (p50, 24 h) across 5 providers. Binance's `bsc-dataseed1` is the incumbent, but PublicNode, dRPC and Merkle have closed the gap on EU origin nodes." - "On Arbitrum the leader is {{best_name:chain:arbitrum}} at {{best_p50:chain:arbitrum}} (p50, 24 h) across 8 providers. Arbitrum is the second-largest cohort and one of the few chains where Lava and MeowRPC compete on a no-key tier alongside PublicNode and the Arbitrum Foundation's own endpoint." - - "{{name:publicnode}} returns {{p50:publicnode}} on the unfiltered aggregate. PublicNode (operated by Allnodes) is the de-facto default no-key RPC for many dapps because it terminates close to a large EU-anchored validator footprint and is one of the few providers present on most measured chains." - - "{{name:drpc}} sits at {{p50:drpc}} on the unfiltered aggregate. dRPC is a decentralized RPC mesh that routes each request across third-party node providers with consensus checks, so latency tracks the median of the underlying mesh rather than a single host." + - "{{name:publicnode}} returns {{p50:publicnode}} on the unfiltered aggregate (mean of its per chain, per region 24h p50s). PublicNode (operated by Allnodes) is the de-facto default no-key RPC for many dapps because it terminates close to a large EU-anchored validator footprint and is one of the few providers present on most measured chains." + - "{{name:drpc}} sits at {{p50:drpc}} on the unfiltered aggregate (mean of its per chain, per region 24h p50s). dRPC is a decentralized RPC mesh that routes each request across third-party node providers with consensus checks, so latency tracks the median of the underlying mesh rather than a single host." - "Cloudflare-eth is a cautionary tale. The endpoint returns HTTP 200 in well under a second, but a growing share of calls now resolve to `jsonrpc_err -32046 Cannot fulfill request` rather than a usable block, reliability is measured by `rpc_call_total{result=\"ok\"} / rpc_call_total` rather than HTTP status alone." - "Single-chain providers (Base official, Binance, Arbitrum Foundation, Optimism Foundation, Avalanche) mechanically inflate a cross-chain aggregate because they only emit metrics for their one chain. Read the leaderboard per chain and the comparison stays honest." @@ -85,6 +98,8 @@ faq: a: "15 audited providers across 10 EVM chains. Universal multi-chain (work on 4+ chains): PublicNode, dRPC, 1RPC, Tenderly Gateway (`gateway.tenderly.co/public/`), Nodies (POKT successor at `*-pokt.nodies.app`). Ethereum-specific or limited footprint: MeowRPC (ETH + Arbitrum only since 2025), Flashbots Protect, Cloudflare (read-degraded), Lava Network (ETH + Arbitrum no-key, other chains require key), Merkle (Base + BSC stable, ETH locked-out by Cloudflare). Chain-official foundation RPCs: Arbitrum (`arb1.arbitrum.io/rpc`), Optimism (`mainnet.optimism.io`), Base (`mainnet.base.org`), Avalanche (`api.avax.network`), BNB (`bsc-dataseed1.binance.org`). Excluded: Ankr (key-gated), gateway.fm (29 req/IP), LlamaRPC / BlockPI / OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal / GetBlock / Chainstack (key-gated)." - q: "Why is Cloudflare-eth returning errors?" a: "Cloudflare's public Ethereum gateway recently switched to a permissioned mode for many JSON-RPC methods. The endpoint still returns HTTP 200 quickly, but the body is increasingly a JSON-RPC error (`-32046 Cannot fulfill request`) rather than a block number. This benchmark catches the trap by classifying responses as `ok` only when both the HTTP status is 200 and the JSON-RPC body has a usable `result` field. Latency without reliability is a misleading ranking signal for a public RPC." + - q: "Why can an RPC with a low success rate still show a fast latency?" + a: "Latency percentiles are computed only from successful calls. The harness records a round-trip time when a probe returns a valid, fresh block; HTTP errors, JSON-RPC error bodies, timeouts and stale responses increment the `rpc_call_total` counter but never enter the latency distribution. An endpoint failing 60 percent of its calls is therefore timed only on the 40 percent that succeed, which can make it look faster than a healthy provider doing real work on every request. That is why the leaderboard pairs every latency figure with a Success column computed as ok calls over total calls in the same 24h window. Read rows below 50 percent success as degraded regardless of their p50. Providers under 5 percent success are dropped from the ranking and pinned below the table as unresponsive." - q: "Which free RPC supports Ethereum archive node calls?" a: "Most free public RPCs are state-pruned, so `eth_getBalance` at block (head, 5,000,000) returns an error rather than the historical balance. This benchmark probes five depths (300, 7,200, 216,000, 1,296,000, 5,000,000 blocks) every five minutes and exposes a gauge per provider per depth. The page surfaces a per-provider archive-depth badge: a green tick at the 5M tier means full historical state is available for free without a key. Anything pruned at 7200 confirms the endpoint is on a Geth default config and only serves the last ~24 hours." - q: "How is RPC latency measured on OpenChainBench?" @@ -148,7 +163,7 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key endpoint." + formula: "Mean over its measured chains and 3 regions of per cell 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to PublicNode's no-key endpoint. Successful calls only; failures show in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode"}) @@ -171,7 +186,7 @@ providers: - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to dRPC's decentralized mesh." + formula: "Mean over its measured chains and 3 regions of per cell 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to dRPC's decentralized mesh. Successful calls only; failures show in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc"}) @@ -194,7 +209,7 @@ providers: - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to 1RPC's Automata gateway." + formula: "Mean over its measured chains and 3 regions of per cell 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to 1RPC's Automata gateway. Successful calls only; failures show in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc"}) @@ -217,7 +232,7 @@ providers: - slug: meowrpc name: MeowRPC tag: Free public RPC, no registration - 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 MeowRPC's no-key endpoint." + formula: "Mean over ETH + Arbitrum and 3 regions of per cell 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to MeowRPC's no-key endpoint. Successful calls only; failures show in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="meowrpc"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="meowrpc"}) @@ -240,7 +255,7 @@ providers: - slug: flashbots name: Flashbots tag: Private-mempool RPC, anti-sandwich - 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 the Flashbots Protect read proxy." + formula: "Mean across 3 regions of the 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to the Flashbots Protect read proxy. Ethereum only; successful calls only, failures show in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="flashbots"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="flashbots"}) @@ -263,7 +278,7 @@ providers: - slug: cloudflare name: Cloudflare tag: Permissioned-mode for many JSON-RPC methods - 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 cloudflare-eth (check success column for jsonrpc_err share)." + formula: "Mean across 3 regions of the 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to cloudflare-eth. Ethereum only; successful calls only, so the fast p50 hides the jsonrpc_err share shown in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cloudflare"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="cloudflare"}) @@ -286,7 +301,7 @@ providers: - slug: base-official name: Base tag: Coinbase-operated, Base mainnet RPC - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Coinbase's `mainnet.base.org` endpoint." + formula: "Mean across 3 regions of the 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to Coinbase's `mainnet.base.org`. Base only; successful calls only, failures show in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="base-official"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="base-official"}) @@ -309,7 +324,7 @@ providers: - slug: binance name: Binance tag: BNB Chain dataseed RPC, Binance-operated - 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 Binance's `bsc-dataseed1.binance.org` endpoint." + formula: "Mean across 3 regions of the 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to Binance's `bsc-dataseed1.binance.org`. BNB Chain only; successful calls only, failures show in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="binance"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="binance"}) @@ -332,7 +347,7 @@ providers: - slug: tenderly name: Tenderly tag: Multi-chain public gateway, 9 chains, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to `gateway.tenderly.co/public/`." + formula: "Mean over its measured chains and 3 regions of per cell 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to `gateway.tenderly.co/public/`. Successful calls only; failures show in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly"}) @@ -355,7 +370,7 @@ providers: - slug: nodies name: Nodies tag: POKT Network's decentralized public RPC successor, 7+ chains - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Nodies' `*-pokt.nodies.app` endpoint." + formula: "Mean over its measured chains and 3 regions of per cell 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to Nodies' `*-pokt.nodies.app` endpoint. Successful calls only; failures show in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nodies"}) @@ -378,7 +393,7 @@ providers: - slug: lava name: Lava tag: Decentralized permissionless RPC mesh (ETH + Arbitrum no-key) - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Lava Network's no-key mesh endpoint." + formula: "Mean over ETH + Arbitrum and 3 regions of per cell 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to Lava Network's no-key mesh endpoint. Successful calls only; failures show in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="lava"}) @@ -401,7 +416,7 @@ providers: - slug: merkle name: Merkle tag: Base + BSC public no-key gateway (Ethereum hit by Cloudflare 20-min lockout, excluded) - 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 Merkle's Base/BSC no-key gateway." + formula: "Mean over Base + BSC and 3 regions of per cell 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to Merkle's no-key gateway. Successful calls only; failures show in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="merkle"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="merkle"}) @@ -424,7 +439,7 @@ providers: - slug: arbitrum-official name: Arbitrum tag: Arbitrum Foundation public RPC, Arbitrum One only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to the Arbitrum Foundation's `arb1.arbitrum.io/rpc`." + formula: "Mean across 3 regions of the 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to the Arbitrum Foundation's `arb1.arbitrum.io/rpc`. Arbitrum One only; successful calls only, failures show in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="arbitrum-official"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="arbitrum-official"}) @@ -447,7 +462,7 @@ providers: - slug: optimism-official name: Optimism tag: Optimism Foundation public RPC, Optimism mainnet only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to the Optimism Foundation's `mainnet.optimism.io`." + formula: "Mean across 3 regions of the 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to the Optimism Foundation's `mainnet.optimism.io`. Optimism only; successful calls only, failures show in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="optimism-official"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="optimism-official"}) @@ -470,7 +485,7 @@ providers: - slug: avalanche-official name: Avalanche tag: Ava Labs C-Chain public RPC, Avalanche C-Chain only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Ava Labs' `api.avax.network/ext/bc/C/rpc`." + formula: "Mean across 3 regions of the 24h p50 round-trip latency (ms) for `eth_getBlockByNumber` sent every 60s to Ava Labs' `api.avax.network/ext/bc/C/rpc`. Avalanche C-Chain only; successful calls only, failures show in the Success column." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="avalanche-official"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="avalanche-official"}) diff --git a/benchmarks/stablecoin-peg.yml b/benchmarks/stablecoin-peg.yml index a16ea70b..e597a693 100644 --- a/benchmarks/stablecoin-peg.yml +++ b/benchmarks/stablecoin-peg.yml @@ -5,7 +5,7 @@ number: "014" title: Most stable stablecoin, live peg deviation across USDC, USDT and DAI seo_title: "Most stable stablecoin 2026: peg deviation" seo_description: "Most stable USD stablecoin live by peg deviation: USDC, USDT, DAI via liquidity-weighted median across venues." -subtitle: Live peg deviation in basis points across USDC, USDT and DAI, computed every minute from a liquidity-weighted median across Binance, Kraken, Bitstamp and Curve. +subtitle: Live peg deviation in basis points across USDC, USDT and DAI, computed every minute from a liquidity-weighted median across Binance, Coinbase, Kraken, Bitstamp and Curve. category: Trading status: live metric: Peg deviation @@ -21,9 +21,10 @@ seo_intro: | smooths out the venue-level discrepancies that actually matter, the USDC/USDT cross-venue gap on Binance when USDC briefly trades 25 bps below its Kraken quote, the USDe flash to $0.65 - the night of October 10 2025, the DAI Curve 3pool reverse swap - rate diverging from forward. We poll Binance, Kraken, Bitstamp - (CEX REST tickers, 5 second cadence) and the Curve 3pool + the night of October 10 2025, the DAI Curve 3pool quote + drifting off peg while every CEX book stays flat. We poll + Binance, Coinbase, Kraken, Bitstamp (CEX REST tickers, 5 second + cadence) and the Curve 3pool on-chain (12 second cadence, matches Ethereum block time), aggregate per-venue prices into a per-minute liquidity-weighted median per USD-quoted stable, and surface five live numbers per @@ -40,26 +41,31 @@ abstract: | stable, then aggregating per-minute into a liquidity-weighted median. Deviation is `|aggregated_price - 1.00|` in basis points, recorded as both a gauge (latest) and a histogram (per-minute - samples). p99 over 24 h via `quantile_over_time` is the headline - leaderboard number. USDT-quoted pairs (Binance USDC/USDT, FDUSD, + samples). The leaderboard sorts on the 24 h median (p50) of the + per-minute worst deviation, the everyday tightness number; a + separate p99 column shows the depeg tail over the same window. + USDT-quoted pairs (Binance USDC/USDT, FDUSD, USDe) are deliberately excluded from the primary ranking because USDT's own peg deviation would contaminate every other stable's measurement; they are exposed on a separate `peg_deviation_usdt_anchored` metric instead. DAI's CEX coverage is essentially dead in 2026 (Binance zero book, Kraken thin, Bitstamp dead), so the only - honest signal is on-chain via Curve 3pool's `get_dy`. We poll the - pool both forward (USDC → DAI) and reverse (DAI → USDC) to - surface directional asymmetry. A binary `peg_depeg_event_flag` + honest signal is on-chain via Curve 3pool's `get_dy`. We quote + the pool both forward (USDC → DAI) and reverse (DAI → USDC) and + publish the geometric mean of the two, which cancels the pool + swap fee exactly; only genuine pool imbalance moves DAI's + number. A binary `peg_depeg_event_flag` goes high when a stable spends ≥5 consecutive minutes outside [$0.97, $1.03] and clears after 30 minutes back inside; conservative so it does not flap on normal stress. methodology: - - "Sources per stable, USDC: Binance USDC/USDT (USDT-anchored, secondary), Kraken USDCUSD, Bitstamp usdcusd. USDT: Kraken USDTUSD, Bitstamp usdtusd. FDUSD: Binance FDUSDUSDT (USDT-anchored, secondary). USDe: Binance USDEUSDT (USDT-anchored, secondary). DAI: Curve 3pool `get_dy` forward (USDC → DAI) and reverse (DAI → USDC)." + - "Sources per stable, USDC: Binance USDC/USDT (USDT-anchored, secondary), Kraken USDCUSD, Bitstamp usdcusd. USDT: Coinbase USDT-USD, Kraken USDTUSD, Bitstamp usdtusd. FDUSD: Binance FDUSDUSDT (USDT-anchored, secondary). USDe: Binance USDEUSDT (USDT-anchored, secondary). DAI: Curve 3pool `get_dy`, quoted forward (USDC → DAI) and reverse (DAI → USDC), published as the geometric mean of the two quotes." + - "DAI's source class differs from the rest. No liquid CEX USD pair for DAI exists (live check July 2026: Kraken DAIUSD under $150k per 24 h with a 9 bps spread, Bitstamp at zero volume, Coinbase DAI-USD delisted), so DAI is measured from the Curve 3pool on-chain quote while USDC and USDT come from CEX tickers. The geometric mean of forward and reverse `get_dy` cancels the pool swap fee; residual pool imbalance is DAI's genuine price signal, not an artifact." - "Cadence: 5 seconds for CEX REST tickers, 12 seconds for Curve `get_dy` on-chain (matches Ethereum block time). Per-venue samples are grouped into 60-second buckets for the liquidity-weighted median aggregation." - "Aggregation: per-minute liquidity-weighted median across USD-quoted venues (`Quote = QuoteUSD`). Weight is 24 h USD volume estimate. The aggregated price drives `peg_deviation_bps` (per-minute median, retained for backward compatibility) and `peg_deviation_bps_histogram`." - - "Primary headline: `peg_deviation_worst_bps`, the per-minute MAX |price - $1.00| across all venue samples in the 60-second bucket, in basis points. The leaderboard ranks stables on `quantile_over_time(0.99, peg_deviation_worst_bps[24h])`. Per-minute max surfaces sub-minute depeg wicks that a median would smooth away, matching the OHLC convention of TradFi reference rates. Gauges `peg_minute_min_bps`, `peg_minute_open_bps`, `peg_minute_close_bps` expose the bar shape." - - "Cross-venue gap (OCB-only number): `max(price across USD-quoted venues) - min(price)`, per minute, in basis points. Surfaces the windows where Coinbase quotes $1.0003 while Kraken quotes $0.9978. Stored as `peg_cross_venue_gap_bps`." + - "Primary headline: `peg_deviation_worst_bps`, the per-minute MAX |price - $1.00| across all venue samples in the 60-second bucket, in bps. The ledger sorts on `quantile_over_time(0.50, peg_deviation_worst_bps[24h])`, the 24 h median (everyday tightness); the p99 column is the depeg tail. Per-minute max surfaces sub-minute depeg wicks a median would smooth away. Gauges `peg_minute_min_bps`, `peg_minute_open_bps`, `peg_minute_close_bps` expose the bar shape." + - "Cross-venue gap (OCB-only number): `max(price across USD-quoted venues) - min(price)`, per minute, in basis points. Surfaces the windows where Coinbase quotes USDT at $1.0003 while Kraken quotes $0.9978. Stored as `peg_cross_venue_gap_bps`." - "USDT-anchored secondary metric: Binance USDC/USDT, FDUSD/USDT and USDE/USDT are exposed on `peg_deviation_usdt_anchored_bps{venue}` separately so the USD-anchored primary leaderboard is not contaminated by USDT's own peg deviation." - "Time outside band: total seconds in the trailing 24 h during which the per-minute aggregated price fell outside [0.995, 1.005] (±50 bps). Split into `peg_time_below_peg_24h_seconds` (< 0.995) and `peg_time_above_peg_24h_seconds` (> 1.005) because Circle redemption only clears above-peg, so the direction tells you which failure mode is active." - "Depeg event flag: binary `peg_depeg_event_flag` set to 1 when the per-minute aggregated price has been outside [0.97, 1.03] for ≥5 consecutive minutes; cleared after 30 minutes back inside. Conservative so it does not flap during normal stress events." @@ -67,10 +73,10 @@ methodology: - "Excluded by design: aggregator-only prices (CoinGecko, Coinmarketcap, DefiLlama) because they are themselves liquidity-weighted medians of the venues we already poll directly. Algo-stables that have already failed (UST, USDR) are out of scope; the bench tracks live, currently-redeemable stables." findings: - - "{{best_name}} currently leads at {{best_p50}} (median per-minute worst deviation, 24 h) across {{count}} measured stablecoins. The number is the worst 1% of the per-minute aggregated price's distance from $1.00, in basis points, on USD-quoted venues only." - - "{{name:usdc}} returns {{p99:usdc}} (p99 deviation, 24 h). USDC's peg is anchored by Circle's primary-market redemption window with US banks, so USD-quoted CEX deviations are usually mean-reverted within minutes by arbitrageurs hitting Coinbase or Kraken." + - "{{best_name}} currently leads at {{best_p50}} (median per-minute worst deviation, 24 h) across {{count}} measured stablecoins. The number is the 24 h median (p50) of each minute's worst distance from $1.00, in basis points, on USD-quoted venues only; the p99 column shows the depeg tail." + - "{{name:usdc}} returns {{p99:usdc}} (p99 deviation, 24 h). USDC's peg is anchored by Circle's primary-market redemption window with US banks, so USD-quoted CEX deviations are usually mean-reverted within minutes by arbitrageurs hitting Kraken or Bitstamp." - "{{name:usdt}} sits at {{p99:usdt}} (p99 deviation, 24 h). USDT carries the secondary-market premium of being the dominant pair currency on Binance and most non-US CEXes, so its CEX deviation is structurally biased above $1 during risk-on minutes and below during risk-off." - - "{{name:dai}} clocks {{p99:dai}} (p99 deviation, 24 h) measured on Curve 3pool. DAI's CEX coverage is essentially dead in 2026 so the on-chain pool quote is the only honest signal; the forward vs reverse swap rates surface arbitrage gaps that an averaged CEX tracker would silently smooth out." + - "{{name:dai}} clocks {{p99:dai}} (p99 deviation, 24 h) measured on Curve 3pool, the geometric mean of forward and reverse `get_dy` so the pool swap fee cancels out. DAI's CEX coverage is essentially dead in 2026 so the on-chain pool quote is the only honest signal; the source class differs from the CEX-measured stables and is stated in the methodology." - "USDe and FDUSD are USDT-anchored on Binance (no deep USD-quoted venue exists for either) and live on the companion bench `stablecoin-peg-usdt-anchored` (bench № 015), which is the only honest comparison surface for them. Mixing them into a USD-anchored leaderboard would compare apples to oranges since their deviation includes USDT's own drift." - "{{worst_name}} trails at {{worst_p50}} (median per-minute worst deviation, 24 h) on the active filter. The gap between leader and laggard widens on volatile minutes, the cross-venue gap column surfaces how synchronized the venues are at the exact moment of stress." @@ -78,15 +84,15 @@ faq: - q: "Which stablecoin is the most stable right now?" a: "{{best_name}} currently leads the leaderboard at {{best_p50}} (median per-minute worst deviation, 24 h) across {{count}} measured stablecoins on USD-quoted venues. The leaderboard re-sorts every minute against fresh Prometheus samples, so the answer reflects the last 24 hours of measured per-minute aggregated price, not a static white-paper figure." - q: "What is stablecoin peg deviation in basis points?" - a: "Peg deviation is the absolute distance between a stablecoin's price and $1.00, expressed in basis points where 1 bp = 0.0001 = 0.01%. A stablecoin trading at $0.9978 has 22 bps of deviation; at $1.0050 it has 50 bps. The industry uses bps because the deviations are small. tens of bps is normal noise, hundreds of bps is stress, thousands of bps is a depeg event. The benchmark records both per-minute deviation and the worst-1% tail (p99) over 24 h, the integration-grade number for any product that holds inventory in the stable." + a: "Peg deviation is the absolute distance between a stablecoin's price and $1.00, expressed in basis points where 1 bp = 0.0001 = 0.01%. A stablecoin trading at $0.9978 has 22 bps of deviation; at $1.0050 it has 50 bps. The industry uses bps because the deviations are small. tens of bps is normal noise, hundreds of bps is stress, thousands of bps is a depeg event. The leaderboard sorts on the 24 h median (p50) of the per-minute worst deviation, the everyday tightness number; the p99 column shows the worst-1% tail over the same window, the integration-grade number for any product that holds inventory in the stable." - q: "How does USDC compare to USDT on stability?" - a: "{{name:usdc}} returns {{p99:usdc}} (p99 deviation, 24 h) on USD-quoted venues (Kraken, Bitstamp). {{name:usdt}} returns {{p50:usdt}} on USD-quoted venues (Kraken, Bitstamp). USDC's peg is anchored by Circle's same-day USD redemption with US banks, USDT's by Tether's larger but slower-clearing redemption window. The leaderboard surfaces the live gap and the cross-venue spread per minute so the comparison is grounded in measured data rather than a sentiment-driven preference." + a: "{{name:usdc}} returns {{p99:usdc}} (p99 deviation, 24 h) on USD-quoted venues (Kraken, Bitstamp). {{name:usdt}} returns {{p99:usdt}} (p99 deviation, 24 h) on USD-quoted venues (Coinbase, Kraken, Bitstamp). USDC's peg is anchored by Circle's same-day USD redemption with US banks, USDT's by Tether's larger but slower-clearing redemption window. The leaderboard surfaces the live gap and the cross-venue spread per minute so the comparison is grounded in measured data rather than a sentiment-driven preference." - q: "Did USDe really depeg to $0.65 in October 2025?" a: "Yes, on Binance USDEUSDT specifically, the night of October 10 2025. Ethena's USDe is anchored by a delta-neutral basis trade rather than fiat reserves, and a sharp liquidation cascade on perp positions briefly cleared the orderbook on Binance well below peg. The benchmark exposes this kind of event live via the `peg_depeg_event_flag` (set when a stable has been outside [$0.97, $1.03] for ≥5 consecutive minutes) and via the USDT-anchored secondary metric. USDe's USD-anchored primary deviation cannot be measured because no major USD venue lists a deep USDe/USD pair; the USDT-anchored value is the honest live signal." - q: "How is the stablecoin peg measured on OpenChainBench?" - a: "We poll Binance, Kraken and Bitstamp REST tickers every 5 seconds for every (stable, venue) pair, and the Curve 3pool `get_dy` on-chain every 12 seconds (matches Ethereum block time). Per-venue samples are grouped into 60-second buckets and aggregated into a liquidity-weighted median per stable (weight = 24h USD volume). Deviation is `|aggregated_price - 1.00|` in basis points, recorded as both a gauge and a histogram. p99 over 24 h via Prometheus `quantile_over_time` is the headline. USDT-quoted pairs are exposed on a separate secondary metric so USDT's own peg deviation does not contaminate the USD-anchored primary." + a: "We poll Binance, Coinbase, Kraken and Bitstamp REST tickers every 5 seconds for every (stable, venue) pair, and the Curve 3pool `get_dy` on-chain every 12 seconds (matches Ethereum block time; DAI's price is the geometric mean of the forward and reverse quotes so the pool fee cancels). Per-venue samples are grouped into 60-second buckets and aggregated into a liquidity-weighted median per stable (weight = 24h USD volume). Deviation is `|aggregated_price - 1.00|` in basis points, recorded as both a gauge and a histogram. The leaderboard sorts on the 24 h median (p50) of the per-minute worst deviation; the p99 column shows the depeg tail. USDT-quoted pairs are exposed on a separate secondary metric so USDT's own peg deviation does not contaminate the USD-anchored primary." - q: "Why ignore CoinGecko or CoinMarketCap prices?" - a: "Aggregator prices (CoinGecko, CoinMarketCap, DefiLlama) are themselves liquidity-weighted medians of the venues this benchmark already polls directly, refreshed every 1-5 minutes. Re-aggregating an aggregator adds latency, hides the venue-level cross-spread that actually matters during stress (USDC briefly $0.9978 on Kraken while $1.0003 on Coinbase), and inherits whatever venue selection the aggregator chose. The benchmark goes to the source venues directly so the cross-venue gap is visible and the depeg event flag fires within 5 minutes of the price actually breaking the band." + a: "Aggregator prices (CoinGecko, CoinMarketCap, DefiLlama) are themselves liquidity-weighted medians of the venues this benchmark already polls directly, refreshed every 1-5 minutes. Re-aggregating an aggregator adds latency, hides the venue-level cross-spread that actually matters during stress (USDT briefly $0.9978 on Kraken while $1.0003 on Coinbase), and inherits whatever venue selection the aggregator chose. The benchmark goes to the source venues directly so the cross-venue gap is visible and the depeg event flag fires within 5 minutes of the price actually breaking the band." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/stablecoin-peg @@ -114,8 +120,9 @@ prometheus: # peg_source_health{stable, venue} gauge (0|1) # peg_minute_sample_count{stable, venue} gauge # -# Each "provider" below is one stablecoin. The leaderboard ranks -# stables by p99 deviation (lower = more stable). USDT-anchored +# Each "provider" below is one stablecoin. The ledger sorts on the +# p50 slot (24h median of per-minute worst deviation, lower = more +# stable); the p99 slot renders as the depeg-tail column. USDT-anchored # stables (FDUSD, USDe) are tracked on the secondary metric because # their USD-quoted pairs do not exist with depth on any no-key venue. @@ -135,8 +142,8 @@ providers: - slug: usdt name: USDT - tag: Tether, dominant CEX pair currency, audited on Kraken + Bitstamp - formula: "24h median of the per-minute worst |aggregated_price − $1.00| in bps; aggregated_price is the per-minute liquidity-weighted median of USDTUSD ticks on Kraken and Bitstamp." + tag: Tether, dominant CEX pair currency, audited on Coinbase + Kraken + Bitstamp + formula: "24h median of the per-minute worst |aggregated_price − $1.00| in bps; aggregated_price is the per-minute liquidity-weighted median of USDT/USD ticks on Coinbase, Kraken and Bitstamp." queries: p50: quantile_over_time(0.50, peg_deviation_worst_bps{stable="usdt"}[24h]) p90: quantile_over_time(0.90, peg_deviation_worst_bps{stable="usdt"}[24h]) @@ -148,8 +155,8 @@ providers: - slug: dai name: DAI - tag: MakerDAO, audited via Curve 3pool get_dy (on-chain only) - formula: "24h median of the per-minute worst |dai_price − $1.00| in bps, derived from Curve 3pool get_dy forward (USDC → DAI) and reverse (DAI → USDC) every 12s over 24h." + tag: MakerDAO, on-chain only via Curve 3pool, no liquid CEX USD pair exists in 2026 + formula: "24h median of the per-minute worst |dai_price − $1.00| in bps; dai_price is the geometric mean of Curve 3pool get_dy forward and reverse quotes (pool swap fee cancels), polled every 12s." queries: p50: quantile_over_time(0.50, peg_deviation_worst_bps{stable="dai"}[24h]) p90: quantile_over_time(0.90, peg_deviation_worst_bps{stable="dai"}[24h]) diff --git a/benchmarks/validator-yield.yml b/benchmarks/validator-yield.yml index 61638a06..b2e8103a 100644 --- a/benchmarks/validator-yield.yml +++ b/benchmarks/validator-yield.yml @@ -2,10 +2,10 @@ slug: validator-yield number: "026" -title: Highest staking yield, live validator net APR across Solana and Hyperliquid -seo_title: "Highest staking yield 2026: SOL vs HYPE" -seo_description: "Highest staking yield ranked live by net validator APR. Solana top 200 (Stakewiz total APY, MEV in) vs Hyperliquid active set. Uptime + commission + stake." -subtitle: Median validator net yield (APR multiplied by uptime) in basis points, compared across chains. Solana top 200 by stake, all active Hyperliquid validators. +title: Highest staking yield, live validator net APR across Solana, Hyperliquid and Ethereum +seo_title: "Highest staking yield 2026: SOL vs HYPE vs ETH" +seo_description: "Highest staking yield ranked live by net validator APR. Solana top 200 (Stakewiz total APY, MEV in), Hyperliquid active set, Ethereum consensus-layer solo validator APR (spec formula, MEV excluded). Uptime + commission + stake." +subtitle: Median validator net yield (APR multiplied by uptime) in basis points, compared across chains. Solana top 200 by stake, all active Hyperliquid validators, Ethereum as one network-average consensus-layer solo validator figure. category: Blockchains status: live metric: Net yield @@ -13,7 +13,7 @@ unit: bps higher_is_better: true disclaimer: | - Honest scope. (1) `net_yield = gross_apr × uptime`. NOT "yield net of MEV", Stakewiz `total_apy` already folds Jito MEV into gross, MEV is INCLUDED. `mev_share_bps` exists for transparency, not subtraction. (2) Solana median is dragged by ~42 vals at 0% APY (commission 100% or zero leader slots). (3) Hyperliquid: centralised sequencer, no separate MEV layer, ~30 vals only. (4) Ethereum deferred v2, requires beacon-node or MEV-Boost relay scraping. + Honest scope. (1) `net_yield = gross_apr × uptime`. NOT "yield net of MEV", Stakewiz `total_apy` already folds Jito MEV into gross, MEV is INCLUDED. `mev_share_bps` exists for transparency, not subtraction. (2) Solana median is dragged by ~42 vals at 0% APY (commission 100% or zero leader slots). (3) Hyperliquid: centralised sequencer, no separate MEV layer, ~30 vals only. (4) Ethereum: one network-average consensus-layer APR from the beacon spec formula, execution tips and MEV excluded. seo_intro: | This benchmark answers the question every staker asks when @@ -22,11 +22,14 @@ seo_intro: | including MEV where the chain has it. Marketing pages quote "up to 7% APY" without specifying validator, uptime assumption, or whether MEV tips are folded in. This page - measures the reality on-chain across two yield surfaces with + measures the reality on-chain across three yield surfaces with fundamentally different economics: Solana's competitive, ~3000-validator, MEV-heavy stake market (top 200 tracked by - activated stake) and Hyperliquid's permissioned, ~30-validator, - no-MEV centralised-sequencer model. The headline number per + activated stake), Hyperliquid's permissioned, ~30-validator, + no-MEV centralised-sequencer model, and Ethereum's ~1M-validator + beacon chain, published as a single network-average + consensus-layer solo validator figure with execution tips and + MEV excluded. The headline number per chain is the median net yield in basis points, where 1% APY = 100 bps. Per-validator gauges expose gross APR, commission, uptime, MEV share and dollar stake so the reader can drill from @@ -46,7 +49,19 @@ abstract: | transparency gauge. For Hyperliquid, the source is `/info validatorSummaries` whose `predictedApr.day` is staking yield only (no MEV layer exists, Hyperliquid's centralised - sequencer captures order flow value upstream). Solana is capped + sequencer captures order flow value upstream). For Ethereum, the + source is the live total beacon-chain effective balance from + ultrasound.money (`/api/v2/fees/effective-balance-sum`), plugged + into the consensus spec reward formula (base reward factor 64) + to give the network-average nominal APR for a 32 ETH solo + validator. The Ethereum figure is consensus layer only: + attestation, proposer and sync committee rewards in expectation. + Execution tips and MEV are excluded, so it understates what a + MEV-Boost proposer realizes, and it is not a liquid staking APY. + Ethereum ships as one network-average series rather than + per-validator gauges: the active set is ~1M validator indices, + and consensus rewards are uniform per 32 ETH increment, so the + network average equals the solo validator number. Solana is capped to the top 200 validators by activated stake to keep Prometheus cardinality bounded (the full set is ~3000); Hyperliquid exposes all ~30 active. The per-chain leaderboard number is @@ -71,8 +86,9 @@ methodology: - "Cap. Solana exposes ~3000 active validators across the full set; the bench caps to the top 200 by `activated_stake` (USD-weighted) to keep Prom cardinality at ~230 series total across the two chains. The top 200 represent ~90% of staked SOL by value, so the median is representative of where real stake sits, not of the long tail of <1k SOL validators. Hyperliquid exposes ~30 active validators total, all are tracked." - "Median (chain-level headline). `ocb_chain_median_net_yield_bps{chain}` is computed harness-side over the per-validator gauges every scrape, not derived from Prom. This includes the ~42 Solana validators currently at 0% APY (commission 100% or no leader slots), which materially pulls the Solana median down vs the modal active-validator yield. A reader who wants 'yield I would get if I picked a sane validator' should look at p75 or higher on the Solana distribution, not the chain median." - "MEV treatment. Solana: MEV tips from Jito are already folded into Stakewiz `total_apy`. `ocb_validator_mev_share_bps` is exposed for transparency (a reader sees 'Helius APR 600 bps of which 80 bps MEV') but NOT subtracted to produce a 'staking-only' figure. Hyperliquid: no separate MEV layer (centralised sequencer captures order-flow value at the protocol level), so the MEV gauge is 0 by convention." + - "Ethereum. Source: `GET ultrasound.money/api/v2/fees/effective-balance-sum` (free, no key), live total beacon-chain effective balance. APR = 64 × epochs_per_year / sqrt(total_gwei), the consensus spec reward formula at 100% participation (realized ~99.5%, under 2 bps of drift). One network-average series: consensus rewards are uniform per 32 ETH increment, so the average equals the solo validator nominal APR. Execution tips and MEV excluded. Price: CoinGecko (ETH/USD)." - "Jailed / inactive handling. Hyperliquid: `jailed=1` validators (currently 4) land in the dataset with `predictedApr=0`, so `net_yield_bps=0`. Solana: ~42 validators report `total_apy=0`. Live data shows two causes: commission 100% (operator routes everything to themselves, delegators get 0) or zero leader slots this epoch (no block-production rewards). Both legitimate transient states, both count toward total + median." - - "Excluded by design. Ethereum validator yield (deferred v2, beacon-node ~1 TB or MEV-Boost relay scraping required, neither is a 5-min poll). Lido / Rocket Pool / Coinbase staked-ETH are products with own fee structures, not validator yields (separate bench `liquid-staking-yield`, future). Cosmos, Polkadot, Avalanche out of scope v1, chain-specific sources would dilute focus." + - "Excluded by design. Per-validator Ethereum gauges (~1M active indices; needs beacon-node ingestion, deferred to v2). Ethereum execution tips and MEV (needs per-relay MEV-Boost scraping; the published figure is consensus only). Lido / Rocket Pool / Coinbase staked-ETH are products with own fee structures, not validator yields (separate bench `liquid-staking-yield`, future). Cosmos, Polkadot, Avalanche out of scope v1, chain-specific sources would dilute focus." findings: - "{{best_name}} currently leads at {{best_p50}} (median net yield, 24 h) across {{count}} measured chains. The cross-chain gap is structural: Solana's ~5.6% median reflects PoS inflation + Jito MEV tips minus average ~24% validator commission, while Hyperliquid's ~2.2% reflects staking-only rewards on a deflationary HYPE token with no MEV layer to top it up." @@ -80,7 +96,7 @@ findings: - "{{name:hyperliquid}} returns {{p50:hyperliquid}} (median, 24 h) across all 31 active validators. 4 are currently jailed (consensus fault, `jailed=1`, `predictedApr=0`) which pulls the median; the active-set median is closer to 2.25% (top operators at ~225 bps cluster very tightly because Hyperliquid's reward model distributes evenly across non-jailed validators)." - "Top Solana validators by stake: Figment ($1.36B), Helius ($1.29B), Binance Staking ($1.16B), Jupiter ($1.10B), Ledger by Figment ($794M). These are the institutional and exchange validators where delegated stake concentrates; their individual net yields cluster around the chain modal, not the median." - "Top Hyperliquid validators by stake: the four Hyper Foundation validators (1-4) collectively control >$12.8B in staked HYPE, with Anchorage by Figment ($1.43B) and Nansen × HypurrCollective ($1.38B) the largest non-foundation operators. The foundation concentration is much higher than on Solana, a structural difference between a permissioned ~30-validator set and a permissionless ~3000-validator one." - - "Ethereum is deferred to v2. Honest validator-level APR requires beacon-node integration (storage cost) or per-relay MEV-Boost scraping (which only covers the proposer fraction of yield). Until the v2 ingestion lands, this bench compares Solana vs Hyperliquid only, two chains chosen because each exposes a clean, free, no-key public API for the full validator set." + - "{{name:ethereum}} publishes {{p50:ethereum}} as a single network-average consensus-layer APR, computed from the beacon spec reward formula on the live total effective balance (~40M ETH staked). The figure moves inversely with the square root of total stake and excludes execution tips and MEV, so a proposer running MEV-Boost realizes more in practice. It is a solo validator nominal APR, not a liquid staking APY: Lido or Rocket Pool quote lower because product fees are netted out." faq: - q: "What is validator net yield?" @@ -93,8 +109,8 @@ faq: a: "MEV is INCLUDED in the gross APR figure, not subtracted from it. For Solana, Stakewiz's `total_apy` already folds Jito MEV tips into the headline APR by design, this bench inherits that aggregation rather than re-computing. The `ocb_validator_mev_share_bps` gauge is exposed separately for transparency (so you can see e.g. 'Helius's 600 bps APR is 80 bps MEV + 520 bps staking') but it does NOT get subtracted to produce a 'staking-only' number. For Hyperliquid, no separate MEV layer exists at validator level (the centralised sequencer captures value upstream), so the MEV gauge is reported as 0 by convention. This is the same `net_yield` definition every staking dashboard uses, Stakewiz, Solana Beach, Hyperliquid Stats, etc." - q: "Why are some Solana validators at 0% APY?" a: "Two legitimate causes, both visible in the live data. (1) Commission set to 100%: the validator operator has configured their vote account to route 100% of staking rewards to themselves, so delegators receive 0%. This is common for operators running their own treasury stake (Binance, exchange custody, etc.) where 'delegators' is a fiction, there's only the operator's own SOL. (2) Zero leader slots in the current epoch: Solana's leader schedule is stake-weighted but randomised per epoch, so a small validator can statistically draw zero slots in a given ~2-day epoch and earn zero block-production rewards. Both states are transient: commission can be lowered, leader slots cycle every epoch. The bench surfaces them as 0 rather than filtering because they ARE part of the live validator set." - - q: "Why is Ethereum missing from this benchmark?" - a: "Ethereum is deferred to v2. Honest validator-level APR on Ethereum requires either (a) running a beacon-node connection (~1 TB disk, ongoing sync cost) to read per-validator effective balance and attestation history, or (b) scraping every MEV-Boost relay's API for proposer-level payment data (Flashbots, BloXroute, Eden, Aestus, Manifold, etc., each with its own schema). Neither is a 5-minute HTTP poll like Stakewiz or Hyperliquid `/info`. The v1 scope was 'free no-key APIs that expose the full active validator set in one call'; Ethereum doesn't have that. v2 will add beacon-node ingestion or relay aggregation once the harness footprint can afford it." + - q: "What does the Ethereum number measure?" + a: "It is the network-average consensus-layer APR for a 32 ETH solo validator, computed from the beacon chain spec reward formula on the live total effective balance published by ultrasound.money. It covers attestation, proposer and sync committee rewards in expectation, at 100% participation (realized participation runs about 99.5%, a drift of under 2 bps). It EXCLUDES execution-layer tips and MEV, so a proposer running MEV-Boost earns more in practice. It is NOT a liquid staking APY: Lido or Coinbase quotes are product rates with their own fee schedules netted out. Ethereum ships as one aggregate series rather than per-validator gauges because the active set is about 1M validator indices and consensus rewards are uniform per 32 ETH increment, so the network average equals the solo validator nominal APR. Per-validator Ethereum gauges and MEV inclusion require beacon-node or relay ingestion and stay on the v2 roadmap." - q: "Where do Stakewiz and Jito Kobe data come from?" a: "Stakewiz (`api.stakewiz.com`) is a community-maintained Solana validator analytics service that ingests on-chain vote-account state, commission history, leader schedule and reward distribution, then publishes a clean `/validators` JSON with `total_apy` already computed as a 30-epoch trailing estimate. Jito Kobe (`kobe.mainnet.jito.network`) is Jito Labs' public API exposing per-validator MEV-tip earnings broken down by epoch. Both are free, key-less, and well-known in the Solana validator-ops community, this bench treats them as the canonical source for Solana validator APR the same way most Solana staking dashboards do (Solana Beach, Marinade UI, etc., all consume one or both)." - q: "Why cap Solana at the top 200 validators?" @@ -152,10 +168,24 @@ providers: sample_size: ocb_chain_total_validators{chain="hyperliquid"} series: ocb_chain_median_net_yield_bps{chain="hyperliquid"} -# Ethereum deferred to v2, requires beacon-node integration or -# per-relay MEV-Boost scraping for honest validator-level APR. -# The v1 scope was "free no-key public APIs exposing the full -# active validator set in one HTTP call"; Ethereum doesn't fit -# that constraint. When the v2 ingestion lands, Ethereum joins -# this bench as a third provider on the same `ocb_chain_median_net_yield_bps` -# metric without schema change. + - slug: ethereum + name: Ethereum + tag: Network avg · consensus spec APR, MEV excluded + formula: "Network-average consensus-layer APR in bps for a 32 ETH solo validator: APR = 64 × epochs_per_year / sqrt(total effective balance in gwei), with live total stake from ultrasound.money. Execution tips and MEV excluded." + queries: + p50: ocb_chain_median_net_yield_bps{chain="ethereum"} + p90: quantile(0.90, ocb_validator_net_yield_bps{chain="ethereum"}) + p99: quantile(0.99, ocb_validator_net_yield_bps{chain="ethereum"}) + mean: avg(ocb_validator_net_yield_bps{chain="ethereum"}) + success: clamp_max(avg_over_time(ocb_validator_uptime_pct{chain="ethereum"}[24h]) / 100, 1) + sample_size: ocb_chain_total_validators{chain="ethereum"} + series: ocb_chain_median_net_yield_bps{chain="ethereum"} + +# Ethereum publishes ONE synthetic series (validator="beacon-network"), +# the network-average consensus-layer nominal APR from the beacon spec +# reward formula on the live total effective balance (ultrasound.money, +# free, no key). Consensus rewards are uniform per 32 ETH increment, so +# the network average IS the solo-validator number. Execution tips and +# MEV are excluded; per-validator Ethereum gauges (beacon-node +# ingestion) and MEV inclusion (per-relay MEV-Boost scraping) remain v2. +# Source-selection notes live in harnesses/validator-yield/cmd/script/ethereum.go. diff --git a/harnesses/bridge-monitor/cmd/monitor/across_bridge.go b/harnesses/bridge-monitor/cmd/monitor/across_bridge.go new file mode 100644 index 00000000..fa43f673 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/across_bridge.go @@ -0,0 +1,229 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +// AcrossBridge integrates the Across Swap API (app.across.to/api/swap/approval) +// into the quote loop. Public REST, keyless (verified live 2026-07-08). +// +// Why the Swap API and not /suggested-fees: two of this bench's corridors are +// cross-asset (Base USDC → Arb USDT, Arb USDT → Sol USDC) and suggested-fees +// only quotes same-asset deposits. The swap endpoint quotes both shapes with +// one response schema and provider-computed USD fee totals, so every corridor +// uses the same all-in definition. Solana is a first-class origin/destination +// (SVM spoke pool, chainId 34268394551451), verified live both directions. +// +// HyperCore is skipped: Across's chainId 999 destination is HyperEVM, where +// delivery is an ERC-20 transfer on the EVM chain, NOT a HyperCore perp +// account credit like the other providers quote on the Arb → HyperCore +// corridor. Publishing it would compare different deliverables. +// +// ── Providers evaluated for this bench and dropped ── +// +// Stargate: the keyless stargate.finance/api/v1/quotes endpoint is deprecated +// (returns a deprecation notice pointing at transfer.layerzero-api.com), and +// the replacement VT API answers {"error":"Unauthorized"} without an API key +// (verified 2026-07-08). The remaining keyless path is an on-chain +// quoteOFT/quoteSend eth_call, but Stargate pools are same-asset only and +// EVM-only for that call path, which matches zero of this bench's corridors: +// the EVM legs are cross-asset (USDC→USDT / USDT→USDC) and the same-asset +// legs start or end on Solana. Building SVM quote plumbing for a provider +// that still could not quote any corridor is disproportionate, so Stargate +// is excluded rather than misrepresented. +// +// CCTP: Circle's burn/mint has no quote API that returns a delivered amount. +// Standard transfers mint exactly 1:1 and the entire cost is source plus +// destination gas the user pays out of band; CCTP v2 fast transfers expose a +// bps fee (iris-api.circle.com/v2/burn/USDC/fees) but still no delivery +// quote. Producing a row would require us to estimate gas ourselves, which +// is a different definition from the quoted-USD-in minus quoted-USD-delivered +// number every other row publishes. CCTP is also USDC-only, so only one of +// the four corridors is even in scope. Excluded by design; disclosed in the +// bench spec methodology. +type AcrossBridge struct { + client *http.Client +} + +func NewAcrossBridge() *AcrossBridge { + return &AcrossBridge{client: &http.Client{Timeout: 45 * time.Second}} +} + +// Chain ID mapping for Across (Solana uses Across's SVM chain id). +func acrossChainID(chain string) int64 { + switch strings.ToLower(chain) { + case "solana": + return 34268394551451 + case "base": + return 8453 + case "arbitrum": + return 42161 + } + return 0 +} + +// acrossSupportedToken restricts Across to the stablecoin corridors this +// bench sweeps (Sol/Base/Arb USDC + Arb USDT). The TRUMP → BRETT calibration +// route is deliberately not quoted: the Swap API would wrap two DEX swaps +// around the bridge leg, and the route is excluded from the bench queries +// anyway (from_token filter), so quoting it only burns rate budget. +func acrossSupportedToken(chain, token string) bool { + switch strings.ToLower(chain) { + case "solana": + return token == "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" // USDC mint, case-sensitive + case "base": + return strings.EqualFold(token, "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") // USDC + case "arbitrum": + return strings.EqualFold(token, "0xaf88d065e77c8cc2239327c5edb3a432268e5831") || // USDC + strings.EqualFold(token, "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9") // USDT + } + return false +} + +// AcrossFeeUSD is one USD-denominated fee bucket in the Swap API response. +// amountUsd is a JSON string (decimal), same as LiFi/Relay. +type AcrossFeeUSD struct { + AmountUsd string `json:"amountUsd"` +} + +type AcrossSwapResponse struct { + Fees struct { + Total struct { + AmountUsd string `json:"amountUsd"` + Details struct { + Bridge struct { + AmountUsd string `json:"amountUsd"` + Details struct { + DestinationGas AcrossFeeUSD `json:"destinationGas"` + } `json:"details"` + } `json:"bridge"` + } `json:"details"` + } `json:"total"` + } `json:"fees"` + InputAmount string `json:"inputAmount"` + ExpectedOutputAmount string `json:"expectedOutputAmount"` + ExpectedFillTime float64 `json:"expectedFillTime"` + // Error shape (non-200): {"message": "..."} or {"error": "..."} + Message string `json:"message,omitempty"` + ErrorID string `json:"error,omitempty"` +} + +func (a *AcrossBridge) GetQuote(route TestRoute, rawAmount, depositor, recipient string) (*AcrossSwapResponse, time.Duration, error) { + start := time.Now() + + url := fmt.Sprintf( + "https://app.across.to/api/swap/approval?tradeType=exactInput&amount=%s&inputToken=%s&originChainId=%d&outputToken=%s&destinationChainId=%d&depositor=%s&recipient=%s", + rawAmount, route.FromToken, acrossChainID(route.FromChain), + route.ToToken, acrossChainID(route.ToChain), depositor, recipient, + ) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, time.Since(start), fmt.Errorf("across request create: %w", err) + } + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0") + req.Header.Set("Accept", "application/json") + + resp, err := a.client.Do(req) + if err != nil { + return nil, time.Since(start), fmt.Errorf("across request: %w", err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + latency := time.Since(start) + if resp.StatusCode != http.StatusOK { + return nil, latency, fmt.Errorf("across %d: %s", resp.StatusCode, truncate(string(raw), 300)) + } + + var out AcrossSwapResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, latency, fmt.Errorf("across decode: %w", err) + } + if out.Message != "" || out.ErrorID != "" { + return nil, latency, fmt.Errorf("across error: %s %s", out.ErrorID, out.Message) + } + return &out, latency, nil +} + +func (a *AcrossBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawUnits string, region, solAddress, evmAddress string) { + amountStr := strconv.FormatFloat(amountUsd, 'f', 0, 64) + labels := []string{"across", route.FromChain, route.ToChain, route.FromToken, route.ToToken, amountStr, region, route.ToChain} + + // Unsupported corridors (HyperCore destination, non-stable calibration + // tokens) return early WITHOUT emitting metrics, same convention as + // Near Intents, so coverage asymmetry never shows up as fake failures. + if acrossChainID(route.FromChain) == 0 || acrossChainID(route.ToChain) == 0 { + return + } + if !acrossSupportedToken(route.FromChain, route.FromToken) || !acrossSupportedToken(route.ToChain, route.ToToken) { + return + } + + depositor := evmAddress + recipient := evmAddress + if route.FromChain == "Solana" { + depositor = solAddress + } + if route.ToChain == "Solana" { + recipient = solAddress + } + + quote, quoteLatency, err := a.GetQuote(route, rawUnits, depositor, recipient) + + if err != nil { + log.Printf("[ACROSS][%s][%.0f USD] ❌ %v", route.Name, amountUsd, err) + bridgeErrors.WithLabelValues(append(labels, "quote_failed")...).Inc() + bridgeQuoteSuccess.WithLabelValues(labels...).Set(0) + return + } + + // Latency is only meaningful for quotes that returned a usable route + // (same rule as the other bridges). Fast 4xx rejections must not enter + // the histogram or they skew the leaderboard. + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) + + // fees.total.amountUsd is Across's own USD valuation of input value minus + // expected delivered value: relayer capital + LP fee + destination gas + + // any origin/destination swap impact (signed) + app fee. That is exactly + // the quoted-in minus quoted-delivered definition the other rows publish. + costUsd, _ := strconv.ParseFloat(quote.Fees.Total.AmountUsd, 64) + if costUsd < 0 { + // Swap impact in the user's favour (e.g. USDT above peg) can push the + // total negative. Same clamp as the other providers: no USD value lost. + costUsd = 0 + } + costPct := 0.0 + if amountUsd > 0 { + costPct = (costUsd / amountUsd) * 100 + } + + bridgeFeeUsd, _ := strconv.ParseFloat(quote.Fees.Total.Details.Bridge.AmountUsd, 64) + gasUsd, _ := strconv.ParseFloat(quote.Fees.Total.Details.Bridge.Details.DestinationGas.AmountUsd, 64) + slippage := costUsd - bridgeFeeUsd + if slippage < 0 { + slippage = 0 + } + + bridgeFeesUSD.WithLabelValues(labels...).Set(bridgeFeeUsd) + bridgeFeesPercent.WithLabelValues(labels...).Set((bridgeFeeUsd / amountUsd) * 100) + bridgeCostUSD.WithLabelValues(labels...).Set(costUsd) + bridgeCostPercent.WithLabelValues(labels...).Set(costPct) + bridgeSlippageUSD.WithLabelValues(labels...).Set(slippage) + bridgeGasUSD.WithLabelValues(labels...).Set(gasUsd) // destination fill gas, already inside bridge fee + bridgeFixFeeUSD.WithLabelValues(labels...).Set(0) + bridgeOutputUSD.WithLabelValues(labels...).Set(amountUsd - costUsd) + bridgeEstimatedTimeMs.WithLabelValues(labels...).Set(quote.ExpectedFillTime * 1000) + + log.Printf("[ACROSS][%s][%.0f USD] ✅ Quote: %dms | Cost: $%.4f (%.3f%%) | Est: %.0fs", + route.Name, amountUsd, quoteLatency.Milliseconds(), + costUsd, costPct, quote.ExpectedFillTime) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/across_bridge_test.go b/harnesses/bridge-monitor/cmd/monitor/across_bridge_test.go new file mode 100644 index 00000000..16edfe41 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/across_bridge_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/json" + "strconv" + "testing" +) + +// 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 +// (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}` + +func TestAcrossSwapResponseParsing(t *testing.T) { + var out AcrossSwapResponse + if err := json.Unmarshal([]byte(acrossLiveSwapResponse), &out); err != nil { + t.Fatalf("decode live across response: %v", err) + } + costUsd, err := strconv.ParseFloat(out.Fees.Total.AmountUsd, 64) + if err != nil { + t.Fatalf("parse fees.total.amountUsd: %v", err) + } + // $1.0992 all-in on a $300 notional = 0.366 percent. Sanity-bound rather + // than exact-match so refreshing the fixture with a new live capture + // does not require editing the assertion. + if costUsd <= 0 || costUsd > 30 { + t.Fatalf("cost out of sane range: %v", costUsd) + } + costPct := costUsd / 300 * 100 + if costPct <= 0 || costPct > 10 { + t.Fatalf("cost pct out of sane range: %v", costPct) + } + bridgeFeeUsd, err := strconv.ParseFloat(out.Fees.Total.Details.Bridge.AmountUsd, 64) + if err != nil { + t.Fatalf("parse bridge fee: %v", err) + } + if bridgeFeeUsd <= 0 { + t.Fatalf("bridge fee should be positive, got %v", bridgeFeeUsd) + } + gasUsd, err := strconv.ParseFloat(out.Fees.Total.Details.Bridge.Details.DestinationGas.AmountUsd, 64) + if err != nil { + t.Fatalf("parse destination gas: %v", err) + } + if gasUsd <= 0 { + t.Fatalf("destination gas should be positive, got %v", gasUsd) + } + if out.ExpectedFillTime <= 0 { + t.Fatalf("expectedFillTime should be positive, got %v", out.ExpectedFillTime) + } + if out.Message != "" || out.ErrorID != "" { + t.Fatalf("unexpected error fields: %q %q", out.Message, out.ErrorID) + } +} + +func TestAcrossRouteSupport(t *testing.T) { + routes := GetTestRoutes() + supported := map[string]bool{} + for _, r := range routes { + ok := acrossChainID(r.FromChain) != 0 && acrossChainID(r.ToChain) != 0 && + acrossSupportedToken(r.FromChain, r.FromToken) && acrossSupportedToken(r.ToChain, r.ToToken) + supported[r.Name] = ok + } + 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 + "TRUMP_SOL_BRETT_BASE": false, // calibration route, excluded + "USDC_ARB_HYPERCORE": false, // Across 999 is HyperEVM, not a HyperCore credit + } { + if supported[name] != want { + t.Errorf("route %s: supported=%v, want %v", name, supported[name], want) + } + } +} diff --git a/harnesses/bridge-monitor/cmd/monitor/main.go b/harnesses/bridge-monitor/cmd/monitor/main.go index 55551933..5f867994 100644 --- a/harnesses/bridge-monitor/cmd/monitor/main.go +++ b/harnesses/bridge-monitor/cmd/monitor/main.go @@ -53,6 +53,9 @@ func main() { log.Println("⚠️ Li.Fi bridge initialized (no API key - rate limited to 75 req/2h)") } + acrossBridge := NewAcrossBridge() + log.Println("✅ Across bridge initialized (no key needed)") + nearIntentsBridge := NewNearIntentsBridge(config.NearIntentsAPIKey) if config.NearIntentsAPIKey != "" { log.Println("✅ Near Intents bridge initialized (with partner JWT)") @@ -220,7 +223,7 @@ func main() { } // Run quote tests immediately on startup (all routes) - runQuoteTests(mobulaBridge, relayBridge, debridgeBridge, lifiBridge, nearIntentsBridge, allRoutes, config.MonitorRegion, solAddress, evmAddress) + runQuoteTests(mobulaBridge, relayBridge, debridgeBridge, lifiBridge, acrossBridge, nearIntentsBridge, allRoutes, config.MonitorRegion, solAddress, evmAddress) // If in dry-run mode, run a single dry-run test if config.ExecutionMode == "dry-run" && executor != nil { @@ -266,7 +269,7 @@ func main() { done := make(chan struct{}) go func() { defer close(done) - runQuoteTests(mobulaBridge, relayBridge, debridgeBridge, lifiBridge, nearIntentsBridge, GetTestRoutes(), config.MonitorRegion, solAddress, evmAddress) + runQuoteTests(mobulaBridge, relayBridge, debridgeBridge, lifiBridge, acrossBridge, nearIntentsBridge, GetTestRoutes(), config.MonitorRegion, solAddress, evmAddress) }() select { case <-done: @@ -453,6 +456,7 @@ func runQuoteTests( relayBridge *RelayBridge, debridgeBridge *DebridgeBridge, lifiBridge *LiFiBridge, + acrossBridge *AcrossBridge, nearIntentsBridge *NearIntentsBridge, routes []TestRoute, region, solAddress, evmAddress string, ) { @@ -486,6 +490,13 @@ func runQuoteTests( lifiBridge.TestRoute(route, amount, amountUsd, rawUnits, region, solAddress, evmAddress) time.Sleep(500 * time.Millisecond) + // Across covers the Sol/Base/Arb stablecoin corridors via the + // keyless Swap API (cross-asset legs included). HyperCore and the + // TRUMP calibration route return early inside TestRoute without + // emitting metrics, so coverage gaps stay honest. + acrossBridge.TestRoute(route, amount, amountUsd, rawUnits, region, solAddress, evmAddress) + time.Sleep(500 * time.Millisecond) + // Near Intents covers Sol/Base/Arb USDC bi-directionally and HyperCore // as destination only. Sources outside that set return early inside // TestRoute without emitting metrics, so the bench-fee bucket stays diff --git a/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go b/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go index d5adf41c..645f2f0c 100644 --- a/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go @@ -230,6 +230,16 @@ func (n *NearIntentsBridge) TestRoute(route TestRoute, amount, amountUsd float64 // bridge_cost_usd and leave the breakdown buckets at 0 to make it obvious // in the data that no per-component decomposition is available from this // provider. + // + // Destination delivery IS included in this number. A 2026-07 audit + // flagged inUsd-outUsd as missing destination-chain gas; that is wrong. + // The 1Click OpenAPI documents the quote's `withdrawFee` (destination + // withdrawal fee, smallest unit of the destination asset) as "already + // accounted for in the final amountOut result", and live dry quotes + // (verified 2026-07-08, Base→Arb $300 USDC) return withdrawFee alongside + // the netted amountOut. So inUsd-outUsd is the same all-in definition as + // the other bridges (source fee + spread + destination delivery). Do NOT + // add a separate destination gas term on top: it would double count. costUsd := inUsd - outUsd if costUsd < 0 { costUsd = 0 diff --git a/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go b/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go index 908bfbcb..708aa052 100644 --- a/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go @@ -198,7 +198,17 @@ func (r *RelayBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawU relayerGas, _ := strconv.ParseFloat(quote.Fees.RelayerGas.AmountUsd, 64) relayerSvc, _ := strconv.ParseFloat(quote.Fees.RelayerService.AmountUsd, 64) - costUsd := -impact // impact is negative in Relay response + // details.totalImpact.usd is signed from the user's perspective: + // negative when the user loses USD value (the normal case, so -impact + // is the positive cost), positive when the quoted output is worth MORE + // than the input at oracle prices. The Base USDC → Arb USDT corridor + // sits in the second case whenever USDT trades above USDC (verified + // live 2026-07-08: totalImpact +$0.044 on $300), so this corridor's + // cost clamps to 0 below. That zero is a genuine "no USD value lost" + // quote under the all-in in-minus-out definition, not missing data. + // The bench spec aggregates across corridors so a single clamped + // corridor cannot render Relay's headline as a flat 0.000%. + costUsd := -impact if costUsd == 0 { costUsd = (inUsd - outUsd) } diff --git a/harnesses/l1-finality/.env.example b/harnesses/l1-finality/.env.example index 99db34c3..369a0c94 100644 --- a/harnesses/l1-finality/.env.example +++ b/harnesses/l1-finality/.env.example @@ -10,6 +10,7 @@ RPC_STELLAR= RPC_HEDERA= RPC_SUI= RPC_TON= +RPC_BITCOIN= RPC_LITECOIN= RPC_MONERO= RPC_CARDANO= diff --git a/harnesses/l1-finality/README.md b/harnesses/l1-finality/README.md index 22a9a9a1..96b8a737 100644 --- a/harnesses/l1-finality/README.md +++ b/harnesses/l1-finality/README.md @@ -33,6 +33,7 @@ This works for: **BNB, Avalanche, Gram**. | **Hedera** | HTTP poll | Mirror `/api/v1/blocks?order=desc&limit=2` | 1 block back. Timestamps parsed at ns precision | Hashgraph aBFT deterministic | | **SUI** | HTTP poll | `sui_getLatestCheckpointSequenceNumber` + `sui_getCheckpoint` | 1 checkpoint back | Circle USDC = 1, Mysticeti finalizes in 1 | | **Gram** | SSE wall-clock | `tonapi.io/v2/sse/blocks?workchain=-1` (masterchain only) | Time between consecutive masterchain blocks | Gram (formerly TON) docs: a tx is final once included in a masterchain block, so block_N is final when block_N+1 commits | +| **Bitcoin** | HTTP poll, own-clock wall-clock (probabilistic) | mempool.space `/blocks/tip/height` every 30 s, User-Agent `OpenChainBench-harness/1.0` | Wall-clock T1→T2 between block first seen at the tip and reaching 6 confirmations, harness clock only (miner timestamps allow ~2h skew) | Exchange deposit convention; Bitcoin never reaches absolute finality | | **Litecoin** | HTTP poll (probabilistic) | blockchair `/stats.best_block_height` and `/dashboards/block/{height}.block.time` | 12 confirmations | Coinbase deposit standard, post-April-2026 13-block MWEB reorg | | **Monero** | HTTP poll (probabilistic) | monero-rpc `get_info` + `get_block_header_by_height` (with cakewallet/sethforprivacy/monerujo failover) | 10 confirmations | Wallet protocol unlock period | | **Cardano** | HTTP poll (probabilistic) | koios `/tip` + `/blocks?block_height=eq.` | 15 confirmations | Above Coinbase 10 / Kraken 15. Far below the academic k=2160 (~12 h) | diff --git a/harnesses/l1-finality/cmd/script/bitcoin_ws.go b/harnesses/l1-finality/cmd/script/bitcoin_ws.go new file mode 100644 index 00000000..4e090cb4 --- /dev/null +++ b/harnesses/l1-finality/cmd/script/bitcoin_ws.go @@ -0,0 +1,168 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// Bitcoin wall-clock measurement via HTTP polling of mempool.space. +// +// Bitcoin has probabilistic finality: no block is ever protocol-final, +// confidence grows with confirmation depth. We use the exchange deposit +// convention of 6 confirmations and measure it with our own clock, the +// same way the WS wall-clock chains are measured: +// +// T1 = first poll where block N is the chain tip +// T2 = first poll where tip - N >= 6 (block N has 6 confirmations) +// lag = T2 - T1 +// +// Own-clock is deliberate. Bitcoin block timestamps are miner-set and +// may skew by up to ~2h (consensus only requires timestamp > median of +// last 11 and < network time + 2h), so a timestamp-based estimator can +// be wildly wrong on individual samples. Our own clock matches how every +// other chain in this harness is measured (harness-observed wall clock). +// +// Baseline rule: on the first successful poll we only record the tip +// height as a baseline and never record a firstSeen for it. That block +// may have been sitting at the tip for minutes before we started, so +// timing it would fabricate a sample. The first publish can therefore +// only happen after a genuinely observed arrival -> 6-conf transition: +// roughly 7 block intervals (~70 min) after process start. +// +// If the poll interval skips heights (tip jumps by 2+, or a reorg +// replaces the tip), the skipped heights never get a firstSeen entry and +// never produce a sample. We only publish for blocks whose arrival at +// the tip we actually observed. No publish on fetch failure. +// +// Cadence: 30s. Bitcoin blocks land every ~10 min on average, so this is +// gentle on mempool.space (free, keyless) and the <=30s quantization is +// noise against a ~60 min measurement. + +const ( + bitcoinConfirmations = 6 + bitcoinPollInterval = 30 * time.Second + bitcoinUserAgent = "OpenChainBench-harness/1.0" +) + +// StartBitcoinWallClock launches a goroutine that polls the mempool.space +// tip height every 30s and emits l1_finality_wallclock_lag_milliseconds +// when an observed block reaches 6 confirmations. +func StartBitcoinWallClock() { + base := strings.TrimRight(getenvDefault("RPC_BITCOIN", "https://mempool.space/api"), "/") + go func() { + backoff := 2 * time.Second + for { + err := runBitcoinPoll(base) + if err != nil { + fmt.Printf("[L1][bitcoin] poll error: %v (retrying in %v)\n", err, backoff) + wallClockHealth.WithLabelValues("bitcoin").Set(0) + } + time.Sleep(backoff) + if backoff < 60*time.Second { + backoff *= 2 + } + } + }() +} + +func runBitcoinPoll(base string) error { + client := &http.Client{Timeout: 10 * time.Second} + + // firstSeen[height] = our clock when that height was first observed + // as the chain tip. Populated only after the baseline poll. + firstSeen := map[int64]time.Time{} + var lastTip int64 // 0 = baseline not taken yet + + tick := time.NewTicker(bitcoinPollInterval) + defer tick.Stop() + consecErrors := 0 + + // Immediate first poll so the baseline is set at startup rather than + // one full tick later. + for ; ; <-tick.C { + tip, err := bitcoinTipHeight(client, base) + if err != nil { + consecErrors++ + wallClockHealth.WithLabelValues("bitcoin").Set(0) + if consecErrors >= 10 { + return fmt.Errorf("too many errors: %w", err) + } + continue + } + consecErrors = 0 + wallClockHealth.WithLabelValues("bitcoin").Set(1) + now := time.Now() + + if lastTip == 0 { + // Baseline: this block was already at the tip when we + // started, we did not observe its arrival. Do not time it. + lastTip = tip + fmt.Printf("[L1][bitcoin] baseline tip=%d (timing starts with the next block)\n", tip) + continue + } + + if tip > lastTip { + // Only the current tip gets a firstSeen. If the tip jumped + // more than one height between polls we did not observe the + // intermediate blocks arriving, so they are never timed. + firstSeen[tip] = now + fmt.Printf("[L1][bitcoin] new tip=%d (tracking for %d confirmations)\n", tip, bitcoinConfirmations) + lastTip = tip + } else if tip < lastTip { + // Reorg: the previous tip was orphaned. Heights above the + // new tip were replaced; drop their timers so we never + // publish a sample for a block that left the best chain. + for h := range firstSeen { + if h > tip { + delete(firstSeen, h) + } + } + fmt.Printf("[L1][bitcoin] reorg: tip regressed %d -> %d\n", lastTip, tip) + lastTip = tip + } + + for h, t1 := range firstSeen { + if tip-h >= bitcoinConfirmations { + lagMs := float64(now.Sub(t1).Milliseconds()) + if lagMs >= 0 { + wallClockLagGauge.WithLabelValues("bitcoin").Set(lagMs) + wallClockLagSum.WithLabelValues("bitcoin").Observe(lagMs) + wallClockSampleCtr.WithLabelValues("bitcoin").Inc() + fmt.Printf("[L1][bitcoin] block=%d reached %d confirmations, wall-clock-lag=%.0fms (%.1f min)\n", + h, bitcoinConfirmations, lagMs, lagMs/60000) + } + delete(firstSeen, h) + } + } + } +} + +// bitcoinTipHeight calls GET /blocks/tip/height, a plain-text integer. +func bitcoinTipHeight(client *http.Client, base string) (int64, error) { + req, err := http.NewRequest("GET", base+"/blocks/tip/height", nil) + if err != nil { + return 0, err + } + req.Header.Set("User-Agent", bitcoinUserAgent) + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode != 200 { + return 0, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + n, err := strconv.ParseInt(strings.TrimSpace(string(body)), 10, 64) + if err != nil { + return 0, fmt.Errorf("parse_height: %v", err) + } + if n <= 0 { + return 0, fmt.Errorf("bad_height: %d", n) + } + return n, nil +} diff --git a/harnesses/l1-finality/cmd/script/main.go b/harnesses/l1-finality/cmd/script/main.go index 45a97bba..fe239c2a 100644 --- a/harnesses/l1-finality/cmd/script/main.go +++ b/harnesses/l1-finality/cmd/script/main.go @@ -44,6 +44,7 @@ func main() { StartTONWallClock() StartSUIWallClock() StartStellarWallClock() + StartBitcoinWallClock() <-sigChan fmt.Println("\nShutting down...") diff --git a/harnesses/l1-finality/cmd/script/stellar_ws.go b/harnesses/l1-finality/cmd/script/stellar_ws.go index a5dfe756..6d287eb5 100644 --- a/harnesses/l1-finality/cmd/script/stellar_ws.go +++ b/harnesses/l1-finality/cmd/script/stellar_ws.go @@ -35,7 +35,8 @@ type stellarLedgerEvent struct { func StartStellarWallClock() { go func() { - backoff := 2 * time.Second + const baseBackoff = 1 * time.Second + backoff := baseBackoff cursor := "now" var lastSeen time.Time var lastSeq int64 @@ -44,10 +45,28 @@ func StartStellarWallClock() { if nextCursor != "" { cursor = nextCursor } - if err != nil { - fmt.Printf("[L1][stellar] SSE error: %v (reconnecting in %v)\n", err, backoff) - wallClockHealth.WithLabelValues("stellar").Set(0) + if err == nil { + // Graceful close. Horizon force-closes every SSE + // connection after ~10s with `event: close` and expects + // the client to reconnect using cursor=. That is + // the protocol working as designed, not a failure. + // Reconnect quickly and keep the backoff at its floor. + // + // BUG HISTORY (why the Stellar row went dead): the + // previous loop treated every stream end as an error and + // doubled the backoff without ever resetting it, so + // within a minute of startup it slept 64s between ~10s + // connection windows. During each window the replayed + // ledgers failed the 6s liveness filter and the first + // live event exceeded the 30s sanity bound, so the gauge + // almost never received a sample and last_over_time on + // the page eventually returned nothing. + backoff = baseBackoff + time.Sleep(baseBackoff) + continue } + fmt.Printf("[L1][stellar] SSE error: %v (reconnecting in %v)\n", err, backoff) + wallClockHealth.WithLabelValues("stellar").Set(0) time.Sleep(backoff) if backoff < 60*time.Second { backoff *= 2 @@ -156,5 +175,7 @@ func runStellarSSE(cursor string, lastSeen *time.Time, lastSeq *int64) (string, if err := scanner.Err(); err != nil { return lastID, fmt.Errorf("scan: %w", err) } - return lastID, fmt.Errorf("stream closed") + // Clean EOF: Horizon's routine ~10s force-close. Not an error; the + // caller reconnects immediately with the returned cursor. + return lastID, nil } diff --git a/harnesses/perp-fees/cmd/script/config.go b/harnesses/perp-fees/cmd/script/config.go index ac0d62ce..1359d480 100644 --- a/harnesses/perp-fees/cmd/script/config.go +++ b/harnesses/perp-fees/cmd/script/config.go @@ -33,10 +33,10 @@ func loadConfig() *Config { {slug: "hyperliquid", display: "Hyperliquid", assets: []string{"ETH", "BTC", "SOL"}}, {slug: "dydx", display: "dYdX v4", assets: []string{"ETH", "BTC", "SOL"}}, {slug: "lighter", display: "Lighter", assets: []string{"ETH", "BTC", "SOL"}}, - // GMX v2 has separate market addresses per asset; only ETH wired up - // for now. BTC market address would need to be added to the gmx - // fetcher to support multi-asset. - {slug: "gmx", display: "GMX v2", assets: []string{"ETH"}}, + // GMX v2 has separate market addresses per asset; the gmx fetcher + // maps asset → market address (gmxMarkets in gmx.go), verified + // against gmxinfra /markets/info. + {slug: "gmx", display: "GMX v2", assets: []string{"ETH", "BTC", "SOL"}}, // Gains v8 on Base — reads fees on-chain via Base RPC. Pair index // for each asset is discovered by scanning the diamond's // `pairs(N)` slot for the asset/USD name match (findGainsPair in diff --git a/harnesses/perp-fees/cmd/script/dydx.go b/harnesses/perp-fees/cmd/script/dydx.go index 44c08d35..5b6d4ebe 100644 --- a/harnesses/perp-fees/cmd/script/dydx.go +++ b/harnesses/perp-fees/cmd/script/dydx.go @@ -68,26 +68,18 @@ func fetchDYdX(v VenueConfig) PerpSample { s.MidPrice = mid // Walk asks - matched, totalQty := 0.0, 0.0 + levels := make([]bookLevel, 0, len(book.Asks)) for _, a := range book.Asks { px, _ := strconv.ParseFloat(a.Price, 64) sz, _ := strconv.ParseFloat(a.Size, 64) - levelCost := px * sz - if matched+levelCost >= v.NotionalUSD { - partial := (v.NotionalUSD - matched) / px - totalQty += partial - matched = v.NotionalUSD - break - } - matched += levelCost - totalQty += sz + levels = append(levels, bookLevel{Px: px, Sz: sz}) } - if matched < v.NotionalUSD*0.99 || totalQty == 0 { - s.Err = fmt.Sprintf("insufficient_depth: matched=%.2f", matched) + effective, err := walkBookForNotional(levels, v.NotionalUSD) + if err != nil { + s.Err = fmt.Sprintf("walk: %v", err) s.FetchLatencyMs = time.Since(start).Milliseconds() return s } - effective := v.NotionalUSD / totalQty s.SpreadBps = (effective - mid) / mid * 10000 // 2) Funding rate @@ -99,16 +91,30 @@ func fetchDYdX(v VenueConfig) PerpSample { } } - // 3) Fee tier from chain REST + // 3) Fee tier from chain REST. Hard requirement: if this fails the + // all-in number would silently miss the taker fee, so we error out and + // skip publishing this cycle instead. var feeParams dydxFeeParams - if err := dydxGet(client, dydxChainREST+"/dydxprotocol/v4/feetiers/perpetual_fee_params", &feeParams); err == nil && len(feeParams.Params.Tiers) > 0 { - // Tier 0 is the default for any user not in higher tiers - t0 := feeParams.Params.Tiers[0] - // ppm = parts per million; bps = ppm / 100 - s.TakerFeeBps = float64(t0.TakerFeePpm) / 100.0 + if err := dydxGet(client, dydxChainREST+"/dydxprotocol/v4/feetiers/perpetual_fee_params", &feeParams); err != nil { + s.Err = fmt.Sprintf("feetiers: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + if len(feeParams.Params.Tiers) == 0 { + s.Err = "feetiers_empty" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s } + // Tier 0 is the default for any user not in higher tiers + t0 := feeParams.Params.Tiers[0] + // ppm = parts per million; bps = ppm / 100 + s.TakerFeeBps = float64(t0.TakerFeePpm) / 100.0 s.AllInBps = s.TakerFeeBps + s.SpreadBps + // Notional tiers: rewalk the already-fetched book at $1k/$10k/$100k. + // The indexer book is depth-limited; tiers the book cannot fill are + // skipped (counted), not extrapolated. + applyBookTiers(&s, levels, mid) s.FetchLatencyMs = time.Since(start).Milliseconds() return s } diff --git a/harnesses/perp-fees/cmd/script/gains.go b/harnesses/perp-fees/cmd/script/gains.go index 16cbfe5a..2d9d1f7f 100644 --- a/harnesses/perp-fees/cmd/script/gains.go +++ b/harnesses/perp-fees/cmd/script/gains.go @@ -21,7 +21,8 @@ import ( // Diamond: 0x6cd5ac19a07518a8092eeffda4f1174c72704eeb (Base mainnet) // Functions: // pairs(uint256) -> struct, contains spreadP and feeIndex -// fees(uint256) -> struct, openFeeP is the first uint256 +// fees(uint256) -> struct, first uint256 is totalPositionSizeFeeP +// (the open fee; matches the backend fees[] array) // // Asset → pairIndex mapping is read from on-chain pair name strings // so we don't hardcode it either. @@ -68,6 +69,11 @@ func ethCall(client *http.Client, to, data string) (string, error) { } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) + // Surface HTTP-level failures (429 from public Base RPC, 5xx) as such + // instead of a generic JSON parse error, so classifyErr can bucket them. + if resp.StatusCode != 200 { + return "", fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(respBody), 120)) + } var r rpcResp if err := json.Unmarshal(respBody, &r); err != nil { return "", fmt.Errorf("parse: %w", err) @@ -130,12 +136,13 @@ 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 (verified against gains.trade UI 2026-05-07): - // slot 7 → spreadP (full bid-ask spread, 1e10 precision) - // slot 9 → feeIndex (the fees(N) tier — was wrongly read at 10 before) - // slot 10 → groupIndex - // Reading slot 9 + applying half-spread (/2) gives the same numbers - // the gains.trade frontend displays for ETH and BTC. + // Pair struct slot layout (re-verified against the gains.trade backend + // /trading-variables on 2026-07-08): + // slot 7 → spreadP (full bid-ask spread, 1e10 precision) + // slot 8 → groupIndex + // slot 9 → feeIndex (the fees(N) tier; ETH and BTC use 13, SOL 11) + // Reading slot 9 + applying half-spread (/2) matches the backend values + // exactly (ETH/BTC spreadP=1e8, SOL spreadP=0). return &gainsPair{ From: from, To: to, @@ -154,12 +161,18 @@ func gainsReadFeeOpenP(client *http.Client, feeIndex int64) (*big.Int, error) { } var ( - gainsPairCache = map[string]*gainsPair{} // by uppercase asset name - gainsPairIdxCache = map[string]int{} // by uppercase asset name - gainsFeeCache = map[int64]gainsFeeEntry{} - gainsCacheMu sync.Mutex + gainsPairCache = map[string]*gainsPair{} // by uppercase asset name + gainsPairIdxCache = map[string]int{} // by uppercase asset name + gainsPairAt = map[string]time.Time{} // last on-chain read per asset + gainsFeeCache = map[int64]gainsFeeEntry{} + gainsCacheMu sync.Mutex ) +// Pair config (spreadP, feeIndex) changes only by governance, but caching it +// forever would freeze the published spread until a process restart. Re-read +// the known pair index every TTL so config changes surface within hours. +const gainsPairTTL = 6 * time.Hour + type gainsFeeEntry struct { openFeeP *big.Int at time.Time @@ -173,11 +186,32 @@ func findGainsPair(client *http.Client, asset string) (*gainsPair, error) { upper := strings.ToUpper(asset) gainsCacheMu.Lock() - if p, ok := gainsPairCache[upper]; ok { + cached, ok := gainsPairCache[upper] + fresh := ok && time.Since(gainsPairAt[upper]) < gainsPairTTL + idx, hasIdx := gainsPairIdxCache[upper] + gainsCacheMu.Unlock() + if fresh { + return cached, nil + } + + // Known index, stale cache: re-read that single pair instead of a full + // scan. On failure we return the error so the cycle skips publishing + // (visible via fetch_errors_total) rather than silently reusing config + // that may have changed on-chain. + if ok && hasIdx { + p, err := gainsReadPair(client, idx) + if err != nil { + return nil, fmt.Errorf("refresh pair %d: %w", idx, err) + } + if !strings.EqualFold(p.From, asset) || !strings.EqualFold(p.To, "USD") { + return nil, fmt.Errorf("pair %d is now %s/%s, expected %s/USD", idx, p.From, p.To, asset) + } + gainsCacheMu.Lock() + gainsPairCache[upper] = p + gainsPairAt[upper] = time.Now() gainsCacheMu.Unlock() return p, nil } - gainsCacheMu.Unlock() gainsScanMu.Lock() defer gainsScanMu.Unlock() @@ -185,7 +219,7 @@ func findGainsPair(client *http.Client, asset string) (*gainsPair, error) { // Re-check after acquiring the scan lock — another goroutine may have // just populated the cache while we waited. gainsCacheMu.Lock() - if p, ok := gainsPairCache[upper]; ok { + if p, ok := gainsPairCache[upper]; ok && time.Since(gainsPairAt[upper]) < gainsPairTTL { gainsCacheMu.Unlock() return p, nil } @@ -195,6 +229,7 @@ func findGainsPair(client *http.Client, asset string) (*gainsPair, error) { for i := 0; i < 60; i++ { p, err := gainsReadPair(client, i) if err != nil { + fmt.Printf("[PERP][gains] pair scan idx %d: %v\n", i, err) consecutiveErrors++ if consecutiveErrors >= 3 && i > 5 { break @@ -208,6 +243,7 @@ func findGainsPair(client *http.Client, asset string) (*gainsPair, error) { gainsCacheMu.Lock() gainsPairCache[strings.ToUpper(p.From)] = p gainsPairIdxCache[strings.ToUpper(p.From)] = i + gainsPairAt[strings.ToUpper(p.From)] = time.Now() gainsCacheMu.Unlock() if strings.EqualFold(p.From, asset) && strings.EqualFold(p.To, "USD") { return p, nil @@ -215,7 +251,7 @@ func findGainsPair(client *http.Client, asset string) (*gainsPair, error) { time.Sleep(150 * time.Millisecond) } gainsCacheMu.Lock() - if p, ok := gainsPairCache[upper]; ok { + if p, ok := gainsPairCache[upper]; ok && strings.EqualFold(p.To, "USD") { gainsCacheMu.Unlock() return p, nil } @@ -240,7 +276,7 @@ func fetchGains(v VenueConfig, _ string) PerpSample { // Cache the openFeeP for 1h. Gains fee tiers change rarely (governance // vote) so polling more often just burns RPC quota. Holding the mutex // across the RPC call deduplicates concurrent fetches that share a - // feeIndex (ETH and BTC both use feeIndex 3). + // feeIndex (ETH and BTC both use feeIndex 13, SOL uses 11). gainsCacheMu.Lock() cached, ok := gainsFeeCache[pair.FeeIndex] var openFeeP *big.Int @@ -270,6 +306,16 @@ func fetchGains(v VenueConfig, _ string) PerpSample { s.TakerFeeBps = openFeeF s.SpreadBps = spreadF s.AllInBps = openFeeF + spreadF + // Notional tiers: Gains v8 fees are percentages of position size. + // totalPositionSizeFeeP and spreadP are both flat rates that do not + // change with notional (the fees(feeIndex) tier is per pair, not per + // trade size), so the bps figure is identical at $1k, $10k and $100k. + // Gains does apply dynamic price impact on pairs with spreadP=0 (SOL), + // but that value is computed inside the protocol at trade time and is + // not readable from the on-chain config this harness measures, so we + // publish the flat rate at every tier rather than invent an impact + // curve. + applyFlatTiers(&s) s.FetchLatencyMs = time.Since(start).Milliseconds() return s } diff --git a/harnesses/perp-fees/cmd/script/gmx.go b/harnesses/perp-fees/cmd/script/gmx.go index dfff1759..6c2b8fb7 100644 --- a/harnesses/perp-fees/cmd/script/gmx.go +++ b/harnesses/perp-fees/cmd/script/gmx.go @@ -20,8 +20,15 @@ import ( const gmxSubsquid = "https://gmx.squids.live/gmx-synthetics-arbitrum/graphql" const gmxinfraBase = "https://arbitrum-api.gmxinfra.io" -// ETH/USD market on Arbitrum mainnet (long/short ETH, USDC collateral) -const gmxETHMarket = "0x70d95587d40A2caf56bd97485aB3Eec10Bee6336" +// GMX v2 market token addresses on Arbitrum mainnet, one market per index +// asset (USDC-collateral main markets). Verified against gmxinfra +// /markets/info (marketToken ↔ indexToken symbol) and the Subsquid +// marketInfos entities. +var gmxMarkets = map[string]string{ + "ETH": "0x70d95587d40A2caf56bd97485aB3Eec10Bee6336", + "BTC": "0x47c031236e19d024b42f8AE6780E44A573170703", + "SOL": "0x09400D9DB990D5ed3f35D7be61DfAEB900Af03C9", +} type gmxGqlReq struct { Query string `json:"query"` @@ -56,9 +63,16 @@ func fetchGMX(v VenueConfig) PerpSample { start := time.Now() client := &http.Client{Timeout: 10 * time.Second} + market, ok := gmxMarkets[v.Asset] + if !ok { + s.Err = "unsupported_asset" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + // 1) Position fee from Subsquid (live on-chain factor) q := gmxGqlReq{ - Query: `query { marketInfos(where: {id_eq: "` + gmxETHMarket + `"}) { + Query: `query { marketInfos(where: {id_eq: "` + market + `"}) { id positionFeeFactorForPositiveImpact positionFeeFactorForNegativeImpact }}`, } @@ -106,7 +120,7 @@ func fetchGMX(v VenueConfig) PerpSample { body2, _ := io.ReadAll(resp2.Body) _ = json.Unmarshal(body2, &markets) for _, mk := range markets.Markets { - if mk.MarketToken == gmxETHMarket { + if mk.MarketToken == market { // fundingFactorPerSecondLong scaled by 1e30. Convert to per-hour bps. perSec := factor1e30ToBps(mk.FundingFactorPerSecondLong) s.FundingRatePerHrBps = perSec * 3600 @@ -116,6 +130,14 @@ func fetchGMX(v VenueConfig) PerpSample { } s.AllInBps = s.TakerFeeBps + s.SpreadBps + // Notional tiers: GMX v2's positionFeeFactor is a per-notional factor + // (fee = factor x sizeDelta), so the bps figure is identical at $1k, + // $10k and $100k. The only size-dependent term is priceImpact, which + // scales with OI imbalance; against the main markets' nine-figure open + // interest even $100k moves it by well under a tenth of a bp, and the + // harness already reports the conservative negative-impact fee branch. + // Publishing the same value across tiers is therefore correct. + applyFlatTiers(&s) s.FetchLatencyMs = time.Since(start).Milliseconds() return s } diff --git a/harnesses/perp-fees/cmd/script/hyperliquid.go b/harnesses/perp-fees/cmd/script/hyperliquid.go index b0c23479..8f1cd5c3 100644 --- a/harnesses/perp-fees/cmd/script/hyperliquid.go +++ b/harnesses/perp-fees/cmd/script/hyperliquid.go @@ -69,7 +69,13 @@ func fetchHyperliquid(v VenueConfig) PerpSample { s.MidPrice = mid // Walk asks for v.NotionalUSD of buy - effective, err := walkAsksForNotional(asks, v.NotionalUSD) + levels := make([]bookLevel, 0, len(asks)) + for _, a := range asks { + px, _ := strconv.ParseFloat(a.Px, 64) + sz, _ := strconv.ParseFloat(a.Sz, 64) + levels = append(levels, bookLevel{Px: px, Sz: sz}) + } + effective, err := walkBookForNotional(levels, v.NotionalUSD) if err != nil { s.Err = fmt.Sprintf("walk: %v", err) s.FetchLatencyMs = time.Since(start).Milliseconds() @@ -100,17 +106,29 @@ func fetchHyperliquid(v VenueConfig) PerpSample { } } - // 3) Taker fee via userFees probe + // 3) Taker fee via userFees probe. Hard requirement: if this fails the + // all-in number would silently miss the fee component, so we error out + // and skip publishing this cycle instead. var fees hlUserFees if err := hlPost(client, map[string]any{ "type": "userFees", "user": "0x0000000000000000000000000000000000000000", - }, &fees); err == nil && fees.FeeSchedule.Cross != "" { - cross, _ := strconv.ParseFloat(fees.FeeSchedule.Cross, 64) - s.TakerFeeBps = cross * 10000 + }, &fees); err != nil { + s.Err = fmt.Sprintf("userFees: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + if fees.FeeSchedule.Cross == "" { + s.Err = "userFees_empty" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s } + cross, _ := strconv.ParseFloat(fees.FeeSchedule.Cross, 64) + s.TakerFeeBps = cross * 10000 s.AllInBps = s.TakerFeeBps + s.SpreadBps + // Notional tiers: rewalk the already-fetched book at $1k/$10k/$100k. + applyBookTiers(&s, levels, mid) s.FetchLatencyMs = time.Since(start).Milliseconds() return s } @@ -130,33 +148,3 @@ func hlPost(client *http.Client, body any, out any) error { } return json.Unmarshal(respBody, out) } - -// walkAsksForNotional walks the ask side until $notional has been matched -// and returns the size-weighted effective price. -func walkAsksForNotional(asks []struct { - Px string `json:"px"` - Sz string `json:"sz"` -}, notional float64) (float64, error) { - matched := 0.0 - totalQty := 0.0 - for _, a := range asks { - px, _ := strconv.ParseFloat(a.Px, 64) - sz, _ := strconv.ParseFloat(a.Sz, 64) - if px <= 0 || sz <= 0 { - continue - } - levelCost := px * sz - if matched+levelCost >= notional { - partial := (notional - matched) / px - totalQty += partial - matched = notional - break - } - matched += levelCost - totalQty += sz - } - if matched < notional*0.99 || totalQty == 0 { - return 0, fmt.Errorf("insufficient_depth: matched=%.2f of %.2f", matched, notional) - } - return notional / totalQty, nil -} diff --git a/harnesses/perp-fees/cmd/script/lighter.go b/harnesses/perp-fees/cmd/script/lighter.go index 979a60f1..61b3358c 100644 --- a/harnesses/perp-fees/cmd/script/lighter.go +++ b/harnesses/perp-fees/cmd/script/lighter.go @@ -89,29 +89,25 @@ func fetchLighter(v VenueConfig) PerpSample { s.MidPrice = mid // Walk asks - matched, totalQty := 0.0, 0.0 + levels := make([]bookLevel, 0, len(book.Asks)) for _, a := range book.Asks { px, _ := strconv.ParseFloat(a.Price, 64) sz, _ := strconv.ParseFloat(a.RemainingBaseAmount, 64) - levelCost := px * sz - if matched+levelCost >= v.NotionalUSD { - partial := (v.NotionalUSD - matched) / px - totalQty += partial - matched = v.NotionalUSD - break - } - matched += levelCost - totalQty += sz + levels = append(levels, bookLevel{Px: px, Sz: sz}) } - if matched < v.NotionalUSD*0.99 || totalQty == 0 { - s.Err = fmt.Sprintf("insufficient_depth: matched=%.2f", matched) + effective, err := walkBookForNotional(levels, v.NotionalUSD) + if err != nil { + s.Err = fmt.Sprintf("walk: %v", err) s.FetchLatencyMs = time.Since(start).Milliseconds() return s } - effective := v.NotionalUSD / totalQty s.SpreadBps = (effective - mid) / mid * 10000 s.AllInBps = s.TakerFeeBps + s.SpreadBps + // Notional tiers: rewalk the already-fetched book (top 50 levels) at + // $1k/$10k/$100k. Tiers the fetched depth cannot fill are skipped + // (counted), never extrapolated. + applyBookTiers(&s, levels, mid) s.FetchLatencyMs = time.Since(start).Milliseconds() return s } diff --git a/harnesses/perp-fees/cmd/script/main.go b/harnesses/perp-fees/cmd/script/main.go index 49bb152b..b48f3481 100644 --- a/harnesses/perp-fees/cmd/script/main.go +++ b/harnesses/perp-fees/cmd/script/main.go @@ -75,6 +75,12 @@ func fetchAll(cfg *Config) { } else { fmt.Printf("[PERP][%s/%s] all_in=%.2fbps (taker=%.2f, spread=%.2f, funding=%.4fbps/h, mid=$%.2f) in %dms\n", v.Slug, v.Asset, s.AllInBps, s.TakerFeeBps, s.SpreadBps, s.FundingRatePerHrBps, s.MidPrice, s.FetchLatencyMs) + for _, t := range s.Tiers { + fmt.Printf("[PERP][%s/%s] tier $%s: all_in=%.2fbps (spread=%.2f)\n", v.Slug, v.Asset, t.Notional, t.AllInBps, t.SpreadBps) + } + for _, n := range s.SkippedTiers { + fmt.Printf("[PERP][%s/%s] tier $%s: SKIPPED (book depth insufficient)\n", v.Slug, v.Asset, n) + } } }() } diff --git a/harnesses/perp-fees/cmd/script/metrics.go b/harnesses/perp-fees/cmd/script/metrics.go index b5a4d254..b1127391 100644 --- a/harnesses/perp-fees/cmd/script/metrics.go +++ b/harnesses/perp-fees/cmd/script/metrics.go @@ -18,6 +18,8 @@ var ( fetchErrorsCtr *prometheus.CounterVec healthGauge *prometheus.GaugeVec lastRefreshGauge *prometheus.GaugeVec + allInTierGauge *prometheus.GaugeVec + tierSkippedCtr *prometheus.CounterVec ) func init() { @@ -92,6 +94,29 @@ func init() { []string{"venue", "chain"}, ) prometheus.MustRegister(lastRefreshGauge) + + // Notional-tier companion series. perp_fees_all_in_bps keeps its + // exact $1000 meaning (live dashboards + spec queries depend on it); + // the tiers live under a separate metric so no existing query can + // match them by accident. The notional="1000" tier duplicates the + // headline series under the default config. + allInTierGauge = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_fees_all_in_bps_tier", + Help: "All-in opening cost at a given notional tier (taker fee + spread + impact), in bps. notional label is the USD trade size.", + }, + []string{"venue", "chain", "notional"}, + ) + prometheus.MustRegister(allInTierGauge) + + tierSkippedCtr = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "perp_fees_tier_skipped_total", + Help: "Tier samples skipped because the fetched orderbook could not fill the tier notional. No extrapolation: thin books show as missing data.", + }, + []string{"venue", "chain", "notional"}, + ) + prometheus.MustRegister(tierSkippedCtr) } func recordSample(s PerpSample) { @@ -107,6 +132,19 @@ func recordSample(s PerpSample) { fetchLatencyGauge.WithLabelValues(s.Venue, s.Asset).Set(float64(s.FetchLatencyMs)) lastRefreshGauge.WithLabelValues(s.Venue, s.Asset).Set(float64(time.Now().Unix())) healthGauge.WithLabelValues(s.Venue, s.Asset).Set(1) + + for _, t := range s.Tiers { + allInTierGauge.WithLabelValues(s.Venue, s.Asset, t.Notional).Set(t.AllInBps) + } + // A skipped tier means the fetched book could not fill that notional. + // Unlike whole-venue failures (where we keep the previous gauge and rely + // on health/errors), a gauge here would keep republishing a stale spread + // as if the book were deep enough. Delete the series instead so the + // tier honestly shows as missing data, and count the skip. + for _, n := range s.SkippedTiers { + tierSkippedCtr.WithLabelValues(s.Venue, s.Asset, n).Inc() + allInTierGauge.DeleteLabelValues(s.Venue, s.Asset, n) + } } func classifyErr(msg string) string { diff --git a/harnesses/perp-fees/cmd/script/types.go b/harnesses/perp-fees/cmd/script/types.go index ffe9a11d..6de847ce 100644 --- a/harnesses/perp-fees/cmd/script/types.go +++ b/harnesses/perp-fees/cmd/script/types.go @@ -12,6 +12,19 @@ type PerpSample struct { FetchLatencyMs int64 `json:"fetch_latency_ms"` At string `json:"at"` Err string `json:"error,omitempty"` + + // Notional tiers measured alongside the headline notional. Published to + // perp_fees_all_in_bps_tier{venue, chain, notional}; the headline series + // perp_fees_all_in_bps keeps its $1000 meaning untouched. + Tiers []TierSample `json:"tiers,omitempty"` + SkippedTiers []string `json:"skipped_tiers,omitempty"` // notional labels the book could not fill +} + +// TierSample is the opening cost measured at one notional tier. +type TierSample struct { + Notional string `json:"notional"` // "1000", "10000", "100000" + SpreadBps float64 `json:"spread_bps"` + AllInBps float64 `json:"all_in_bps"` } // VenueConfig describes one venue × asset pair the harness should measure. diff --git a/harnesses/perp-fees/cmd/script/walk.go b/harnesses/perp-fees/cmd/script/walk.go new file mode 100644 index 00000000..3cce7fa0 --- /dev/null +++ b/harnesses/perp-fees/cmd/script/walk.go @@ -0,0 +1,91 @@ +package main + +import ( + "fmt" + "strconv" +) + +// Notional tiers measured on every venue in addition to the headline +// notional. The headline series perp_fees_all_in_bps stays defined at the +// configured notional (default $1000); the tier gauge +// perp_fees_all_in_bps_tier{notional="1000"|"10000"|"100000"} is published +// alongside so existing dashboards and spec queries keep their meaning. +// The $1000 tier duplicates the headline series under the default config. +var tierNotionals = []float64{1000, 10000, 100000} + +func notionalLabel(n float64) string { + return strconv.FormatFloat(n, 'f', -1, 64) +} + +// bookLevel is one price level of an orderbook's ask side. +type bookLevel struct { + Px float64 + Sz float64 +} + +// walkBookForNotional walks the ask side until $notional has been matched +// and returns the size-weighted effective price. Same math as the original +// per-venue walks: partial fill on the crossing level, 1% tolerance on the +// matched total, error when the book cannot fill the notional. +func walkBookForNotional(levels []bookLevel, notional float64) (float64, error) { + matched := 0.0 + totalQty := 0.0 + for _, l := range levels { + if l.Px <= 0 || l.Sz <= 0 { + continue + } + levelCost := l.Px * l.Sz + if matched+levelCost >= notional { + partial := (notional - matched) / l.Px + totalQty += partial + matched = notional + break + } + matched += levelCost + totalQty += l.Sz + } + if matched < notional*0.99 || totalQty == 0 { + return 0, fmt.Errorf("insufficient_depth: matched=%.2f of %.2f", matched, notional) + } + return notional / totalQty, nil +} + +// applyBookTiers walks the already-fetched ask levels once per tier notional +// and fills s.Tiers with taker fee + tier spread. Call it after s.TakerFeeBps +// is known. Costs no extra API calls: the book was fetched for the headline +// walk anyway. +// +// Honesty rule for thin books: if the fetched levels cannot fill a tier's +// notional we do NOT extrapolate from the last level. The tier is recorded +// in s.SkippedTiers instead, which increments perp_fees_tier_skipped_total +// and deletes the tier gauge series so the tier shows as missing data +// rather than a fake tight spread. +func applyBookTiers(s *PerpSample, levels []bookLevel, mid float64) { + for _, n := range tierNotionals { + effective, err := walkBookForNotional(levels, n) + if err != nil { + s.SkippedTiers = append(s.SkippedTiers, notionalLabel(n)) + continue + } + spread := (effective - mid) / mid * 10000 + s.Tiers = append(s.Tiers, TierSample{ + Notional: notionalLabel(n), + SpreadBps: spread, + AllInBps: s.TakerFeeBps + spread, + }) + } +} + +// 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 +// 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) { + for _, n := range tierNotionals { + s.Tiers = append(s.Tiers, TierSample{ + Notional: notionalLabel(n), + SpreadBps: s.SpreadBps, + AllInBps: s.AllInBps, + }) + } +} diff --git a/harnesses/stablecoin-peg/README.md b/harnesses/stablecoin-peg/README.md index 0bda65e3..c3d5ecb0 100644 --- a/harnesses/stablecoin-peg/README.md +++ b/harnesses/stablecoin-peg/README.md @@ -17,29 +17,31 @@ Per stablecoin, every minute: ## Methodology in one paragraph -For each stable, every venue's bid/ask is polled (5 s for CEX, 12 s for on-chain). Samples are bucketed per minute, median per venue per minute, then a **liquidity-weighted median across venues** gives the canonical per-stable price for that minute. Deviation = `|price - 1.00| × 10000` in bps. The primary metric uses **USD-quoted samples only** (Coinbase, Kraken, Bitstamp, on-chain pools relative to USDC) — USDT-quoted samples (Binance USDC/USDT) feed a secondary metric so USDT's own peg drift doesn't contaminate every other stable's number. Outliers > 20% off peg are dropped; 10-20% capped at 10% for percentile stability; stale samples (> 5 min old) excluded. +For each stable, every venue's bid/ask is polled (5 s for CEX, 12 s for on-chain). Samples are bucketed per minute, median per venue per minute, then a **liquidity-weighted median across venues** gives the canonical per-stable price for that minute. Deviation = `|price - 1.00| × 10000` in bps. The primary metric uses **USD-quoted samples only** (Coinbase USDT-USD, Kraken, Bitstamp, plus the Curve 3pool for DAI). USDT-quoted samples (Binance USDC/USDT) feed a secondary metric so USDT's own peg drift doesn't contaminate every other stable's number. Outlier rule is multi-venue consensus: a sample > 2% off peg is kept only when another venue corroborates it in the same direction within 30 s; > 50% off is treated as a parser bug and dropped. -The bench page on openchainbench.com surfaces `quantile_over_time(0.99, peg_deviation_bps[24h])` as the headline "p99 24h deviation" per stable. +The DAI price is the geometric mean of Curve 3pool `get_dy` quoted in both directions (USDC→DAI and DAI→USDC). Both directions embed the same pool swap fee, so the geometric mean cancels it exactly; only genuine pool imbalance moves the number, which keeps the DAI series comparable to CEX mid prices. + +The bench page on openchainbench.com sorts the ledger on `quantile_over_time(0.50, peg_deviation_worst_bps[24h])` (the 24 h median of the per-minute worst deviation); the p99 column shows `quantile_over_time(0.99, ...)`, the depeg-tail number. ## Stables and venues | Stable | Sources (quote) | Notes | |---|---|---| | USDC | Kraken USDCUSD ($63M), Bitstamp usdcusd ($2.6M), Binance USDCUSDT ($2.8B, secondary) | Deepest coverage, gold-standard reference | -| USDT | Kraken USDTUSD ($184M), Bitstamp usdtusd ($12M) | The only no-key USD-quoted USDT markets with depth | +| USDT | Coinbase USDT-USD ($25M), Kraken USDTUSD ($184M), Bitstamp usdtusd ($12M) | The no-key USD-quoted USDT markets with depth | | FDUSD | Binance FDUSDUSDT ($41M, secondary only) | USDT-only depth; primary metric blank — that's honest | | USDe | Binance USDEUSDT ($1.8M, secondary only) | The Oct 2025 depeg star; thin but watched | -| DAI | Curve 3pool USDC→DAI + reverse | All CEX DAI markets are dead in 2026 (Binance zero book, Kraken thin, Bitstamp dead). On-chain is the only honest signal. | +| DAI | Curve 3pool get_dy, geometric mean of both directions (fee cancels) | All CEX DAI markets are dead in 2026 (re-checked 2026-07-08: Kraken DAIUSD ~$145k/24h, ~9 bps spread; Bitstamp zero volume; Coinbase delisted). On-chain is the only honest signal. | -## Source coverage matrix (live-validated 2026-05-20) +## Source coverage matrix (live-validated 2026-05-20, DAI + Coinbase re-checked 2026-07-08) -| Stable | Binance | Kraken | Bitstamp | Curve | Coverage verdict | -|---|---|---|---|---|---| -| USDT | (quote only) | ✅ $184M | ✅ $12M | indirect via 3pool | strong | -| USDC | ✅ $2.8B (USDT) | ✅ $63M | ✅ $2.6M | ✅ 3pool | strongest | -| DAI | dead | thin | dead | ✅ 3pool | on-chain only | -| FDUSD | ✅ $41M (USDT) | - | - | - | USDT-only | -| USDe | ✅ $1.8M (USDT) | - | - | - | USDT-only | +| Stable | Binance | Coinbase | Kraken | Bitstamp | Curve | Coverage verdict | +|---|---|---|---|---|---|---| +| USDT | (quote only) | ✅ $25M | ✅ $184M | ✅ $12M | indirect via 3pool | strong | +| USDC | ✅ $2.8B (USDT) | 404 (not listed) | ✅ $63M | ✅ $2.6M | ✅ 3pool | strongest | +| DAI | dead | delisted | thin ($145k) | dead | ✅ 3pool | on-chain only | +| FDUSD | ✅ $41M (USDT) | - | - | - | - | USDT-only | +| USDe | ✅ $1.8M (USDT) | - | - | - | - | USDT-only | V2 candidates (need a free signup): Alchemy/Defillama price feeds, Curve crvUSD/USDC pool (crvUSD), GHO/USDC pool (GHO), Curve PYUSD/USDC pool (PYUSD), Sky PSM (USDS direct). @@ -69,8 +71,11 @@ go run ./cmd/script ## PromQL recipes ```promql -# 24h p99 deviation per stable (the headline leaderboard) -quantile_over_time(0.99, peg_deviation_bps[24h]) +# 24h median of per-minute worst deviation (the ledger sort key) +quantile_over_time(0.50, peg_deviation_worst_bps[24h]) + +# 24h p99, the depeg-tail column +quantile_over_time(0.99, peg_deviation_worst_bps[24h]) # Cross-venue p99 gap (the bench's unique angle) quantile_over_time(0.99, peg_cross_venue_gap_bps[24h]) @@ -98,6 +103,6 @@ Standard OCB-miniapp shape - multi-stage Dockerfile, port 2112, internal-only on - **5 stables only in MVP** (USDC, USDT, FDUSD, USDe, DAI). Adding crvUSD/GHO/USDS/PYUSD is a v2 lift via more Curve pools. - **Ethereum mainnet only** for the on-chain source. Per-chain split (USDC-base, USDC-arbitrum, etc.) is a v2 build. - **Bridged variants** (USDC.e on Arbitrum, etc.) not included — they have a different risk profile (bridge inventory) and should never be rolled into the issuer's canonical metric. -- **Coinbase Advanced Exchange API** (`api.exchange.coinbase.com`) was attempted but returns 404 / NotFound for USDC-USD as of May 2026 — Coinbase's public REST shape has shifted again. Reinclude once a stable endpoint is found. +- **Coinbase Exchange API** (`api.exchange.coinbase.com`): USDT-USD ticker works and is polled (added 2026-07). USDC-USD returns 404 (USDC is not a listed product; Coinbase treats it as USD-equivalent) and DAI-USD is delisted, so Coinbase contributes USDT only. - **Liquidity weights are static** in config.go, sourced from a 2026-05-20 snapshot. Refresh quarterly or wire to a live $ vol fetcher. - **Histogram precision**: tier boundaries hardcoded for [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000] bps. Sub-1-bps deviations all bucket together — acceptable for the leaderboard's `bps integer` granularity. diff --git a/harnesses/stablecoin-peg/cmd/script/config.go b/harnesses/stablecoin-peg/cmd/script/config.go index 528c6014..af30f1f6 100644 --- a/harnesses/stablecoin-peg/cmd/script/config.go +++ b/harnesses/stablecoin-peg/cmd/script/config.go @@ -21,7 +21,7 @@ type Source struct { // Quote tags how a price should be interpreted. // -// - QuoteUSD → genuine USD pair (Coinbase, Kraken USDCUSD, Bitstamp). +// - QuoteUSD → genuine USD pair (Coinbase USDT-USD, Kraken USDCUSD, Bitstamp). // Goes into the primary metric directly. // - QuoteUSDT → USDT-quoted (Binance USDC/USDT). // **Excluded from primary** because USDT's own deviation would @@ -82,9 +82,14 @@ func sources() []Source { }, // ── USDT ──────────────────────────────────────────────── - // Kraken + Bitstamp are the only no-key USD-quoted USDT - // markets with meaningful depth. Binance USDT is a quote - // asset, not a tradable pair vs USD. + // Coinbase + Kraken + Bitstamp are the no-key USD-quoted + // USDT markets with meaningful depth. Binance USDT is a + // quote asset, not a tradable pair vs USD. + { + Stable: "usdt", Venue: "coinbase", Pair: "USDT-USD", Quote: QuoteUSD, + URL: envDefault("STABLE_URL_COINBASE_USDT", "https://api.exchange.coinbase.com/products/USDT-USD/ticker"), + Liquidity: 25_000_000, + }, { Stable: "usdt", Venue: "kraken", Pair: "USDTUSD", Quote: QuoteUSD, URL: envDefault("STABLE_URL_KRAKEN_USDT", "https://api.kraken.com/0/public/Ticker?pair=USDTUSD"), @@ -118,22 +123,21 @@ func sources() []Source { // ── DAI ───────────────────────────────────────────────── // CEX coverage for DAI is essentially dead in 2026 - // (Binance zero book, Kraken thin, Bitstamp dead). The - // only honest signal is on-chain via Curve 3pool. We - // don't include Binance/Kraken/Bitstamp DAI pairs to + // (re-validated 2026-07-08: Kraken DAIUSD ~$145k/24h with + // a ~9 bps spread, Bitstamp daiusd zero volume, Coinbase + // DAI-USD delisted). The only honest signal is on-chain + // via Curve 3pool. We don't include thin CEX DAI pairs to // avoid the thin-market noise contaminating the metric. + // + // The published price is the geometric mean of get_dy in + // both directions (see pollCurve3poolMid), which cancels + // the pool swap fee so the series is comparable to the + // CEX mid prices used for USDC/USDT. { - Stable: "dai", Venue: "curve_3pool", Pair: "usdc->dai", Quote: QuotePool, + Stable: "dai", Venue: "curve_3pool", Pair: "usdc<->dai mid", Quote: QuotePool, URL: envDefault("STABLE_URL_CURVE_RPC", "https://ethereum-rpc.publicnode.com"), Liquidity: 500_000_000, // 3pool TVL, approximate }, - // Convenience: also poll get_dy DAI->USDC to surface - // directional asymmetry; same pool but the reverse swap. - { - Stable: "dai", Venue: "curve_3pool_rev", Pair: "dai->usdc", Quote: QuotePool, - URL: envDefault("STABLE_URL_CURVE_RPC", "https://ethereum-rpc.publicnode.com"), - Liquidity: 500_000_000, - }, } } diff --git a/harnesses/stablecoin-peg/cmd/script/main.go b/harnesses/stablecoin-peg/cmd/script/main.go index 0b856479..cc314104 100644 --- a/harnesses/stablecoin-peg/cmd/script/main.go +++ b/harnesses/stablecoin-peg/cmd/script/main.go @@ -40,7 +40,7 @@ func main() { for _, s := range srcs { s := s interval := cexPollInterval - if s.Venue == "curve_3pool" || s.Venue == "curve_3pool_rev" { + if s.Venue == "curve_3pool" { interval = curvePollInterval } go runSourceLoop(ctx, s, interval, agg) diff --git a/harnesses/stablecoin-peg/cmd/script/sources.go b/harnesses/stablecoin-peg/cmd/script/sources.go index d438cac5..558fb1c2 100644 --- a/harnesses/stablecoin-peg/cmd/script/sources.go +++ b/harnesses/stablecoin-peg/cmd/script/sources.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "math" "net/http" "strconv" "strings" @@ -29,14 +30,10 @@ func poll(ctx context.Context, s Source) (float64, error) { return pollKraken(ctx, s.URL, s.Pair) case "bitstamp": return pollBitstamp(ctx, s.URL) + case "coinbase": + return pollCoinbase(ctx, s.URL) case "curve_3pool": - // USDC (i=1) -> DAI (i=0) with input 1_000_000 (= $1). - // Returns price of DAI per USDC. Decimal: input 6 dec, output 18 dec. - return pollCurveGetDy(ctx, s.URL, curve3poolAddr, 1, 0, "0xf4240", 1e18) - case "curve_3pool_rev": - // DAI (i=0) -> USDC (i=1) with input 1e18 (= $1). - // Returns price of USDC per DAI. Decimal: input 18 dec, output 6 dec. - return pollCurveGetDy(ctx, s.URL, curve3poolAddr, 0, 1, "0xde0b6b3a7640000", 1e6) + return pollCurve3poolMid(ctx, s.URL) default: return 0, fmt.Errorf("unknown venue: %s", s.Venue) } @@ -143,10 +140,65 @@ func pollBitstamp(ctx context.Context, url string) (float64, error) { return (bid + ask) / 2, nil } +// ─── Coinbase Exchange ───────────────────────────────────────────── + +type cbTicker struct { + Bid string `json:"bid"` + Ask string `json:"ask"` +} + +func pollCoinbase(ctx context.Context, url string) (float64, error) { + body, status, err := httpGet(ctx, url) + if err != nil { + return 0, err + } + if status != 200 { + return 0, fmt.Errorf("http %d", status) + } + var t cbTicker + if err := json.Unmarshal(body, &t); err != nil { + return 0, fmt.Errorf("parse: %w", err) + } + bid, e1 := strconv.ParseFloat(t.Bid, 64) + ask, e2 := strconv.ParseFloat(t.Ask, 64) + if e1 != nil || e2 != nil || bid <= 0 || ask <= 0 { + return 0, fmt.Errorf("invalid bid/ask: %q/%q", t.Bid, t.Ask) + } + return (bid + ask) / 2, nil +} + // ─── Curve onchain via eth_call get_dy(i, j, _dx) ────────────────── const curve3poolAddr = "0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7" +// pollCurve3poolMid returns a fee-free implied DAI/USD price from the +// Curve 3pool. `get_dy` embeds the pool's swap fee, so a single +// directional quote systematically reads low regardless of where DAI +// actually trades. Both directions carry the same (1 - fee) factor: +// +// fwd = get_dy(USDC->DAI) ~= (1/p) * (1-fee) DAI out per 1 USDC +// rev = get_dy(DAI->USDC) ~= p * (1-fee) USDC out per 1 DAI +// +// so sqrt(rev/fwd) = p exactly. The fee cancels; only genuine pool +// imbalance moves the number, which puts the DAI series in the same +// class as a CEX mid price (no fee, no direction artifact). +func pollCurve3poolMid(ctx context.Context, rpcURL string) (float64, error) { + // USDC (i=1) -> DAI (i=0), input 1_000_000 (= $1, 6 dec), output 18 dec. + fwd, err := pollCurveGetDy(ctx, rpcURL, curve3poolAddr, 1, 0, "0xf4240", 1e18) + if err != nil { + return 0, fmt.Errorf("fwd: %w", err) + } + // DAI (i=0) -> USDC (i=1), input 1e18 (= $1, 18 dec), output 6 dec. + rev, err := pollCurveGetDy(ctx, rpcURL, curve3poolAddr, 0, 1, "0xde0b6b3a7640000", 1e6) + if err != nil { + return 0, fmt.Errorf("rev: %w", err) + } + if fwd <= 0 || rev <= 0 { + return 0, fmt.Errorf("non-positive quote: fwd=%v rev=%v", fwd, rev) + } + return math.Sqrt(rev / fwd), nil +} + // pollCurveGetDy issues `get_dy(int128 i, int128 j, uint256 _dx)` on // the pool. We construct the calldata manually rather than depend on // a full ABI encoder — the function selector is the keccak prefix diff --git a/harnesses/validator-yield/README.md b/harnesses/validator-yield/README.md index ea78f859..0ac98361 100644 --- a/harnesses/validator-yield/README.md +++ b/harnesses/validator-yield/README.md @@ -6,7 +6,9 @@ Ranks validators by net yield = `gross APR × uptime` (slashing treated as negligible in v1, MEV included in `gross APR` where the upstream already folds it in — see "Honest scope" below). -Scope v1: **Solana** + **Hyperliquid** (Ethereum deferred to v2). +Scope: **Solana** + **Hyperliquid** + **Ethereum** (Ethereum as one +network-average consensus-layer series, `validator="beacon-network"`; +per-validator Ethereum gauges and MEV inclusion stay v2). ## Honest scope (no marketing) @@ -18,6 +20,15 @@ for transparency on the MEV-vs-stake split, but it is **not** subtracted from the gross figure. The number compared across validators is total APR × uptime, not "yield after MEV is removed". +Ethereum is the inverse case: its figure is **consensus-layer only** +(spec reward formula on live total effective balance from +ultrasound.money), so execution tips and MEV are **excluded** and +`mev_share_bps` is 0. It is a nominal solo-validator APR, not a +liquid-staking product APY. Source-selection notes (and rejected +candidates: beaconcha.in ethstore now keyed, Lido stETH APR is a +product rate, DefiLlama pools are LST APYs) live in +`cmd/script/ethereum.go`. + ## Sources (all free, no API key) | Chain | Source | Endpoint | @@ -25,7 +36,9 @@ APR × uptime, not "yield after MEV is removed". | Solana | Stakewiz | `GET https://api.stakewiz.com/validators` | | Solana (enrichment) | Jito Kobe | `GET https://kobe.mainnet.jito.network/api/v1/validators` | | Hyperliquid | Hyperliquid `/info` | `POST https://api.hyperliquid.xyz/info` body `{"type":"validatorSummaries"}` | +| Ethereum | ultrasound.money | `GET https://ultrasound.money/api/v2/fees/effective-balance-sum` | | Prices (SOL) | CoinGecko | `GET https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd` | +| Prices (ETH) | CoinGecko | `GET https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd` | | Prices (HYPE) | Hyperliquid `/info` | `POST` body `{"type":"metaAndAssetCtxs"}` | ## Cap diff --git a/harnesses/validator-yield/cmd/script/config.go b/harnesses/validator-yield/cmd/script/config.go index 984658d1..e07dc9ef 100644 --- a/harnesses/validator-yield/cmd/script/config.go +++ b/harnesses/validator-yield/cmd/script/config.go @@ -29,6 +29,10 @@ var ( jitoKobeURL = envDefault("VAL_ECON_JITO_KOBE_URL", "https://kobe.mainnet.jito.network/api/v1/validators") hyperliqURL = envDefault("VAL_ECON_HYPERLIQUID_URL", "https://api.hyperliquid.xyz/info") coingeckoURL = envDefault("VAL_ECON_COINGECKO_URL", "https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd") + + // Ethereum sources (see ethereum.go for the source-selection notes). + ultrasoundURL = envDefault("VAL_ECON_ULTRASOUND_URL", "https://ultrasound.money/api/v2/fees/effective-balance-sum") + coingeckoEthURL = envDefault("VAL_ECON_COINGECKO_ETH_URL", "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd") ) func envDefault(key, def string) string { diff --git a/harnesses/validator-yield/cmd/script/ethereum.go b/harnesses/validator-yield/cmd/script/ethereum.go new file mode 100644 index 00000000..fab1f522 --- /dev/null +++ b/harnesses/validator-yield/cmd/script/ethereum.go @@ -0,0 +1,220 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "time" +) + +const ( + ethereumChain = "ethereum" + ultrasoundSourceTag = "ultrasound_money" + coingeckoEthSourceTag = "coingecko_eth" +) + +// Ethereum source choice (evaluated live 2026-07-08, all with +// User-Agent "OpenChainBench-harness/1.0"): +// +// CHOSEN: ultrasound.money `/api/v2/fees/effective-balance-sum`. +// Free, keyless, one tiny JSON object ({slot, sum, timestamp}, sum +// in gwei) served from ultrasound's own beacon node. We plug the +// live total effective balance into the consensus-spec reward +// formula (see below) to get the network-average nominal +// consensus-layer APR for a 32 ETH solo validator. This matches +// the bench definition style: Hyperliquid publishes a nominal +// staking-only APR (`predictedApr`), and Solana publishes a +// nominal trailing APR. The Ethereum number is staking +// (consensus) only: attestation + proposer + sync-committee +// rewards in expectation, EXCLUDING execution-layer tips and MEV. +// +// REJECTED: beaconcha.in `/api/v1/ethstore/latest` (would have +// given a measured day-APR incl. execution rewards) — now returns +// "Unauthorized: a valid API key is required" even at 1 req/day; +// the free keyless tier was removed. REJECTED: Lido +// `eth-api.lido.fi/v1/protocol/steth/apr/sma` — free and keyless +// but it is a liquid-staking PRODUCT APR (net of Lido's 10% fee, +// includes EL rewards), not a validator yield; wrong definition +// for this bench. REJECTED: DefiLlama `yields.llama.fi/pools` — +// same objection, LST product APYs. REJECTED: full beacon-node +// validator scrape (`/eth/v1/beacon/states/head/validators`) — +// multi-hundred-MB response over ~1M validators, not a 5-minute +// HTTP poll; that path stays "v2" as the bench originally noted. +// +// Consensus-spec APR formula (Altair reward accounting): +// +// base_reward_per_increment = EFFECTIVE_BALANCE_INCREMENT(1e9 gwei) +// × BASE_REWARD_FACTOR(64) +// / sqrt(total_active_balance_gwei) +// +// A perfectly-performing validator earns, in expectation across +// attestation (54/64), proposer (8/64) and sync-committee (2/64) +// weights, exactly one full base reward per increment per epoch. +// With 82181.25 epochs/year (384 s epochs), the nominal APR is: +// +// apr = BASE_REWARD_FACTOR × EPOCHS_PER_YEAR / sqrt(total_gwei) +// +// This is the ideal (100% participation) network-average number. +// Realized network participation runs ~99.5%, so the published +// figure overstates the realized average by well under 2 bps. We do +// NOT fold participation in: measuring it honestly needs per-epoch +// attestation data (beacon-node ingestion, the v2 path). uptime_pct +// is published as 100 accordingly and the YAML prose says so. +const ( + ethBaseRewardFactor = 64.0 + ethEpochsPerYear = 365.25 * 24 * 3600 / 384 // 82181.25, 384 s per epoch +) + +// ultrasoundEffectiveBalance mirrors +// https://ultrasound.money/api/v2/fees/effective-balance-sum +// Verified shape (2026-07-08): +// +// {"slot":14724497,"sum":40389900000000000,"timestamp":"2026-07-08T13:39:47Z"} +// +// `sum` is total beacon-chain effective balance in gwei. +type ultrasoundEffectiveBalance struct { + Slot int64 `json:"slot"` + Sum float64 `json:"sum"` // gwei + Timestamp string `json:"timestamp"` +} + +func fetchEthEffectiveBalanceGwei(ctx context.Context, client *http.Client) (float64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ultrasoundURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0") + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("ultrasound http %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + var out ultrasoundEffectiveBalance + if err := json.Unmarshal(body, &out); err != nil { + return 0, fmt.Errorf("ultrasound parse: %w", err) + } + // Sanity bounds: anything under 1M or over 200M ETH staked is a + // data-quality failure, not a real reading. No publish on failure. + const gweiPerEth = 1e9 + stakedEth := out.Sum / gweiPerEth + if stakedEth < 1e6 || stakedEth > 2e8 { + return 0, fmt.Errorf("ultrasound implausible effective balance: %.0f ETH", stakedEth) + } + return out.Sum, nil +} + +// fetchEthereumPrice hits CoinGecko's free simple/price endpoint, +// same pattern (and same free-tier budget math) as fetchSolanaPrice: +// 1 call per 5-minute cycle against a ~30 req/min ceiling. +func fetchEthereumPrice(ctx context.Context, client *http.Client) (float64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, coingeckoEthURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0") + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("coingecko http %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + var payload map[string]map[string]float64 + if err := json.Unmarshal(body, &payload); err != nil { + return 0, fmt.Errorf("coingecko parse: %w", err) + } + v, ok := payload["ethereum"] + if !ok { + return 0, fmt.Errorf("coingecko missing ethereum key") + } + price := v["usd"] + if price <= 0 { + return 0, fmt.Errorf("coingecko invalid price %f", price) + } + return price, nil +} + +// scrapeEthereum publishes ONE synthetic series per gauge: +// validator="beacon-network", the network-average solo validator. +// Ethereum has ~1M active validator indices; per-validator gauges are +// not representable on the OCB Prom cardinality budget and, unlike +// Solana/Hyperliquid, no free keyless API exposes per-validator APR. +// Because consensus rewards are stake-weighted uniformly (every +// 32 ETH increment earns the same base reward in expectation), the +// network average IS the solo-validator nominal APR, so a single +// series loses no ranking information for this bench. +// +// Gauge semantics for the synthetic row: +// +// gross = net consensus-spec nominal APR (uptime published as +// 100; realized participation ~99.5%, see above) +// mev_share = 0 execution-layer tips/MEV deliberately excluded +// commission = 0 a solo validator pays no commission +// stake_usd TOTAL network effective balance in USD (this row +// represents the whole network, not one operator) +// jailed = 0 +// total_validators = 1 (series exposed by the harness, per the +// metric's documented definition; NOT the ~1M +// active validator count) +func scrapeEthereum(ctx context.Context, client *http.Client) { + totalGwei, err := fetchEthEffectiveBalanceGwei(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(ethereumChain, ultrasoundSourceTag).Inc() + fmt.Printf("[ethereum] ultrasound err: %v\n", err) + return + } + + ethPrice, err := fetchEthereumPrice(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(ethereumChain, coingeckoEthSourceTag).Inc() + fmt.Printf("[ethereum] coingecko err (stake_usd will be 0): %v\n", err) + ethPrice = 0 + } + + // apr = BASE_REWARD_FACTOR × EPOCHS_PER_YEAR / sqrt(total_gwei), + // as a fraction (0.026 = 2.6%) → bps via pctToBps. + apr := ethBaseRewardFactor * ethEpochsPerYear / math.Sqrt(totalGwei) + grossBps := pctToBps(apr) + + const ( + id = "beacon-network" + name = "Ethereum solo validator (network avg)" + ) + + uptimePct := 100.0 + netBps := grossBps * (uptimePct / 100) + stakedEth := totalGwei / 1e9 + stakeUSD := stakedEth * ethPrice + + validatorNetYieldBps.WithLabelValues(ethereumChain, id, name).Set(netBps) + validatorGrossYieldBps.WithLabelValues(ethereumChain, id, name).Set(grossBps) + validatorMevShareBps.WithLabelValues(ethereumChain, id, name).Set(0) + validatorCommissionBps.WithLabelValues(ethereumChain, id, name).Set(0) + validatorUptimePct.WithLabelValues(ethereumChain, id, name).Set(uptimePct) + validatorStakeUSD.WithLabelValues(ethereumChain, id, name).Set(stakeUSD) + validatorJailed.WithLabelValues(ethereumChain, id, name).Set(0) + + chainTotalValidators.WithLabelValues(ethereumChain).Set(1) + chainMedianNetYieldBps.WithLabelValues(ethereumChain).Set(medianBps([]float64{netBps})) + lastScrapeTimestamp.WithLabelValues(ethereumChain).Set(float64(time.Now().Unix())) + + fmt.Printf("[ethereum] published network-avg consensus APR %.1f bps (%.2fM ETH staked), ETH=$%.2f\n", + netBps, stakedEth/1e6, ethPrice) +} diff --git a/harnesses/validator-yield/cmd/script/main.go b/harnesses/validator-yield/cmd/script/main.go index 0b30787b..8f25c6c5 100644 --- a/harnesses/validator-yield/cmd/script/main.go +++ b/harnesses/validator-yield/cmd/script/main.go @@ -19,7 +19,7 @@ func main() { installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== Validator Economics Harness ===") fmt.Println("OpenChainBench bench #026 — net yield = gross APR + MEV − downtime") - fmt.Println("Scope v1: Solana + Hyperliquid (Ethereum deferred to v2)") + fmt.Println("Scope: Solana + Hyperliquid + Ethereum (consensus-layer, network avg)") fmt.Println() go func() { @@ -40,6 +40,7 @@ func main() { // hit independent upstreams so there's no rate-limit interaction. go runChainScraper(ctx, client, "solana", scrapeSolana) go runChainScraper(ctx, client, "hyperliquid", scrapeHyperliquid) + go runChainScraper(ctx, client, "ethereum", scrapeEthereum) sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) diff --git a/src/app/rss.xml/route.ts b/src/app/rss.xml/route.ts index 8d871f81..b794adc8 100644 --- a/src/app/rss.xml/route.ts +++ b/src/app/rss.xml/route.ts @@ -15,8 +15,12 @@ * Freshness model: * - `pubDate` per item = the bench's first commit (stable, from * `bench-dates.ts`). - * - `lastBuildDate` on the channel = the most recent bench's pubDate. - * Aggregators use this to decide whether to refetch the body. + * - `lastBuildDate` on the channel = feed generation time. Item + * descriptions embed live headline numbers, so the body genuinely + * changes between renders. Deriving it from the newest item's + * pubDate (the old behaviour) froze the channel whenever + * `bench-dates.ts` lagged behind the benchmarks/ directory, and + * aggregators stopped refetching. * * Cache: 5 min edge TTL so a brand new bench surfaces in aggregator * polls within minutes of being merged, without hammering the data @@ -84,7 +88,12 @@ export async function GET() { })) .sort((a, c) => c.pubDate.getTime() - a.pubDate.getTime()); - const latest = items[0]?.pubDate ?? new Date(); + // Generation time, not the newest item's pubDate: item dates come from + // a committed static map that can lag new benches, which froze + // lastBuildDate (stuck at 2026-06-09) and made aggregators skip the + // refetch. The route revalidates every 5 min, so this moves with the + // cache, matching how often the live numbers in descriptions change. + const lastBuild = new Date(); const self = `${SITE.url}/rss.xml`; const lines: string[] = []; lines.push(''); @@ -96,7 +105,7 @@ export async function GET() { lines.push(` ${SITE.url}`); lines.push(` ${escapeXml(FEED_DESCRIPTION)}`); lines.push(" en"); - lines.push(` ${toRfc822(latest)}`); + lines.push(` ${toRfc822(lastBuild)}`); lines.push( ` `, ); diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index 4ce05396..d317e9e2 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -62,6 +62,18 @@ function DimensionRow({ ); } +/** "2026-07-06 14:00 UTC" from the bench's lastRunAt ISO string. Fixed + * UTC rendering so server and client HTML agree (no hydration drift) + * and answer engines get an unambiguous freshness stamp. */ +function fmtAsOfUtc(iso: string): string | null { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad( + d.getUTCDate(), + )} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`; +} + /** Mutate `url.searchParams` to keep one dimension param in sync. * Removes the param when the value is the first option (the implicit * default) so canonical URLs stay short. */ @@ -333,6 +345,8 @@ export function BenchmarkBody({ }; }, [benchmark, hasLayerSplit, layer]); + const asOfUtc = benchmark ? fmtAsOfUtc(benchmark.lastRunAt) : null; + // View switcher state. Per-bench, persisted via localStorage. Default // mirrors the heuristic the page used before the switcher existed so // an anonymous user with no prior preference sees the same layout @@ -764,6 +778,17 @@ export function BenchmarkBody({ : "Product ledger · sorted by p50"}

+ {/* Visible freshness stamp next to the numbers. Answer + engines quote data far more readily when the page says + when it was measured. Uses the harness's lastRunAt + (real data timestamp), not build time. */} + {asOfUtc && ( +

+ Data as of{" "} + , + refreshed continuously. +

+ )} )} diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index bee4be25..71749795 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -344,10 +344,24 @@ export function LedgerTable({ ))} )} - +
+ {/* Screen-reader / crawler caption naming the benchmark. Answer + engines extract tabular numbers far more reliably when the + table itself says what it measures. */} + - - - + {hasSlots && ( )} {secondary && ( - )} - - + + - + {hasSlots && ( )} - {secondary && } + {secondary && ( + + )} + {/* Purely visual rule between the header block and the rows. + A td (not th) so assistive tech and crawlers don't read an + empty header spanning every column. */} -
+ {benchmark.title} benchmark ledger. {benchmark.metric} per provider, + live measured data. +
+ Product {customCols @@ -368,18 +383,25 @@ export function LedgerTable({ ? benchmark.metric : "Latency aggregates"} + {customCols ? colLabel(customCols[0]) : activePanel ? "Value" : "p50"} Reliability Trend + Trend + @@ -387,14 +409,21 @@ export function LedgerTable({ + {secondary}
+ Color + + № + 24h + 24h + Value + Value +
{customCells ? ( - customCells.slice(1).map((c, idx) => ( + customCells.slice(1).map((_, idx) => ( = { debridge: { color: "#FFB347" }, // pumpkin lifi: { color: "#F5C518" }, // saturated yellow relay: { color: "#26D49B" }, // mint + across: { color: "#6CF9D8" }, // across aqua // ─── Trading / perps ─── hyperliquid: { color: "#22D3EE" }, // cyan diff --git a/src/lib/materialize/load.ts b/src/lib/materialize/load.ts index 03fe0cdd..e03887e0 100644 --- a/src/lib/materialize/load.ts +++ b/src/lib/materialize/load.ts @@ -19,6 +19,7 @@ import type { } from "@/types/benchmark"; import { Prometheus } from "@/lib/prometheus"; import { SpecSchema, type Spec } from "@/lib/spec-schema"; +import { REMOVED_BENCH_SLUGS } from "@/lib/removed-benches"; import { renderBenchmarkText } from "@/lib/bench-template"; import { liveResults as liveProviderResults } from "@/lib/provider-filters"; import { @@ -106,7 +107,14 @@ async function loadSpecsFromDisk(): Promise { return result.data; }) ); - return parsed.filter((s): s is Spec => s !== null); + const specs = parsed.filter((s): s is Spec => s !== null); + // Prod-only gate: staging-pipeline benches never reach the prod + // catalog, hubs, feeds or citable API. Direct URL hits get a 410 + // from middleware. Staging/preview/dev render everything. + if (process.env.VERCEL_ENV === "production") { + return specs.filter((s) => !REMOVED_BENCH_SLUGS.has(s.slug)); + } + return specs; } export function buildEditorial( diff --git a/src/lib/removed-benches.ts b/src/lib/removed-benches.ts new file mode 100644 index 00000000..d50e50bc --- /dev/null +++ b/src/lib/removed-benches.ts @@ -0,0 +1,32 @@ +/** + * Bench slugs that must not exist on production. They render normally + * on dev / staging / preview (VERCEL_ENV !== "production") and are the + * staging pipeline: benches still being validated, or held back for a + * partnership announcement. + * + * Enforced in three places, all driven by this single set: + * 1. src/middleware.ts returns 410 Gone for direct URL hits on prod + * (SEO-correct signal for previously indexed URLs). + * 2. src/lib/materialize/load.ts drops the specs at loader level on + * prod, so the catalog index, category pages, /rpc hub, compare + * pairs, /api/citable, llms.txt and RSS never link or cite them. + * 3. src/app/sitemap.ts excludes the routes on prod. + * + * Moving a bench to production = remove its slug here, bump the + * bench-set cache keys in src/lib/spec.ts, ship dev to main. + */ +export const REMOVED_BENCH_SLUGS = new Set([ + // retired for good + "bridge-revenue", + "evm-quote-latency", + // duplicate of solana-tx-landing (bench 016); the 027 active-probe + // variant never got data on prod and shows an empty placeholder + "solana-tx-landing-latency", + // staging pipeline, held back until validated / announced + "indexing-freshness", + "monad-rpc", + "megaeth-rpc", + "rpc-keyed-latency", + "explorer-chain-coverage", + "portfolio-chain-coverage", +]); diff --git a/src/lib/spec.ts b/src/lib/spec.ts index 5bf4ce85..d2a59e88 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -263,7 +263,11 @@ const loadBenchmarkUnfilteredCached = unstable_cache( // changed; cached v19 entries would miss the new chains. // v21: +bench 067 (portfolio-chain-coverage). Bench SET changed. // v22: +bench 068 (explorer-chain-coverage) + Explorers category. - ["bench-unfiltered-v22"], + // v23: prod-only bench gate (REMOVED_BENCH_SLUGS filtered at loader + // level on VERCEL_ENV=production). Bench SET now differs per env, so + // the env is part of the cache key to keep prod and preview entries + // from colliding. + ["bench-unfiltered-v23", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] }, ); @@ -412,7 +416,8 @@ const loadAllBenchmarksCached = unstable_cache( // 055-066; sitemap/citable/products must pick up the new slugs). // v25: bumped with bench-unfiltered-v21 (+bench 067 portfolio-chain-coverage). // v26: bumped with bench-unfiltered-v22 (+bench 068 explorer-chain-coverage). - ["all-benchmarks-v26"], + // v27: bumped with bench-unfiltered-v23 (prod-only bench gate); env in key. + ["all-benchmarks-v27", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] }, ); export const loadAllBenchmarks = cache(loadAllBenchmarksCached); @@ -487,7 +492,8 @@ const loadBenchmarkFiltered = unstable_cache( // 055-066). // v13: bumped with bench-unfiltered-v21 (+bench 067 portfolio-chain-coverage). // v14: bumped with bench-unfiltered-v22 (+bench 068 explorer-chain-coverage). - ["bench-filters-v14"], + // v15: bumped with bench-unfiltered-v23 (prod-only bench gate); env in key. + ["bench-filters-v15", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] } ); diff --git a/src/middleware.ts b/src/middleware.ts index bfb5e389..544dcb0b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -44,20 +44,12 @@ const CANONICAL_NO_QUERY = new Set([ "/api/openapi.json", ]); -/** - * Bench slugs that existed on production at some point and were later - * removed (or that were never on production but their dev path could - * have leaked into the index via staging crawl). 410 on prod, normal - * render on dev / preview. - * - * Also re-exported so the sitemap can exclude these routes on prod; - * emitting them advertises URLs that middleware immediately 410s, - * which fails the sitemap-smoke gate and rolls back every deploy. - */ -export const REMOVED_BENCH_SLUGS = new Set([ - "bridge-revenue", - "evm-quote-latency", -]); +// Single source of truth for prod-excluded bench slugs. Lives in its +// own module (not here) so the spec loader and the materialize worker +// can import it without pulling next/server. Re-exported for the +// sitemap, which historically imports it from "@/middleware". +import { REMOVED_BENCH_SLUGS } from "@/lib/removed-benches"; +export { REMOVED_BENCH_SLUGS }; const BENCH_PATH = /^\/benchmarks\/([a-z0-9][a-z0-9-]{0,79})\/?$/; // `/compare/-vs-` with both sides as standard provider slug